How to check the browser locale

Open DevTools, Console tab, and read three values: navigator.language, navigator.languages and Intl.DateTimeFormat().resolvedOptions().locale. On the machine used here the first two answered uk-UA and the third answered uk, with no region. The Accept-Language header that same page sent carries quality values none of the three expose.

Why check this

A report that says "the site came up in the wrong language" names one of four settings, and the reporter does not know which. The browser UI language, the ordered language list, the formatting locale and the header on the wire are configured separately and can all disagree. Read all four before reproducing anything, and read them again on the machine that filed the report.

Run this at the start of a localization session and after a test machine is re-imaged. It prevents an afternoon spent on a formatting defect that exists only because the runner sits on a different regional setting than the browser.

Prerequisites

import { createServer } from 'node:http';

createServer((req, res) => {
  const sent = req.headers['accept-language'] ?? '(absent)';
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Vary: 'Accept-Language' });
  res.end(
    '<!doctype html><meta charset="utf-8"><title>Locale probe</title>' +
    '<p id="header">Accept-Language received: ' + sent + '</p>'
  );
}).listen(8392, '127.0.0.1', () => console.log('listening on 8392'));
import { open } from '../../scripts/browser/session.mjs';

const s = await open();
try {
  await s.goto('http://127.0.0.1:8392/');
  console.log(await s.page.evaluate(`JSON.stringify((${process.argv[2]}))`));
} finally {
  await s.close();
}

Steps

  1. Step 1.

    Read the language the browser reports, in both forms.

    [navigator.language, navigator.languages]
    
    ["uk-UA",["uk-UA","uk","en-US","en"]]

    navigator.language is one tag, the first entry of the list. navigator.languages is the whole ordered preference list, and it is the one a language switcher should read.

  2. Step 2.

    Read the locale the formatting APIs default to, which is a different value.

    Intl.DateTimeFormat().resolvedOptions()
    
    {"locale":"uk","calendar":"gregory","numberingSystem":"latn","timeZone":"Europe/Kiev","year":"numeric","month":"2-digit","day":"2-digit"}

    The region subtag is gone. Every number and date the page formats without an explicit locale uses uk, while navigator.language says uk-UA.

  3. Step 3.

    Pass the navigator tag explicitly and watch the region survive.

    [navigator.language, new Intl.DateTimeFormat(navigator.language).resolvedOptions().locale]
    
    ["uk-UA","uk-UA"]

    The region was not dropped for want of data. Chrome's default formatting locale comes from its own interface language, which holds no region, so the two values differ until the page passes a tag.

  4. Step 4.

    Read what the browser put on the wire, from the probe page.

    document.getElementById('header').textContent
    
    "Accept-Language received: uk-UA,uk;q=0.9,en-US;q=0.8,en;q=0.7"

    The same four tags as navigator.languages, with quality values Chrome generated. The JavaScript list has no q and no wildcard, so it is not the header, and a server-rendered page chooses its language from the header.

  5. Step 5.

    Turn the tag into something a defect report can carry.

    [navigator.language, new Intl.DisplayNames(['en'], { type: 'language' }).of(navigator.language)]
    
    ["uk-UA","Ukrainian (Ukraine)"]

    Intl.DisplayNames reads the same CLDR data the formatters use, so the name matches what the browser resolved instead of a lookup table maintained in the test code.

  6. Step 6.

    Read the separators this locale uses, as code points rather than glyphs.

    new Intl.NumberFormat().formatToParts(1234567.89)
      .map((p) => [p.type, p.type === 'group' || p.type === 'decimal'
        ? 'U+' + p.value.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')
        : p.value])
    
    [["integer","1"],["group","U+00A0"],["integer","234"],["group","U+00A0"],["integer","567"],["decimal","U+002C"],["fraction","89"]]

    The thousands separator is U+00A0, a no-break space, not the ASCII space it looks like on screen.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | navigator.language carries a region and resolvedOptions().locale does not | The default formatting locale comes from the browser interface language | Pass the tag explicitly, as in step 3, or accept formatting without the region. | | The header has tags that navigator.languages does not | The list and the header were set by different mechanisms | Read How to change locale in chrome for which override moves which. | | timeZone is not the country of the locale | Zone and language are separate settings | Verify the zone with How to test timezone handling. | | A separator prints as U+00A0 or U+202F | The locale groups digits with a no-break space | Compare code points in the assertion, not the glyph you typed. | | navigator.languages has one entry and the header has four | A flag or an extension is overriding the list | Read both again in a clean profile before filing anything. |

Common mistakes

Sign: A formatting test passes locally and fails in CI with output that looks identical.Cause: The group separator in many locales is U+00A0 and the expected string in the test was typed with an ASCII space, U+0020. Step 6 prints the code points. Two strings that render the same on screen are not equal, and the CI diff shows nothing, because both separators draw as a gap.
Sign: A test asserts the formatting locale is uk-UA and reads uk instead.Cause: The default Intl locale is not navigator.language. Chrome takes it from its interface language, which had no region here, while navigator.language takes the region from the operating system. Step 3 passes the same tag explicitly and gets uk-UA back, so the data exists and only the default lacked it.
Sign: The server renders English while navigator.language says something else.Cause: A server-rendered page never sees navigator. It sees the Accept-Language header, which is a separate list carrying quality values, as step 4 shows. Read the header before assuming the translation layer is at fault.

What to check next

FAQ

What is the difference between navigator.language and navigator.languages?

navigator.language is the first entry of navigator.languages. The list is the ordered preference set, and it is what a language switcher should read. Reading only the first entry throws away every fallback the user configured.

How do I check the browser locale in Chrome without DevTools?

Load a page that echoes the request headers and read Accept-Language, as in step 4. That is the wire value, which is what a server-rendered page uses.

Why does Intl report a different locale than navigator?

The default Intl locale comes from the browser interface language, and navigator.language comes from the operating system preference. Step 3 shows both values in one line.

How do I check which language a website is serving?

Read the Content-Language response header and the lang attribute on the html element. The browser locale is a request; the response states what was served.

Does an incognito window change the locale?

No. Language settings belong to the profile and an incognito window inherits them. A separate profile, or a Chrome started with a language flag, is what changes the answer.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2

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.

basic6 minpublished updated Maks Verny