406 Not Acceptable (tested)

406 Not Acceptable means the server has several formats it could send, read your Accept request header, and found none it was willing to return. It is a content-negotiation failure, not an auth or routing error. We measured what each common client sends by default and exactly which Accept values make an Express 5.2.1 route answer 406.

1. The Accept header each client sends by default

Recorded by a Node.js echo server that returned req.headers.accept:

ClientDefault Accept
curl 8.14.1*/*
Node.js 24.21.0 fetch()*/*
Python requests 2.32.3*/*
Python urllib.request.urlopen (3.13)(no Accept header at all)
Chromium 153 fetch() and XMLHttpRequest*/*
Chromium 153 page navigationtext/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7

Every default above accepts anything, so a 406 almost always means some code set Accept explicitly: an HTTP client configured for XML, a library that sends Accept: text/plain, an API SDK, or a header copied from somewhere else. Check the request in the browser's Network tab or with curl -v before touching the server.

2. Reproducing 406 in Express 5.2.1

A route that offers JSON and HTML with res.format() and no default:

app.get('/nodef', (req, res) => res.format({
  'application/json': () => res.json({ ok: 1 }),
  'text/html':        () => res.send('<p>ok</p>'),
}));

Measured with curl -H "Accept: ...":

Accept sentWithout defaultWith default
(header removed with -H 'Accept:')200, JSON200, JSON
*/*200, JSON (first key wins)200, JSON
application/*200, JSON200, JSON
application/json200, JSON200, JSON
text/html200, HTML200, HTML
application/xml406200, default
text/plain406200, default
application/json;q=0406 (q=0 means "not acceptable")200, default
text/html;q=0.5, application/json200, JSON200, JSON
text/html, application/json;q=0.5200, HTML200, HTML

The 406 response itself:

$ curl -si -H 'Accept: application/xml' http://localhost:8988/nodef
HTTP/1.1 406 Not Acceptable
X-Powered-By: Express
Vary: Accept
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 932

Express raises a NotAcceptableError. Without an error handler the body is an HTML page: in development it contains the full stack trace (932 bytes here) and the trace is also printed to the server console; with NODE_ENV=production it was a 141-byte page saying only Not Acceptable. An API client expecting JSON gets HTML either way. res.format() also added Vary: Accept to both the 200 and 406 responses, which keeps caches from serving the JSON variant to an HTML request; see the Vary header guide.

The error carries the offered types. This handler:

app.use((err, req, res, next) =>
  res.status(err.status).json({ error: err.message, status: err.status, types: err.types }));

returned 406 {"error":"Not Acceptable","status":406,"types":["application/json","text/html"]}, which tells the caller what it can ask for.

3. How req.accepts() ranks types

Request Acceptaccepts(['html','json'])accepts(['json','html'])accepts('xml')
text/html;q=0.5, application/jsonjsonjsonfalse
text/html, application/json;q=0.5htmlhtmlfalse
*/*htmljsonxml
application/xmlfalsefalsexml
Chromium navigation string (above)htmlhtmlxml

The client's q-values decide first; only on a tie (such as */*) does the order of your list decide. A route with only a JSON handler, res.format({ json: ... }), returned 200 to Chromium's navigation string (through */*;q=0.8) but 406 to a bare Accept: text/html. If you check by hand, if (!req.accepts('json')) return res.sendStatus(406) sends 406 with the body Not Acceptable.

4. Fixes

Client side (when you call someone else's API): send a type the server offers, usually Accept: application/json, or */*. In fetch: fetch(url, { headers: { Accept: 'application/json' } }); Chromium sent exactly that value. In curl: -H 'Accept: application/json'. Look for code that sets Accept globally, such as client defaults or interceptors.

Server side (Express): add a default so unknown types get your main format instead of an error:

res.format({
  'application/json': () => res.json(data),
  'text/html':        () => res.send(html),
  default:            () => res.json(data),
});

With that change every row in the table above returned 200. Keep the strict 406 only if the endpoint really cannot produce anything else, and then return a JSON error that lists err.types.

If the server is not yours and even Accept: */* gets 406, the cause is probably not content negotiation. Web application firewalls such as Apache mod_security are reported to answer 406 when a request matches a rule (for example an unusual User-Agent or request body). We did not reproduce that here. Related: Accept-Language negotiates language the same way, and Accept-Encoding negotiates compression. For the response side of JSON, see Content-Type: application/json.

Frequently asked questions

What does 406 Not Acceptable mean?

The server read the request's Accept header and had no format it was willing to send that matched it. In Express 5.2.1, res.format() with JSON and HTML handlers and no default returned 406 for Accept: application/xml, text/plain and application/json;q=0.

Do curl, fetch or Python requests cause 406 by default?

No. curl 8.14.1, Node.js 24 fetch, Python requests 2.32.3 and Chromium 153 fetch/XHR all sent Accept: */*, and Python urllib sent no Accept header; Express answered 200 to both. A 406 usually means code set Accept explicitly.

How do I fix 406 in Express?

Add a default key to res.format(), for example default: () => res.json(data). With it, every Accept value we tried returned 200. Or have the client send Accept: application/json.

How do q-values affect content negotiation?

Higher q wins: with Accept: text/html;q=0.5, application/json, Express chose JSON; with text/html, application/json;q=0.5 it chose HTML. q=0 excludes a type. With */* the server's own order decides.

Why is my 406 response HTML when I expected JSON?

Express's default error handler renders HTML: 932 bytes including a stack trace in development, 141 bytes in production. Add an error middleware that returns JSON using err.status and err.types.

Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 (http.createServer echo server on localhost:8987, built-in fetch as a client), Express 5.2.1 on localhost:8988, curl 8.14.1, Python requests 2.32.3 and urllib (Python 3.13), and headless Chromium 153.0.8010.12 (Playwright). Flask, Django and FastAPI were not tested (no pip on the test box). WAF and mod_security causes are reported elsewhere, not reproduced here. PlainHeaders is our extension; Node.js, Express, curl, requests and Chromium are free.