Every backend prints this header and almost nobody implements it correctly:
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
The near-universal implementation sorts the media ranges by q and takes the best one you can serve. That is wrong, and RFC 9110 says so plainly: specificity decides first.
A working negotiator, scoring every offer against every range: https://dev48.infy.uk/solve/day64-content-negotiation.html
Specificity outranks q
Accept: text/html;q=0.1, */*;q=1.0
Offers: text/html, application/json
Sort by q and you serve JSON. Correct answer is text/html. The client named it exactly; */* is a catch-all, and a catch-all never overrides a specific mention no matter what q it carries.
The precedence is three levels:
function specificity(range){
if (range.type === '*') return 0; // */*
if (range.subtype === '*') return 1; // text/*
return 2 + range.params.size; // text/html, then text/html;level=1
}
Parameters are a fourth level, and they bite too:
Accept: text/html;q=0.9, text/html;level=1;q=0.3
For a representation that is level=1, the 0.3 wins. More parameters matched means more specific means it decides.
function pick(offers, ranges){
return offers.map(o => {
const best = ranges.filter(r => matches(r, o))
.sort((a, b) => specificity(b) - specificity(a))[0];
return { o, q: best ? best.q : 0 }; // MOST SPECIFIC match sets q
}).filter(x => x.q > 0)
.sort((a, b) => b.q - a.q)[0] || null; // then, and only then, q
}
q=0 is a prohibition, not a low preference
Accept: application/json;q=0
That does not mean "I would rather not". It means do not send me this. If it is the only thing you can produce, the answer is 406 — not "well, they did list it".
The page proves both halves: 406 when it is the only match, and other candidates completely unaffected by the refusal.
The bug my own tests caught
Language negotiation had this:
Accept-Language: fr;q=0, *;q=0.5
"Not French. Anything else is fine." My implementation let the wildcard's 0.5 apply to French, because it scored ranges and took the best q rather than the most specific match. French came back with q=0.5 and got served.
Fix is the same rule as media types — the most specific matching range decides, and then its q is final. Once that was right, both bugs died together.
Language matching is a different rule, and the hyphen is all of it
const langMatches = (range, tag) =>
range === '*' || tag === range || tag.startsWith(range + '-');
en matches en-GB. en must not match eng. Use startsWith without the hyphen and you have written a bug that survives every casual test, because eng is rare in your fixtures and common in the wild.
The clause that breaks gzip-only servers
For Accept-Encoding, identity is acceptable unless explicitly refused — even when it is not listed. So:
Accept-Encoding: gzip
means "gzip is nice, uncompressed is still fine". Only identity;q=0 forbids it. Servers that treat an unlisted identity as forbidden return 406 to clients that would have been perfectly happy.
The reference cases
Eight real headers — Chrome, curl, Googlebot, a REST client, an old IE-style header, a q=0 refusal — replayed against the negotiator with 0 failures. Plus a totality check: every offer scored, exactly one chosen, deterministic across runs.
Content negotiation is a small algorithm with a long tail of clauses that look optional and are not. The RFC is unusually readable. Read the ordering section once and you will never sort by q again.
Part of a from-scratch series — one tool a day, all client-side: https://dev48.infy.uk/solvefromzero.php
Top comments (0)