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
- Chrome 152. Every expression below also works typed into the DevTools Console on any page.
- Node 22 with full ICU. Confirm with
node -p "process.versions.icu", which answered78.2here. - A probe page, so the header the browser sent is readable next to what the page reports. Save as
locpage.mjs, runnode locpage.mjs, then openhttp://127.0.0.1:8392/.
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'));
- Stop it afterwards:
netstat -ano | grep 8392, thenpowershell -Command "Stop-Process -Id <pid> -Force". - The captures below came from one headless Chrome 152 on one Windows machine on 2026-09-12, driven by the script below so each result prints instead of rendering in a console. Your machine reports its own locale.
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
- Step 1.
Read the language the browser reports, in both forms.
[navigator.language, navigator.languages]["uk-UA",["uk-UA","uk","en-US","en"]]navigator.languageis one tag, the first entry of the list.navigator.languagesis the whole ordered preference list, and it is the one a language switcher should read. - 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, whilenavigator.languagesaysuk-UA. - 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.
- 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 noqand no wildcard, so it is not the header, and a server-rendered page chooses its language from the header. - 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.DisplayNamesreads 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. - 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
What to check next
- How to check accept language header: the header from step 4, and how a server should rank it.
- How to change locale in chrome: four ways to override these values, and what each one leaves untouched.
- How to check the thousand separator and decimal separator: the separators from step 6, across every locale you ship.
- How to test timezone handling: the other field
resolvedOptions()returned.
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.
Related on this site
basic6 minpublished updated Maks Verny