DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

text/html;q=0.1 Beats */*;q=1.0 — Sorting Accept Headers by q Is the Bug Everyone Ships

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Parameters are a fourth level, and they bite too:

Accept: text/html;q=0.9, text/html;level=1;q=0.3
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

q=0 is a prohibition, not a low preference

Accept: application/json;q=0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

"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 + '-');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)