Whose answer this is
Content negotiation has more than one deployed answer, so naming the implementation matters
more here than it usually does. Every answer on this page is
negotiator 1.1.0 — the code that answers
req.accepts(), req.acceptsLanguages(),
req.acceptsEncodings() and res.format() in a current Express app. The
chain was checked rather than assumed: express 5.2.1 →
accepts ^2.0.0 → negotiator ^1.0.0. Its tokenizer,
content-type 2.1.0, is part of the port too, because every quirk of how a rule is
read comes from that parser rather than from negotiator.
It was run for real, and 2,780 recorded cases are replayed against this page's engine on every build — not just the winner of each, but the whole ranking, so a port that is right about the head and wrong about the tail still fails. 445 of them end in nothing acceptable, so no test passes by always answering the same way.
Where a standard says something different, the page says so under the answer whenever your own
input reaches the disagreement; the tables below list every one. Apache
mod_negotiation is described rather than executed, because only negotiator is
pinned by the corpus here.
The four headers, and what each one negotiates over
One form covers all four because they share one shape: a header of weighted rules on one side, a list of representations on the other. What a missing header means is not shared, and the last column is where most 406s come from.
| Header | The server side is | Answered by | If the client does not send it |
|---|---|---|---|
Accept | the media types it can serialise the resource as | mediaTypes() | every media type is acceptable
negotiated against */* |
Accept-Language | the languages it holds a translation in | languages() | every language is acceptable
negotiated against * |
Accept-Encoding | the content codings it can compress with | encodings() | nothing but identity is acceptable — the one family where the absence is not permissive
negotiated against (nothing) |
Accept-Charset | the charsets it can encode text in | charsets() | every charset is acceptable
negotiated against * |
How a winner is chosen
Every representation the server offers is scored against every rule in the header, and the most specific rule that matches an offer is the one that supplies its quality (RFC 9110 §12.5.1). Only then are the offers compared, in this order — each row's example is settled by exactly the comparison it names, and the engine was asked to confirm that when this page was built:
| Comparison | Example | Sends | What it means |
|---|---|---|---|
| The quality value | Accept: text/html;q=0.9, application/json;q=0.4 server has: application/json, text/html | text/html | Highest q wins. Each offer takes its q from the most specific rule that matches it, which is where the surprises come from. |
| How literal the rule is | Accept: text/html, text/* server has: text/plain, text/html | text/html | At equal q, a rule naming type and subtype beats text/*, which beats */*. A media parameter that matches adds one more step. |
| The order the client wrote its rules | Accept: text/plain, text/html server has: text/html, text/plain | text/plain | At equal q and equal specificity, the rule written earlier in the header wins — even though RFC 9110 gives no meaning to the order. |
| The order the server listed its representations | Accept: */* server has: application/xml, text/html | application/xml | When one rule matches everything, nothing in the request separates the offers: the list your code passes to res.format() decides. |
The first two come from the specification. The last two do not: RFC 9110 gives no meaning to the order rules are written in, and none at all to the order your own code lists its representations in. In Express both decide real answers, which is why this page shows them.
Results that surprise people
Each answer here is the one this tool gives, computed when the page was built. Paste any row into the form above to see the full scoring.
| Case | Sends | Why it catches people |
|---|---|---|
| A more specific rule with a lower q Accept: text/*;q=0.9, text/html;q=0.1 server has: text/html, text/plain | text/plain | RFC 9110 §12.5.1: the most specific reference supplies the quality value. text/html takes q=0.1 from its own exact mention, while text/plain takes 0.9 from text/* — so asking for HTML last is how you stop getting it. Written as res.format({ html, txt }) in Express this really returns the plain-text branch. |
| A q=0 that does not exclude what you expect Accept: */*;q=0, application/json server has: application/json, application/xml | application/json | */*;q=0 excludes everything the header does not name again. XML is matched only by the wildcard and is gone; JSON survives because its own rule matches it more specifically. A blanket q=0 is a whitelist, not a blacklist. |
| A language that is almost right Accept-Language: zh-Hans server has: zh-Hans-CN | 406 Not Acceptable | negotiator splits a tag at its first hyphen only, so zh-Hans and zh-Hans-CN never meet. RFC 4647 basic filtering says they match. Bare zh does match zh-Hans-CN, which is why the failure looks arbitrary: the less specific request succeeds. |
| A compress-only server and a client that said nothing (no Accept-Encoding sent) server has: gzip, br | 406 Not Acceptable | A missing Accept-Encoding is read as identity only, so a server that can serve gzip and Brotli has nothing acceptable left. RFC 9110 §12.5.3 says the opposite: no field means any coding is acceptable. |
| One low q dragging the uncompressed answer down Accept-Encoding: gzip;q=0.5 server has: gzip, identity | gzip | identity is never named here, so negotiator appends it at the lowest quality in the header — q=0.5, not q=1. It is still acceptable, but it no longer outranks anything you add later at q=0.6. |
| An empty header is not a missing header Accept: server has: text/html, text/plain | 406 Not Acceptable | A missing Accept is read as */*; an Accept: that was sent empty parses to no rules at all, and nothing can match no rules. A proxy that strips a header value rather than the whole line turns every response into a 406. |
| A parameter written after q Accept: application/xml;q=1.0;level=2 server has: application/xml | 406 Not Acceptable | RFC 9110 §12.5.1 calls anything after q an accept-extension, which must not affect selection. negotiator reads level=2 as a parameter of the media type instead, so only an offer carrying that exact parameter matches — and a plain application/xml does not. |
| A q that is not a number Accept: text/html;q=high server has: text/html | 406 Not Acceptable | The quality parses to something that is not a number, and that is never above zero, so the rule is dropped in silence — as is q=-1. A typo in a q does not fall back to q=1: it removes the rule. But q= with nothing after it does mean q=1, because an empty value is falsy and the default applies. |
| An offer that is a file extension Accept: */* server has: html, text/plain | text/plain | negotiator matches media types, and html has no /, so it can never be chosen. In Express you never see this, because accepts maps the extension to text/html before negotiating — which is also why req.accepts('html') and a hand-rolled negotiator call are not the same thing. |
Where the library and the standards disagree
Five places where negotiator does not do what RFC 9110 or RFC 4647 says. Each is
shown on the page above whenever your own input reaches it. The "Sends" column is what
negotiator really does — the standard's reading is described, not executed.
| Input that reaches it | Sends | What negotiator does | What the standard says |
|---|---|---|---|
| Accept: application/xml;q=1.0;level=2 server has: application/xml | 406 Not Acceptable | negotiator reads level as a parameter of the media type itself, so only an offer carrying that exact parameter can match. | RFC 9110 §12.5.1: parameters after q are accept-extensions. They are for other purposes and must not affect which representation is selected. |
| (no Accept-Encoding sent) server has: gzip, br | 406 Not Acceptable | negotiator restricts a request with no Accept-Encoding to identity, so any other coding the server can produce is unacceptable. | RFC 9110 §12.5.3: when a request has no Accept-Encoding field, any content coding is acceptable — the server may compress freely. |
| Accept-Language: zh-Hans server has: zh-Hans-CN | 406 Not Acceptable | negotiator splits a tag into two parts only, so zh-Hans does not match zh-Hans-CN and the request gets nothing. | RFC 4647 §3.3.1 basic filtering: zh-Hans-CN begins with zh-Hans at a subtag boundary, so it matches. |
| Accept-Language: de-CH-1901 server has: de-CH | 406 Not Acceptable | negotiator splits a tag into two parts only, so de-CH-1901 does not match de-CH and the request gets nothing. | RFC 4647 §3.4 lookup: truncating de-CH-1901 from the right reaches de-CH, so it matches. |
| Accept: text/html;q=1.5 server has: text/html | text/html | negotiator keeps q=1.5 as written, so this rule outranks every well-formed one. | RFC 9110 §12.4.2: a quality value runs from 0 to 1 with at most three decimal places. A value above 1 is not a quality value. |
Where another server answers differently
Apache httpd's mod_negotiation has two inputs Express does not have —
a server-side source quality, and a configured language order — so it can disagree with every
answer above that came down to a tie. These are described from its documented behaviour and
not simulated: nothing here runs Apache.
| Input that reaches it | Express sends | What Apache does instead |
|---|---|---|
| Accept: */* server has: application/xml, text/html | application/xml | This answer came down to a tie that negotiator broke by order. Apache does not: it multiplies the client's quality by a server-side source quality (qs, from a type-map file or AddSourceQuality), so a server that prefers one variant can outrank the client's own ordering. Where that is configured, the chosen media type can differ. |
| Accept-Language: * server has: fr, en | fr | Apache settles a language tie from its LanguagePriority directive rather than from the order the client or the server listed things in, so the same request can get a different language from an Apache server. |
| Accept-Language: zh-Hans server has: zh-Hans-CN | 406 Not Acceptable | Apache does not have to answer 406 here. Its default ForceLanguagePriority Prefer Fallback makes it serve the first variant from LanguagePriority when nothing is acceptable, so a reader comparing the two stacks will see a page where Express sends nothing. |
Three things this page cannot tell you
- What your server actually does. This is Express's negotiation. Apache, nginx, Spring, ASP.NET, a CDN and your own handler are more implementations, and the table above names three places one of them parts company. When the answer matters, send the request to the server you have.
- Whether the chosen representation will be cached correctly. A negotiated
response needs a
Varynaming the headers that decided it, or a shared cache will serve one client's language to another. That is a separate question, and the HTTP cache tester answers it — including whether a later request matches the stored entry underVary. - What the representation should be. Negotiation picks from what you say you
can produce. It has no opinion about whether offering both
application/jsonandapplication/xmlis a good idea, and none about what the bodies contain.
About this tool
The resolver is a TypeScript port of negotiator 1.1.0, the negotiation behind Express 5, pinned by
2,780 recorded cases —
872 on Accept,
1,137 on Accept-Language,
463 on Accept-Encoding and
308 on Accept-Charset. They were
generated by running the real library in the build container and are replayed against the port
on every build. Of the 2,447 cases offering more
than one representation, 995 chose something
other than the first thing on the list — which is the share of the time negotiation changes the
outcome at all.
The header parser is ours, shared with the HTTP cache tester and the conditional request tester, and it reports what it made of your paste rather than guessing quietly. Nothing here reads a clock or a network: the same input always gives the same answer.
Everything runs in your browser. Request headers routinely carry session cookies, bearer tokens and internal hostnames, so nothing you paste is uploaded, logged or sent anywhere — which is also why this tool takes a pasted header and your own list instead of fetching a URL for you.