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 header | In the document | characterSet | Shown as |
|---|---|---|---|
| (none sent) | no meta | windows-1252 | café — naïve 日本語 ✓ |
text/html | no meta | windows-1252 | café — naïve 日本語 ✓ |
text/html; charset=utf-8 | no meta | UTF-8 | correct |
text/html | <meta charset="utf-8"> | UTF-8 | correct |
text/html | <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> | UTF-8 | correct |
text/html; charset=iso-8859-1 | <meta charset="utf-8"> | windows-1252 | garbled: the header wins over meta |
text/html; charset=iso-8859-1 | UTF-8 BOM (EF BB BF) at the start | UTF-8 | correct: the BOM wins over the header |
text/plain | - | windows-1252 | garbled |
text/plain; charset=utf-8 | - | UTF-8 | correct |
application/json | - | UTF-8 | correct |
application/json; charset=iso-8859-1 | - | windows-1252 | garbled |
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
| Response | fetch().text() | res.json() | XMLHttpRequest.responseText |
|---|---|---|---|
application/json (no charset) | correct | correct | correct |
application/json; charset=iso-8859-1 | correct | correct | garbled |
text/html; charset=iso-8859-1 | correct | - | 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-Type | r.encoding | r.apparent_encoding | r.text |
|---|---|---|---|
text/html or text/plain | ISO-8859-1 | utf-8 | 'café â\x80\x94 ...' garbled |
text/html; charset=utf-8 | utf-8 | utf-8 | correct |
| (none sent) | None | utf-8 | correct (guessed) |
application/json | utf-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:
| Call | Content-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.