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

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'));

Steps

  1. Step 1.

    Send two languages where the second one carries no q parameter.

    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 q means 1, and 1 beats 0.9. Position in the header is not preference order.

  2. 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 pt and the request asked for pt-BR. Truncating the tag to its primary subtag is what keeps Brazilian visitors out of the English page.

  3. 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=0 means 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.

  4. 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: en

    Three headers, three wrong answers: French instead of German, a tag with no catalogue entry, and English for a client that refused English.

  5. Step 5.

    Watch a public site negotiate, and read its Vary in the same response.

    curl -sS -I -H 'Accept-Language: de,en;q=0.5' https://developer.mozilla.org/
    
    HTTP/2 302
    location: /de/
    vary: Accept

    The redirect target came from the header. The Vary list does not mention it.

  6. 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: Accept

    One URL, two Location values, 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

Sign: Negotiation works in the browser and fails for one customer whose header looks normal in the log.Cause: The parser takes the first tag and ignores q. Chrome sends its own language first, so the defect stays invisible until a header arrives with a lower-quality tag in front. Step 4 shows that parser answering fr to a header whose highest quality tag is de.
Sign: A page negotiates on Accept-Language and its Vary header does not list it.Cause: developer.mozilla.org returned Location /de/ and /en-US/ for the same URL on 2026-09-12, with vary: Accept in both replies. A shared cache keyed on that Vary list can hand the German redirect to an English client. The negotiation is right and the caching declaration is not, and only reading both headers in one response finds it.
Sign: A locale you never shipped appears in the analytics of served pages.Cause: A wildcard handler that returns the first catalogue entry for * picks whatever key sits first in the object. Order the candidate list explicitly rather than relying on the order of an object literal or of a directory listing.

What to check next

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.

intermediate8 minpublished updated Maks Verny