How to check if a page is UTF-8
A page declares its encoding in three places: a BOM, the Content-Type header, and <meta charset>. Read all three with curl -sI and a grep over the body, then open the page and run document.characterSet in the Console. That last value is the encoding the browser used.
Why check this
Run this after any change to a template, a CMS export, a reverse proxy or a CDN rule, and on every page that carries names, addresses or free text from users. A proxy that appends charset=windows-1251 to Content-Type turns every accented character on a UTF-8 page into mojibake, and the template still says <meta charset="utf-8">, so a code review finds nothing.
The three declarations do not carry equal weight, and the page that renders correctly on your machine can render as garbage on a colleague's. The steps below measure the order.
Prerequisites
- curl 8 or later. Any build reads headers; no TLS or HTTP/2 feature is needed here.
- Node 22, to serve the fixtures. Save this as
enc-server.mjsand runnode enc-server.mjs. It serves one document under five declaration schemes on port 8971.
import { createServer } from 'node:http';
const doc = (head) => Buffer.from(
`<!doctype html><html><head>${head}<title>encoding fixture</title></head>` +
`<body><p id="s">Zurich cafe, Привет</p></body></html>`, 'utf8');
const bom = Buffer.from([0xef, 0xbb, 0xbf]);
const pad = `<!--${'x'.repeat(Number(process.env.PAD ?? 1100))}-->`;
const routes = {
'/header-1251': ['text/html; charset=windows-1251', doc('<meta charset="utf-8">')],
'/bom-1251': ['text/html; charset=windows-1251', Buffer.concat([bom, doc('')])],
'/meta-only': ['text/html', doc('<meta charset="utf-8">')],
'/meta-late': ['text/html', doc(`${pad}<meta charset="utf-8">`)],
'/nothing': ['text/html', doc('')],
};
createServer((req, res) => {
const r = routes[req.url];
if (!r) { res.writeHead(404).end(); return; }
res.writeHead(200, { 'content-type': r[0], 'content-length': r[1].length });
res.end(r[1]);
}).listen(8971, '127.0.0.1', () => console.log('fixture server on http://127.0.0.1:8971'));
- Chrome with DevTools. The document.characterSet reference describes the value the browser settled on.
- The browser figures below are one capture, from Chrome 152 on one machine on 2026-09-12.
Steps
- Step 1.
Read what the header declares on the page under test.
curl -s -D - -o page.html --compressed https://developer.mozilla.org/en-US/docs/Web/HTML | grep -i '^content-type'content-type: text/htmlNo
charsetparameter. The header has left the decision to the document. - Step 2.
Read what the document declares, from the body saved in step 1.
head -c 1024 page.html | grep -o -i -E '<meta[^>]*charset[^>]*>'<meta charset="UTF-8" />One declaration, in the document, inside the first kilobyte.
- Step 3.
Check a page that declares nothing at all, so you know what that looks like.
curl -s https://example.com/ -o example.html && grep -o -i -E '<meta[^>]*>' example.html<meta name="viewport" content="width=device-width, initial-scale=1">The 559-byte document carries one
metaelement and it is not a charset. The header on the same response iscontent-type: text/html, with no parameter either. Nothing in this response says how to decode it. - Step 4.
Start the fixture server, then read all three declaration sites for each route in one pass.
for r in header-1251 bom-1251 meta-only meta-late nothing; do printf '%-12s ' "$r"; h=$(curl -sI http://127.0.0.1:8971/$r | grep -i '^content-type' | tr -d '\r' | sed 's/^content-type: //'); b=$(curl -s http://127.0.0.1:8971/$r | head -c 3 | xxd -p); m=$(curl -s http://127.0.0.1:8971/$r | grep -b -o -i -E '<meta[^>]*charset[^>]*>' | head -1); printf 'header "%s" first3 %s meta %s\n' "$h" "$b" "${m:-none}"; doneheader-1251 header "text/html; charset=windows-1251" first3 3c2164 meta 27:<meta charset="utf-8"> bom-1251 header "text/html; charset=windows-1251" first3 efbbbf meta none meta-only header "text/html" first3 3c2164 meta 27:<meta charset="utf-8"> meta-late header "text/html" first3 3c2164 meta 1134:<meta charset="utf-8"> nothing header "text/html" first3 3c2164 meta nonefirst3 efbbbfis a UTF-8 BOM. The number before each meta is its byte offset in the response. - Step 5.
Ask the browser which of those declarations it obeyed. Open each route in Chrome and read
document.characterSetin the Console, or drive it from a Puppeteer session, which is how the output below was produced.import { open } from './session.mjs'; const s = await open(); try { console.log('chrome', await s.browser.version()); for (const r of ['header-1251', 'bom-1251', 'meta-only', 'meta-late', 'nothing']) { await s.goto(`http://127.0.0.1:8971/${r}`); const out = await s.page.evaluate(() => ({ charset: document.characterSet, text: document.getElementById('s').textContent, })); console.log(`${r.padEnd(12)} document.characterSet ${out.charset.padEnd(14)} text "${out.text}"`); } } finally { await s.close(); }chrome Chrome/152.0.7977.76 header-1251 document.characterSet windows-1251 text "Zurich cafe, Привет" bom-1251 document.characterSet UTF-8 text "Zurich cafe, Привет" meta-only document.characterSet UTF-8 text "Zurich cafe, Привет" meta-late document.characterSet UTF-8 text "Zurich cafe, Привет" nothing document.characterSet windows-1251 text "Zurich cafe, Привет"Every route served the same UTF-8 bytes. Row one lost to the header, row two won with a BOM under the same header, row five had nothing to go on.
- Step 6.
Load the route that declares nothing under three browser languages, to see what the fallback costs.
import { open } from './session.mjs'; for (const lang of ['uk-UA', 'en-US', 'ja-JP']) { const s = await open({ args: [`--lang=${lang}`] }); try { await s.goto('http://127.0.0.1:8971/nothing'); const out = await s.page.evaluate(() => ({ cs: document.characterSet, nav: navigator.language, })); console.log(`--lang=${lang.padEnd(6)} navigator.language ${out.nav.padEnd(6)} document.characterSet ${out.cs}`); } finally { await s.close(); } }--lang=uk-UA navigator.language uk-UA document.characterSet windows-1251 --lang=en-US navigator.language en-US document.characterSet windows-1252 --lang=ja-JP navigator.language ja document.characterSet Shift_JISOne byte stream, three decodings, chosen by the reader's browser language.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| document.characterSet is UTF-8 | The browser decoded the page as UTF-8 | Nothing. This is the value that matters, whichever declaration produced it. |
| Header says charset=windows-1251, meta says utf-8 | The header wins and the text is mojibake | Fix the server or proxy. Changing the meta has no effect here. |
| No charset in the header and no meta | The browser guesses from its own language | Add charset=utf-8 to the header, or a <meta charset> in the head. |
| first3 efbbbf on an HTML response | The file carries a UTF-8 BOM | The BOM outranks the header. Remove it unless you meant it. |
Common mistakes
Thresholds
That is how far the browser reads before it starts parsing. A <meta charset> inside that window costs nothing. Past it, Chrome 152 still applied the declaration in this capture, at the cost of a restarted parse.
What to check next
- How to check for garbled characters from the wrong encoding: what the broken rendering looks like, and how to name the pair of encodings that produced it.
- How to check the encoding of a file: the same question for the template or export behind the page.
- How to check JSON encoding: why a charset parameter on
application/jsonis ignored. - How to check content-type of API response: reading the header that decided the verdict here.
- How to change locale in chrome: how to run the step 6 sweep as part of a normal test session.
FAQ
How do I check meta charset without DevTools?
Fetch the first kilobyte and grep it: curl -s https://example.com/ | head -c 1024 | grep -o -i 'charset[^>]*'. That tells you what the document declares. It does not tell you what the browser used, because a header or a BOM can outrank it.
Which declaration wins?
In the capture above: BOM, then the Content-Type header, then <meta charset>, then the browser language. /bom-1251 rendered as UTF-8 under a windows-1251 header, and /header-1251 rendered as windows-1251 despite a utf-8 meta.
Is a missing charset a defect if the page is all ASCII?
Treat it as one. The page is one edit away from carrying a name with an accent, and the fallback is decided by the reader's browser, not by you. example.com declares nothing today and renders correctly only because every byte in it is below 128.
Can I read the encoding from the response body alone?
You can rule encodings out, not name one. A byte sequence that decodes under UTF-8 usually also decodes under windows-1251 with different letters. How to check the encoding of a file works through that asymmetry.
Verified
Verified by Maks Vernycurl 8.21.0Node 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic7 minpublished updated Maks Verny