Fifteen pages told the browser they were English. Between 75% and 98% of their characters were Han.
I only found it because I stopped checking meta tags and started counting characters. The pages had titles, descriptions, canonicals, one h1 each, valid JSON-LD — everything the usual audit looks at. The lang attribute was the one field nobody had ever verified against the body text, and on four domains it was wrong on every page I sampled.
What I sampled
I started with 40 domains. Twelve of their root sitemaps listed nothing but child sitemaps on subdomains, so there was no page URL to sample — that is a different bug and a different post. The remaining 28 domains gave me up to four page URLs each, taken from their own sitemaps and filtered to URLs on the site's own host, excluding the homepage and any .xml entries. No homepage, no sitemap, no XML: real HTML pages only.
That produced 106 pages. I parsed them once for structure and re-fetched 104 of them for the language pass — two timed out on the second pass.
The measurement is not clever. Strip script, style, and noscript, count Han characters, count Latin words, and take the ratio:
import re from html.parser import HTMLParser HAN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") class Text(HTMLParser): def __init__(self): super().__init__() self.skip = 0 self.buf = [] def handle_starttag(self, tag, attrs): if tag in ("script", "style", "noscript", "template"): self.skip += 1 def handle_endtag(self, tag): if tag in ("script", "style", "noscript", "template") and self.skip: self.skip -= 1 def handle_data(self, data): if not self.skip and data.strip(): self.buf.append(data.strip()) def language_fingerprint(html): p = Text() p.feed(html) text = " ".join(p.buf) han = len(HAN.findall(text)) latin = len(re.findall(r"[A-Za-z]+", text)) total = han + latin return { "han": han, "latin": latin, "han_share": round(han / total, 3) if total else None, "declared": (re.search(r'<html[^>]+lang=["\']([^"\']+)', html) or [None, ""])[1], } A 10% threshold separates the two populations cleanly enough that the exact cutoff does not matter: the mislabeled pages sat at 75% and above, and the English pages sat at 0%. There is no ambiguous middle in this sample.
The split
Declared lang | Pages | Actually Chinese (>10% Han) |
|---|---|---|
zh-CN | 48 | 48 |
en | 56 | 15 |
Fifteen of 56 English-declared pages, 26.8%, were Chinese. Put the other way: of the 63 pages in this sample whose body text is Chinese, 23.8% announced themselves as English.
The distribution is not random. All fifteen sat on four domains, and on those domains every page I sampled was wrong — 4 of 4, 4 of 4, 4 of 4, 3 of 3. That is the signature of a constant, not a content mistake. Somebody wrote <html lang="en"> into the root layout of a site whose content pipeline emits Chinese, and nothing downstream ever compared the two.
You can see it in the wild: an FAQ page that declares en while 96.7% of its characters are Han, and a category page with the same declaration and 94.3% Chinese text. Both render fine, both pass a head-tag audit, both would fail this one.
What a wrong lang actually breaks
It does not break rendering, which is why it survives. It breaks the parts of the stack that read the attribute instead of the pixels:
- Screen readers choose a voice from
lang. An English voice reading Han characters either spells them out or skips them, depending on the engine. This is the one failure mode with a real user on the other end. - Translation prompts. Chrome keys its offer to translate off the declared language first. A page that says
enrarely gets the offer, even when every word is Chinese. -
lang-aware CSS.:lang(zh)rules never match, and quote characters, hyphenation, andtext-transformfollow English rules on Chinese text. - Font fallback. The browser resolves fonts per script, but the declared language steers the choice within a script — and it decides whether CJK glyphs come from the page's intended font stack or from whatever the OS falls back to.
- Search. Google has said for years that it infers page language primarily from the visible content, so I am not going to claim a ranking delta from 104 pages. What I can say is that the stack had no second opinion to offer: 28 of 100 pages carried any
hreflangalternate, zero of 100 sent aContent-Languageheader, and all fifteen mislabeled pages carried zerohreflang. Nothing in the markup disagreed with the wrong tag.
The control case is boring, which is the point: the same FAQ template on another domain in the same network declares en and is 0% Han. Same generator, correct locale, because that site's config was set correctly.
The check, as a CI gate
I turned this into a script that crawls a sample and exits non-zero. It is thirty lines and it would have caught all fifteen:
import sys, urllib.request from language_fingerprint import language_fingerprint # the function above URLS = [...] # one page per template, not every page def check(url, expect): req = urllib.request.Request(url, headers={"User-Agent": "lang-guard/1.0"}) with urllib.request.urlopen(req, timeout=20) as r: fp = language_fingerprint(r.read().decode("utf-8", "replace")) declared = fp["declared"].split("-")[0].lower() share = fp["han_share"] or 0 implied = "zh" if share > 0.10 else "en" ok = declared == implied print(f"{'ok ' if ok else 'FAIL'} {url} declared={declared} " f"implied={implied} han={share}") return ok failed = [u for u, e in URLS if not check(u, e)] sys.exit(1 if failed else 0) Run it on one representative page per template — not per URL. The failure I found was template-level, so a four-URL sample per site was enough to catch it on four sites out of 28.
The fix is one line, the source of truth is the hard part
<html lang="en"> in a root layout is a hardcoded default pretending to be a decision. The page's language belongs to the content, or to the locale segment of the route, and the layout should read it:
// __root.tsx — one locale, resolved per request, never a literal export function Root({ locale, children }: { locale: "en" | "zh"; children: ReactNode }) { return ( <html lang={locale}> <body>{children}</body> </html> ); } If articles carry their own language column, use that instead and let the route default be a fallback that logs when it fires. A fallback that fires silently is how you end up with a site whose Chinese content announces itself as English for as long as nobody counts characters.
Two more things the same crawl turned up
2,777 leaked source annotations. Seven pages across two domains shipped attributes like data-tsd-source="/src/components/Layout.tsx:10:9" — 2,777 of them in total. That is TanStack Start's dev-mode source annotation surviving into production HTML. It costs users nothing to download and it hands anyone who views source your component tree, file names, and line numbers. If you see it, your production build is running with the dev transform on.
Zero Content-Language headers. Not one of the 100 pages that answered sent the header. That is normal for most static stacks and not a bug on its own — but combined with a wrong lang, it means the response carried no language signal that was correct.
What this does not prove
Four pages per domain, one crawl, one afternoon. The Han-ratio heuristic ignores mixed-script pages, and I dropped four URLs that failed on re-fetch rather than retrying them. I have no before-and-after data on indexing or rankings, because I changed nothing — this was a measurement pass, and the fix is not shipped yet.
What it does establish is narrower and more useful: a head-tag audit says nothing about whether your lang attribute is true, and on a 28-site sample, 15 pages were lying about it in the same direction, from the same root layout, on four sites that had never checked.
