Content-Type charset=utf-8 (tested)

If é shows up as é and — as —, the bytes are fine: they are UTF-8 being decoded as windows-1252 because nothing told the client the charset. We served identical UTF-8 bytes under every common header and meta combination and recorded what Chromium, fetch, XHR and Python actually decoded.

1. What Chromium rendered

Body bytes: UTF-8 for café — naïve 日本語 ✓. Results from document.characterSet and the visible text:

Content-Type headerIn the documentcharacterSetShown as
(none sent)no metawindows-1252café — naïve 日本語 ✓
text/htmlno metawindows-1252café — naïve 日本語 ✓
text/html; charset=utf-8no metaUTF-8correct
text/html<meta charset="utf-8">UTF-8correct
text/html<meta http-equiv="Content-Type" content="text/html; charset=utf-8">UTF-8correct
text/html; charset=iso-8859-1<meta charset="utf-8">windows-1252garbled: the header wins over meta
text/html; charset=iso-8859-1UTF-8 BOM (EF BB BF) at the startUTF-8correct: the BOM wins over the header
text/plain-windows-1252garbled
text/plain; charset=utf-8-UTF-8correct
application/json-UTF-8correct
application/json; charset=iso-8859-1-windows-1252garbled

So the order is: BOM, then the HTTP header, then the meta tag, then a locale default (windows-1252 here; iso-8859-1 is treated as windows-1252, which is why — becomes — and not three control characters). A missing charset is not "UTF-8 by default" for HTML or plain text.

The HTML spec only promises that a meta charset inside the first 1,024 bytes is found. Chromium 153 was more forgiving in our test: a meta after a 1,100-byte comment, after 100,000 bytes, and even inside <body> was still honored (served from localhost in a single write). Do not rely on that; put <meta charset="utf-8"> first in <head>.

2. fetch(), res.json() and XHR

Responsefetch().text()res.json()XMLHttpRequest.responseText
application/json (no charset)correctcorrectcorrect
application/json; charset=iso-8859-1correctcorrectgarbled
text/html; charset=iso-8859-1correct-garbled
text/html or text/plain (no charset)correct-correct

fetch().text() and .json() always decode as UTF-8 and ignore the charset parameter. XHR follows the header, so an old jQuery or XHR client can garble a response that fetch reads fine. If you need another encoding with fetch, use new TextDecoder('windows-1252').decode(await res.arrayBuffer()).

3. curl and Python requests

curl never decodes anything; curl -sI just shows the header, which is the first thing to check:

$ curl -sI http://localhost:8983/html
HTTP/1.1 200 OK
Content-Type: text/html
Date: Thu, 01 Oct 2026 22:58:00 GMT

A plain Node res.end(string) with no header set sends no Content-Type line at all. Python requests 2.32.3 against these servers:

Content-Typer.encodingr.apparent_encodingr.text
text/html or text/plainISO-8859-1utf-8'café â\x80\x94 ...' garbled
text/html; charset=utf-8utf-8utf-8correct
(none sent)Noneutf-8correct (guessed)
application/jsonutf-8-correct; r.json() correct

Client-side fix when you do not control the server: r.encoding = "utf-8" (or r.encoding = r.apparent_encoding) before reading r.text, or decode r.content yourself.

4. Server fixes

Node.js http:

res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(html);

Express 5.2.1 adds the charset for you; measured headers:

CallContent-Type sent
res.send('café')text/html; charset=utf-8
res.set('Content-Type', 'text/html'); res.send(str)text/html; charset=utf-8
res.type('text').send(str)text/plain; charset=utf-8
res.json(obj)application/json; charset=utf-8
res.sendFile('x.html') / 'x.txt'text/html; charset=utf-8 / text/plain; charset=utf-8
res.send(Buffer.from(str))application/octet-stream (no charset)

nginx (config, per the nginx docs; not run here): charset utf-8; in the server or http block appends ; charset=utf-8 to text/html, and charset_types text/plain text/css application/javascript application/json; extends it to other types.

Add <meta charset="utf-8"> to every HTML page anyway: it covers files opened from disk or served by a host that sends bare text/html. If the page already shows é after these fixes, the text was double-encoded before it was stored (for example a database connection not set to UTF-8), and no header can repair it. For JSON specifics see Content-Type: application/json, and for MIME sniffing see X-Content-Type-Options: nosniff.

Frequently asked questions

Why does é show as é and — as —?

The bytes are UTF-8 but the client decoded them as windows-1252. In Chromium 153, text/html or text/plain without a charset and without a meta tag rendered café — as café —.

Does the Content-Type header or the meta charset win?

The header. With Content-Type: text/html; charset=iso-8859-1 and <meta charset="utf-8"> in the page, Chromium 153 used windows-1252. Only a UTF-8 byte order mark at the start of the file overrode the header.

Is charset=utf-8 needed for application/json?

Not for browsers: Chromium showed JSON without a charset as UTF-8, and fetch().json() always decodes UTF-8. But a wrong charset such as iso-8859-1 garbled the page view and XMLHttpRequest.responseText.

Why does Python requests return garbled text?

For text/* responses without a charset, requests 2.32.3 set r.encoding to ISO-8859-1. Set r.encoding = 'utf-8' or r.apparent_encoding (which detected utf-8) before reading r.text.

How do I set charset=utf-8 in Node.js and Express?

In Node.js call res.setHeader('Content-Type', 'text/html; charset=utf-8'). Express 5.2.1 res.send, res.json, res.type and res.sendFile all added charset=utf-8 automatically, except when sending a Buffer.

Written with AI assistance. Measured on 2026-10-01 in headless Chromium 153.0.8010.12 (Playwright, channel 'chromium', fresh context per page), Node.js 24.21.0 http.createServer on localhost:8981-8985, Express 5.2.1, curl 8.14.1 and Python requests 2.32.3. Every response body was the same UTF-8 bytes for 'café — naïve 日本語 ✓'; only the Content-Type header and the markup around the text changed. The nginx snippet is from its documentation, not executed. PlainHeaders is our extension; Chromium, Node.js, Express, curl and requests are free.