How to check accept language header
Send the header yourself and read Content-Language on the reply. Run curl -sS -D - -H 'Accept-Language: fr;q=0.9, de' http://127.0.0.1:8391/. The answer is German, because a tag with no q carries a quality of 1 and outranks the French one. Then test a wildcard, a refusal and a region subtag.
Why check this
Language selection is the one piece of content negotiation a tester cannot drive from the browser address bar, so it tends to reach production tested in exactly one configuration: the machine of the person who wrote it. Run this check when the product gains a second language, and again after any change to the routing or caching layer in front of it.
The failure it prevents is a customer who sets Portuguese as a preference and is served English, while the log shows the header arrived correctly. The header did arrive. The parser dropped it.
Prerequisites
- curl 8.21.0. The curl manual for
-Hcovers sending a request header. - Node 22 and a free port. Save this as
neg.mjsand runnode neg.mjs. It is the target for steps 1 to 3, and it follows RFC 9110 section 12.5.4.
import { createServer } from 'node:http';
const page = { en: 'Cart', de: 'Warenkorb', fr: 'Panier', pt: 'Carrinho' };
function parse(header) {
return (header ?? '')
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';');
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
return { tag: tag.toLowerCase(), q: q === undefined ? 1 : Number(q.slice(2)) };
})
.filter((e) => e.tag !== '' && Number.isFinite(e.q))
.sort((a, b) => b.q - a.q);
}
function negotiate(header) {
const entries = parse(header);
const refused = new Set(entries.filter((e) => e.q === 0).map((e) => e.tag.split('-')[0]));
for (const { tag, q } of entries) {
if (q === 0) continue;
if (tag === '*') {
const any = Object.keys(page).find((l) => !refused.has(l));
if (any !== undefined) return any;
continue;
}
if (page[tag] !== undefined) return tag;
const base = tag.split('-')[0];
if (page[base] !== undefined) return base;
}
return 'en';
}
createServer((req, res) => {
const header = req.headers['accept-language'];
const locale = negotiate(header);
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Language': locale,
Vary: 'Accept-Language',
});
res.end(
'sent: ' + (header ?? '(absent)') +
'\nparsed: ' + JSON.stringify(parse(header)) +
'\nserved: ' + locale + ' "' + page[locale] + '"\n'
);
}).listen(8391, '127.0.0.1', () => console.log('listening on 8391'));
- Stop it when you are done. On Windows, find the PID with
netstat -ano | grep 8391and runpowershell -Command "Stop-Process -Id <pid> -Force".
Steps
- Step 1.
Send two languages where the second one carries no
qparameter.curl -sS -D - -H 'Accept-Language: fr;q=0.9, de' http://127.0.0.1:8391/HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Language: de Vary: Accept-Language Date: Fri, 11 Sep 2026 22:46:43 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked sent: fr;q=0.9, de parsed: [{"tag":"de","q":1},{"tag":"fr","q":0.9}] served: de "Warenkorb"German wins from second place. An absent
qmeans 1, and 1 beats 0.9. Position in the header is not preference order. - Step 2.
Ask for a region that has no translation of its own.
curl -sS -H 'Accept-Language: pt-BR' http://127.0.0.1:8391/sent: pt-BR parsed: [{"tag":"pt-br","q":1}] served: pt "Carrinho"The catalogue holds
ptand the request asked forpt-BR. Truncating the tag to its primary subtag is what keeps Brazilian visitors out of the English page. - Step 3.
Refuse one language and accept anything else.
curl -sS -H 'Accept-Language: en;q=0, *' http://127.0.0.1:8391/sent: en;q=0, * parsed: [{"tag":"*","q":1},{"tag":"en","q":0}] served: de "Warenkorb"q=0means not acceptable, and*matches every remaining tag. English is the default of this server and it is still the one language it may not serve here. - Step 4.
Run the parser most services actually have against the same three headers. Save it as
naive.mjs.const headers = ['fr;q=0.9, de', 'pt-BR', 'en;q=0, *']; const naive = (h) => h.split(',')[0].split(';')[0].trim(); for (const h of headers) console.log(h.padEnd(14) + ' naive: ' + naive(h));fr;q=0.9, de naive: fr pt-BR naive: pt-BR en;q=0, * naive: enThree headers, three wrong answers: French instead of German, a tag with no catalogue entry, and English for a client that refused English.
- Step 5.
Watch a public site negotiate, and read its
Varyin the same response.curl -sS -I -H 'Accept-Language: de,en;q=0.5' https://developer.mozilla.org/HTTP/2 302 location: /de/ vary: AcceptThe redirect target came from the header. The
Varylist does not mention it. - Step 6.
Repeat the request with an English preference and compare.
curl -sS -I -H 'Accept-Language: en-US,en;q=0.9' https://developer.mozilla.org/HTTP/2 302 location: /en-US/ vary: AcceptOne URL, two
Locationvalues, decided by a header the response never declares as varying.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Content-Language matches the highest q tag you sent | The parser sorts by quality | Repeat with the q values reversed to confirm it sorts rather than guesses. |
| The first tag in the header always wins | The parser reads position, not q | File it. A browser sends its second choice with q=0.9 and its first with none. |
| A pt-BR request returns the default | There is no fallback to the primary subtag | Add tag truncation before the default. Most catalogues hold pt, not pt-BR. |
| A q=0 language comes back anyway | q=0 is read as absent, or as a low score | Treat 0 as a refusal and drop that tag from the candidate list. |
| Vary does not list Accept-Language | A shared cache may serve one language to everyone | Add it, then confirm with How to check vary header. |
Common mistakes
What to check next
- How to check the browser locale: where the header the browser sends comes from, and why it differs from
navigator.language. - How to change locale in chrome: drive the same negotiation from a browser instead of curl.
- How to check vary header: the caching half of the pitfall above.
- How to check for missing translations: what happens once negotiation picks a language the catalogue only half covers.
- How to check hreflang tags: the other way a site declares which language a URL serves.
FAQ
How do I test Accept-Language without changing my browser?
Send it with curl, as in every step above. The header is a plain request header, so any HTTP client can set it, and no browser setting is touched.
Does a missing q value mean zero?
It means 1, the highest quality there is. Tags rank by q and not by position, so a tag with no q outranks every tag that carries one.
What should the server do when nothing matches?
Serve a documented default and name it in Content-Language. Returning 406 for language is allowed by RFC 9110 and shows the visitor nothing useful in any browser.
Does Content-Language change the page language for search engines?
It declares the language of the body that was sent. Crawlers mostly follow the URL and the hreflang links instead, which is a separate check.
Why does a proxy break negotiation?
A cache that stores one copy per URL serves the first language it saw to everyone after that. The response has to carry Vary: Accept-Language for the cache to key on the header.
Verified
Verified by Maks Vernycurl 8.21.0node 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
intermediate8 minpublished updated Maks Verny