# How to test the Accept-Language header properly

> Set Accept-Language alone and the page still formats dates for your own machine. A real language test moves the header, navigator.languages and Intl.

Source: https://mosaicbrowser.com/blog/accept-language-testing

## Testing a site in another language means more than one header

Set Accept-Language alone and the page still formats dates for your own machine. A real language test moves the header, navigator.languages and Intl.

![4: de-AT in the header, 1/1/1970 on the page](https://mosaicbrowser.com/covers/accept-language-testing.webp)

To test the Accept-Language header on its own, send it by hand with curl or a request tool and read what the server returns. That answers one question: does the server pick the right language. It does not tell you what a visitor sees, because the page's script reads the language a second way, `navigator.languages`, and formats dates and numbers a third way, through `Intl`. Change only the header and you get a page that disagrees with itself: German text, American dates.

So there are four things to move, and they move separately.

## The four places a language lives

1. **The `Accept-Language` header.** Sent with every request. The server reads it to choose a translation or a store front.
2. **`navigator.language` and `navigator.languages`.** The same list, read by the page's script. Single-page apps often decide the language here instead of on the server.
3. **`Intl` and every `toLocale*` call.** `Intl.DateTimeFormat`, `NumberFormat`, `Collator`, `ListFormat`, `RelativeTimeFormat`, plus `toLocaleDateString` and the month and weekday names. These take their default from ICU, the library underneath, not from the language list.
4. **The browser's own interface language.** Menus, error pages and permission prompts. A site cannot read it directly, but a tester sees it in every screenshot.

A real visitor from Vienna has all four on Austrian German. A test that moves one of them describes nobody.

## How to test the Accept-Language header on its own

For the server half, a request tool is the right instrument:

```
curl -s -H "Accept-Language: de-AT,de;q=0.9,en;q=0.8" https://example.com/ | head

```

Try a list with a language the site does not have first, a list with q-values in an unusual order, and an empty header. That covers the negotiation code. Then stop using curl, because from here on the questions are about the browser.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: REST API](https://mosaicbrowser.com/features/rest-api)

## What changes when you switch the language in Chrome

Chrome's language list (in settings, or the `--accept-lang` switch at launch) moves the header and `navigator.languages` together. That is better than an extension that edits the header, which leaves the page's script reading your real list. It still leaves `Intl` where it was.

We measured this on a stock Chrome while chasing a different bug. With `--accept-lang` set to `de-AT`, `navigator.language` said `de-AT` and `Intl.DateTimeFormat().resolvedOptions().locale` said `en-US`. We then tried `--lang`, and the environment variables `LANG` and `LC_ALL`, and the macOS preferences `AppleLocale` and `AppleLanguages`. All four left `Intl` on `en-US`.

The one thing that moved it was the DevTools protocol's `setLocaleOverride` call. Same Chrome, same page, one extra call:

```
before   navigator.language de-AT   Intl en-US   1/1/1970   1,234.5   January
after    navigator.language de-AT   Intl de-AT   1.1.1970   1 234,5   Jänner

```

"Jänner" is the Austrian name for January, and it is the kind of detail a translation review catches and a header test never reaches. For manual testing, the Sensors panel in DevTools exposes the same override as a locale field. Playwright and Puppeteer can send it for you; Playwright's `locale` option on a browser context covers the header, `navigator.language` and `Intl` together.

The interface language is the odd one. On Windows and Linux it is a Chrome setting; on a Mac, stock Chrome follows the system language. If your screenshots must show German menus on a Mac, that is a separate change from everything above.

## Check the worker as well as the page

Pages are not the only script on a site. A service worker reads `navigator.languages` too, and it can disagree with the window. Our launcher passes `--accept-lang` a plain comma-separated list, and the reason is written beside that line. Chromium turns the switch into both the header and the navigator list, and it adds the q-values to the header itself. Hand it a list that already carries q-values and the header still comes out fine, but a service worker's `navigator.languages` reads `["he-IL", "he;q=0.9", …]` while the page says `["he-IL", "he", …]`.

The general test: in the page and in a worker, log `navigator.languages`, `Intl.DateTimeFormat().resolvedOptions().locale` and `new Date(0).toLocaleDateString()`. All three should name the same locale in both places, and the header should lead with the same tag.

## How a Mosaic profile carries a language

In Mosaic the language comes from the profile, and by default from the country its proxy exits in. Tesseract Chrome, Mosaic's own Chromium, launches each profile with `--lang` for its interface, `--accept-lang` with a plain list for the header and `navigator.languages`, and its own switch that sets ICU's default locale in every renderer process. Patch `0102` does that last part: the call already existed in Chromium, behind a ChromeOS-only fence, and running it before Blink starts means workers and fresh frames format dates the same way the page does. It is in C++ rather than page script for the reasons in [why we change fingerprints in C++, not JavaScript](https://mosaicbrowser.com/blog/cpp-not-javascript).

When the exit's country has no row in our locale table, the language is left alone and the profile says so, rather than guessing. That gap, and why we report it instead of hiding it, is the story of [a Russian exit and a profile speaking Mexican Spanish](https://mosaicbrowser.com/blog/accept-language-and-proxy-country). If you are testing a store front by country, the same setup gives you the currency and shipping half of the check as well, which we walked through in [checking what Shopify Markets shows a buyer in each country](https://mosaicbrowser.com/blog/shopify-markets-geolocation-test).

## Questions

### How do I test the Accept-Language header with curl?

Send it by hand, for example curl -H "Accept-Language: de-AT,de;q=0.9" https://example.com, and look at what the server returns. That tests the server's language negotiation and nothing the page's own script decides.

### Does changing Accept-Language change navigator.language?

In Chrome, the language list in settings and the --accept-lang switch move both the header and navigator.languages. A header added by an extension or a proxy moves only the header, so the page's script still sees your own list.

### Why does Intl still format dates in my own locale?

Because Intl takes its default from ICU, which follows the operating system, not the language list. We measured --accept-lang, --lang, LANG, LC_ALL, AppleLocale and AppleLanguages on a stock Chrome, and Intl stayed on en-US every time.

### How do I change the Intl locale in Chrome for testing?

DevTools can override it: the Sensors panel has a locale field, and the DevTools protocol has a setLocaleOverride call that automation tools can send. On the same page that call moved dates, numbers and month names to de-AT.

## Related

[A Russian exit and a profile speaking Mexican Spanish](https://mosaicbrowser.com/blog/accept-language-and-proxy-country): A profile matched to a Russian exit kept sending es-MX in Accept-Language. Leaving the language alone was right. Saying nothing about it was the bug.

[Accept-CH and Critical-CH: how a server asks for more](https://mosaicbrowser.com/blog/accept-ch-critical-ch): Accept-CH asks Chrome for more client hints on the next request; Critical-CH makes it retry the first. A server sees both requests and can compare them.

[Checking what Shopify Markets shows a buyer in each country](https://mosaicbrowser.com/blog/shopify-markets-geolocation-test): To test Shopify Markets geolocation, make the exit, timezone and language name one country: Shopify reads the IP for country and the browser for language.

[Why we change fingerprints in C++, not JavaScript](https://mosaicbrowser.com/blog/cpp-not-javascript): A JavaScript spoof is one realm deep. An iframe read the real source of 33 of our patched functions; the same values changed in C++ agree everywhere.



[How to test a metered paywall as a new visitor](https://mosaicbrowser.com/blog/metered-paywall-new-visitor): A meter can count by cookie, by login, by address or by fingerprint. To test yours as a new visitor you need a browser that is new to every one of them.



[A retired bot test can still answer 200 and look like a pass](https://mosaicbrowser.com/blog/dead-detector-returns-200): Antoine Vastel's fp-scanner demo now serves his homepage with HTTP 200, which a challenge reader scores as a pass. His tests moved; here is where.

## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. Free holds 3 profiles. Pro is $49 a month for any number.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
