How to check the html lang attribute

Read document.documentElement.lang, then test the tag with Intl.getCanonicalLocales. A missing attribute and an empty one both report an empty string, so read hasAttribute('lang') as well. In the capture below lang="EN_US" throws a RangeError and matches no :lang(en) rule, while lang="en-US" matches seven elements.

Why check this

Run this on every template, before release, and again after a localisation build starts writing the tag from a config value. It takes under a minute and it is the check most often skipped, because the attribute lives in a layout file that nobody opens.

The failure is a page read out in the wrong voice. A speech synthesiser picks its pronunciation rules from the document language. With no language it falls back to the user's own, so English product text is read with German phonetics and stops being words. Hyphenation, quotation marks and the spell checker take the same value, so the defect reaches sighted users too.

Prerequisites

// lang-demo.mjs  -  six pages, one lang defect each. node lang-demo.mjs
import { createServer } from 'node:http';
const html = {
  '/missing': '<html>',
  '/empty': '<html lang="">',
  '/underscore': '<html lang="EN_US">',
  '/valid': '<html lang="en-US">',
  '/inline': '<html lang="en-US">',
  '/wrong': '<html lang="en">',
};
const body = {
  '/inline': '<p>The German term is <span lang="de">Dateiendung</span>, and the French one is <span lang="fr-CA">extension</span>.</p>',
  '/wrong': '<p>Die Datei wird auf dem Server gespeichert und kann von dem Nutzer mit einem Klick wieder geladen werden.</p>',
};
createServer((req, res) => {
  const url = req.url.split('?')[0];
  res.setHeader('content-type', 'text/html; charset=utf-8');
  res.end(`<!doctype html>${html[url] ?? '<html lang="en-US">'}<head><meta charset="utf-8"><title>Lang demo</title></head>
<body><h1>Glossary</h1>${body[url] ?? '<p>File extension.</p>'}</body></html>`);
}).listen(9153, () => console.log('http://localhost:9153/missing /empty /underscore /valid /inline /wrong'));

