Meta charset and UTF-8: how to check a page's character encoding

Short answer

A page's character encoding tells the browser how to turn bytes into letters. Use UTF-8, and say so in two places that agree: the server's Content-Type: text/html; charset=utf-8 header, and <meta charset="utf-8"> as the very first thing in the head. If the encoding is missing, declared too late or contradicts itself, browsers can guess wrong and show garbled text such as ’ instead of an apostrophe.

What the encoding does

Text is stored as bytes. The encoding is the key for reading them back. When the browser uses the wrong key, accented letters, curly quotes, dashes and symbols such as £ come out wrong:

Read as UTF-8 correct
Don’t miss our café menu – £5 today
Same bytes read as Windows-1252 wrong guess
Don’t miss our café menu – £5 today

That garbled text has a name, mojibake. The same thing can happen to product names, addresses and anything else with accents, curly quotes, dashes or currency symbols, and search engines can end up indexing the garbled version.

The HTML standard says pages must use UTF-8, which covers every language and symbol. Other encodings still turn up on old sites and old templates.

Where it's declared, and which wins

There are three places a page can state its encoding. If they disagree, browsers use the first one they find in this order:

PlaceLooks likeNotes
1. Byte order markThree invisible bytes at the very start of the fileUsually added by a text editor. Wins over everything else
2. HTTP headerContent-Type: text/html; charset=utf-8Set by the server or CDN. Beats the meta tag
3. Meta tag<meta charset="utf-8">Used when there's no BOM and the header doesn't say

So a page whose header says charset=iso-8859-1 but whose meta tag says utf-8 is read as Windows-1252 (browsers treat ISO-8859-1 as Windows-1252), whatever the HTML says. This often happens when a site moves to a new server or CDN that sends its own header. A page should declare its encoding in the HTML once: one <meta charset>, or the older http-equiv form, not both.

Why meta charset="utf-8" must come early

When the encoding isn't in the header, the browser has to find the meta tag before it starts reading the page as text. The HTML standard says the declaration must sit entirely within the first 1,024 bytes of the page. If it's later, behind a long comment, inline styles or a big block of tracking script, the browser may already have guessed wrong. Google recommends UTF-8 where possible.

The simplest rule: make <meta charset="utf-8"> the first line inside <head>. It also keeps it clear of the stray elements that can end the head early (see invalid elements in the head).

How to check by hand

1Run the encoding snippet 30 seconds

Open DevTools (F12), go to the Console and paste:

// Shows which character encoding the page uses, where it's declared, and whether the declarations agree
const res = await fetch(location.href, { cache: 'no-store' });
const header = ((res.headers.get('content-type') || '').match(/charset\s*=\s*["']?([\w:.-]+)/i) || [])[1] || '';
const bytes = new Uint8Array(await res.arrayBuffer());
// windows-1252 gives one character per byte, so string positions are byte positions
const raw = new TextDecoder('windows-1252').decode(bytes);
const bom = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF;
// Blank out comments, scripts and styles (same length, so byte positions don't move)
const blank = s => ' '.repeat(s.length);
const html = raw.replace(/<!--[\s\S]*?(-->|$)/g, blank)
  .replace(/(<(script|style)\b[^>]*>)([\s\S]*?)(<\/\2\s*>|$)/gi, (m, open, tag, body, close) => open + blank(body) + close);
// The head ends at </head>, <body> or the first element that can't be in the head
const headEnd = html.search(/<\/head\s*>|<(?!\/?(html|head|meta|title|link|style|script|base|noscript|template)\b)[a-z]/i);
const decls = [...html.matchAll(/<meta\b[^>]*?charset\s*=\s*["']?([\w:.-]+)[^>]*>/gi)]
  .filter(m => headEnd < 0 || m.index < headEnd)
  .map(m => ({ charset: m[1], endsAtByte: m.index + m[0].length }));
// Browsers treat many labels as one encoding (utf8 = UTF-8; iso-8859-1, latin1 and ascii = windows-1252)
const norm = s => { try { return new TextDecoder(s).encoding; } catch (e) { return (s || '').toLowerCase(); } };
const used = bom ? 'utf-8' : norm(header || (decls[0] && decls[0].charset) || 'utf-8');
console.table({
  'Encoding the browser used': document.characterSet,
  'Content-Type header': header || '(no charset)',
  'Byte order mark': bom ? 'UTF-8 BOM' : 'none',
  'Meta declarations': decls.length ? decls.map(d => d.charset + ' (ends at byte ' + d.endsAtByte + ')').join(', ') : '(none)',
  Result: !header && !bom && !decls.length ? 'MISSING: browsers have to guess'
    : decls.length > 1 ? 'MORE THAN ONE meta declaration'
    : bom && header && norm(header) !== 'utf-8' ? 'CONFLICT: the byte order mark wins, so the page is read as UTF-8'
    : header && decls.some(d => norm(d.charset) !== norm(header)) ? 'CONFLICT: the header wins'
    : !header && !bom && decls[0].endsAtByte > 1024 ? 'TOO LATE: must end within the first 1,024 bytes'
    : used !== 'utf-8' ? 'Declared, but not UTF-8 (' + used + ')'
    : 'ok',
});

It shows the encoding the browser actually used, the charset in the HTTP header, whether there's a byte order mark, and each meta declaration with the byte it ends at. Then it says whether the encoding is missing, declared more than once, conflicting, too late or not UTF-8.

It fetches the page again and reads the raw bytes, so it sees what the server sends rather than what the browser built. It skips comments and scripts, but it's still a pattern match, not a full HTML parser.

2Check the header in DevTools 1 minute

Open the Network tab, reload, click the page's own request and look under Response Headers for content-type. It should end charset=utf-8. Then type document.characterSet in the Console to see what the browser used.

3Look at the top of the source 30 seconds

Press Ctrl+U (⌘+⌥+U on a Mac). <meta charset="utf-8"> should be the first thing inside <head>, near the top of the file, and appear only once.

How to fix it

  1. Save the files as UTF-8 and make sure the database and templates use UTF-8 too. Declaring UTF-8 doesn't help if the bytes are something else.
  2. Send the header from the server or CDN: Content-Type: text/html; charset=utf-8.
  3. Put the meta tag first in the head: <meta charset="utf-8">, once.
  4. Remove contradictions, such as an old <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> left in a template.

Doing this on every page?

SEOSnip checks the encoding for you

Open the extension on any page and it reads the HTTP header and the server HTML. You get:

  • A missing declaration, when there's no header charset, no byte order mark and no meta tag
  • More than one meta charset, each with its line in the server HTML
  • A meta charset that ends after byte 1,024, when the header doesn't declare the encoding
  • A header and meta tag that disagree, side by side
  • A ticket with that evidence, and a "Check if fixed" button that re-runs the check