Steps

  1. Step 1.

    Start the demo server and leave it running in its own terminal.

    node lang-demo.mjs
    
    http://localhost:9153/missing /empty /underscore /valid /inline /wrong
  2. Step 2.

    Read the attribute four ways on each page: the raw attribute, the DOM property, the canonical form of the tag, and what the tag matches in CSS.

    // lang.mjs  -  node lang.mjs <url> [<url> ...]
    import { open } from './ax.mjs';
    const s = await open();
    try {
      for (const url of process.argv.slice(2)) {
        await s.goto(url);
        console.log(await s.page.evaluate((u) => {
          const html = document.documentElement;
          const attr = html.getAttribute('lang');
          let canonical;
          try { canonical = Intl.getCanonicalLocales(attr || 'und').join(','); }
          catch (e) { canonical = e.name + ': ' + e.message; }
          return [
            `${u}`,
            `  hasAttribute('lang') = ${html.hasAttribute('lang')}`,
            `  getAttribute('lang') = ${JSON.stringify(attr)}`,
            `  documentElement.lang  = ${JSON.stringify(html.lang)}`,
            `  Intl.getCanonicalLocales -> ${canonical}`,
            `  html.matches(':lang(en)') = ${html.matches(':lang(en)')}`,
            `  :lang(en) elements = ${document.querySelectorAll(':lang(en)').length}`,
          ].join('\n');
        }, url));
      }
    } finally {
      await s.close();
    }
    
    node lang.mjs http://localhost:9153/missing http://localhost:9153/empty http://localhost:9153/underscore http://localhost:9153/valid
    
    http://localhost:9153/missing
    hasAttribute('lang') = false
    getAttribute('lang') = null
    documentElement.lang  = ""
    Intl.getCanonicalLocales -> und
    html.matches(':lang(en)') = false
    :lang(en) elements = 0
    http://localhost:9153/empty
    hasAttribute('lang') = true
    getAttribute('lang') = ""
    documentElement.lang  = ""
    Intl.getCanonicalLocales -> und
    html.matches(':lang(en)') = false
    :lang(en) elements = 0
    http://localhost:9153/underscore
    hasAttribute('lang') = true
    getAttribute('lang') = "EN_US"
    documentElement.lang  = "EN_US"
    Intl.getCanonicalLocales -> RangeError: Invalid language tag: EN_US
    html.matches(':lang(en)') = false
    :lang(en) elements = 0
    http://localhost:9153/valid
    hasAttribute('lang') = true
    getAttribute('lang') = "en-US"
    documentElement.lang  = "en-US"
    Intl.getCanonicalLocales -> en-US
    html.matches(':lang(en)') = true
    :lang(en) elements = 7

    The first two pages differ in the markup and agree in every reading except hasAttribute. The third carries a tag that no parser accepts, and the page renders without a warning of any kind.

  3. Step 3.

    List the passages that declare their own language, and count the ones that inherit the document language.

    // lang-inline.mjs  -  node lang-inline.mjs <url>
    import { open } from './ax.mjs';
    const s = await open(process.argv[2]);
    try {
      console.log(await s.page.evaluate(() => {
        const out = [];
        for (const e of document.querySelectorAll('[lang]')) {
          const tag = e.getAttribute('lang');
          let valid = true;
          try { Intl.getCanonicalLocales(tag); } catch { valid = false; }
          const inherited = e.parentElement?.closest('[lang]')?.getAttribute('lang') ?? '(none)';
          out.push(`  <${e.tagName.toLowerCase()} lang="${tag}"> valid=${valid} inherits-from="${inherited}" text="${e.textContent.trim().slice(0, 24)}"`);
        }
        const noLang = [...document.querySelectorAll('body *')].filter(
          (e) => !e.children.length && e.textContent.trim() && !e.closest('[lang]:not(html)')
        ).length;
        out.push(`  ${out.length} element(s) carry lang, ${noLang} leaf element(s) fall back to the document language`);
        return out.join('\n');
      }));
    } finally {
      await s.close();
    }
    
    node lang-inline.mjs http://localhost:9153/inline
    
      <html lang="en-US"> valid=true inherits-from="(none)" text="Lang demo
    GlossaryThe Ge"
    <span lang="de"> valid=true inherits-from="en-US" text="Dateiendung"
    <span lang="fr-CA"> valid=true inherits-from="en-US" text="extension"
    3 element(s) carry lang, 1 leaf element(s) fall back to the document language

    Two foreign words, each wrapped in its own tag, each inheriting from en-US. That is what 3.1.2 asks for, and it is the part a page-level check never reaches.

  4. Step 4.

    Compare the declared language with the language the body text is written in. The scan counts function words, which is enough to catch a template that ships one tag for every locale.

    // lang-content.mjs  -  node lang-content.mjs <url>
    import { open } from './ax.mjs';
    const s = await open(process.argv[2]);
    try {
      console.log(await s.page.evaluate(() => {
        const STOP = {
          en: ['the', 'and', 'of', 'to', 'is', 'in', 'a', 'with', 'from', 'on'],
          de: ['der', 'die', 'das', 'und', 'von', 'mit', 'auf', 'dem', 'ein', 'wird'],
          fr: ['le', 'la', 'les', 'et', 'de', 'du', 'des', 'un', 'une', 'est'],
          es: ['el', 'la', 'los', 'de', 'y', 'que', 'en', 'un', 'una', 'con'],
        };
        const words = document.body.innerText.toLowerCase().match(/[\p{L}']+/gu) ?? [];
        const score = Object.fromEntries(
          Object.entries(STOP).map(([l, list]) => [l, words.filter((w) => list.includes(w)).length])
        );
        const best = Object.entries(score).sort((a, b) => b[1] - a[1])[0];
        const declared = document.documentElement.lang;
        return [
          `  declared: ${declared || '(none)'}`,
          `  stopword hits: ${JSON.stringify(score)}`,
          `  content looks like: ${best[1] ? best[0] : 'undecided'} (${best[1]} of ${words.length} words)`,
          `  verdict: ${best[1] && !declared.toLowerCase().startsWith(best[0]) ? 'MISMATCH' : 'consistent'}`,
        ].join('\n');
      }));
    } finally {
      await s.close();
    }
    
    node lang-content.mjs http://localhost:9153/wrong
    
      declared: en
    stopword hits: {"en":0,"de":8,"fr":0,"es":0}
    content looks like: de (8 of 19 words)
    verdict: MISMATCH

    Eight German function words in nineteen, and a tag that says English. Every reading in step 2 passes on this page.

  5. Step 5.

    Run the same reading against a public page, so you know the script agrees with a page that is correct.

    node lang.mjs https://example.com/
    
    https://example.com/
    hasAttribute('lang') = true
    getAttribute('lang') = "en"
    documentElement.lang  = "en"
    Intl.getCanonicalLocales -> en
    html.matches(':lang(en)') = true
    :lang(en) elements = 12

    A two-letter tag, canonical, and every element in the document inherits it. Captured on 2026-09-11.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | hasAttribute = false | The attribute is absent | Add lang to the html element in the layout | | hasAttribute = true with an empty value | A template wrote an empty variable | Fix the value, and fail the build when it is empty | | RangeError: Invalid language tag | The tag is not BCP 47 | Replace the underscore with a hyphen: en-US, not EN_US | | :lang(en) elements = 0 on an English page | No rule keyed to the language applies any more | Fix the tag, then re-run and count again | | verdict: MISMATCH | The tag disagrees with the text | Set the tag per locale, not once for the whole build | | A passage with no lang of its own | It inherits the document language | Wrap foreign passages in a span with their own tag |

Common mistakes

Sign: An audit reads documentElement.lang, gets an empty string, and cannot say whether the attribute is missing or empty.Cause: The DOM property reflects both states as an empty string. On the demo pages /missing and /empty produced identical output on five readings of six. Only hasAttribute('lang') separates them, and the two have different causes: one is a layout file that never had the attribute, the other is a template variable that resolved to nothing.
Sign: lang=EN_US ships and nothing anywhere reports a problem.Cause: An invalid tag is not a parse error. The page renders, the console stays empty, and the attribute is present for any check that only asks whether it exists. What stops is the matching: Intl.getCanonicalLocales throws RangeError, html.matches(':lang(en)') returns false, and the :lang(en) element count drops from seven to zero. Underscores arrive from locale codes copied out of Java or gettext.
Sign: The function-word scan in step 4 answers undecided on a page that is clearly in one language.Cause: It counts common words, so a page with three words of body text gives it nothing to count. On /valid it reported 0 of 3 words and abstained rather than guessing. Treat undecided as no result, run it on a page with a paragraph of prose, and never read a clean run on a short page as a pass.

What to check next

FAQ

What does missing required lang attribute mean?

An audit tool found no lang on the html element. The page renders, and the speech synthesiser falls back to the user's own language for pronunciation. It is a WCAG 3.1.1 failure at Level A, and the fix is one attribute in the layout file.

Is lang="en" enough, or do I need lang="en-US"?

en is enough for 3.1.1. Add the region when something depends on it, such as a date format or a spelling dictionary. Both are valid BCP 47 tags, and step 2 shows both passing Intl.getCanonicalLocales.

How do I check the lang attribute of a page quickly?

Open DevTools, Elements panel, and read the opening html tag. That catches missing and empty. It will not tell you that EN_US is invalid, which is why step 2 runs the tag through Intl.getCanonicalLocales.

Does the lang attribute affect anything besides screen readers?

Yes. It selects hyphenation, the quotation marks CSS inserts, the spell checker dictionary, and font selection for scripts with regional shapes. On the demo page the :lang(en) count went from seven to zero when the tag became invalid, and every rule keyed to language stopped applying.

Do I need lang on every foreign word?

On passages, not on proper nouns or on words that have entered the document language. Step 3 marks two terms, each with its own tag. WCAG 3.1.2 is Level AA and lists names and technical terms as exceptions.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic7 minpublished updated Maks Verny