Last week I ran a grep I should have run in August:
grep -rn "Ma Shan Zheng" src/ One hit. And it was the @font-face block itself:
@font-face { font-family: 'Ma Shan Zheng'; font-style: normal; font-weight: 400; src: url('/fonts/MaShanZheng.ttf') format('truetype'); } Every other line of CSS and JSX in the project — 52 files — never mentions that family name. Not once. The same string appears zero times in the built JS bundle.
So a 5,857,164-byte file had been travelling inside my web build, my Android APK and my iOS app, waiting for a style rule that was never written.
Why nothing warned me
This is the part that makes the bug durable. An @font-face block is a declaration, not a request. The browser only fetches the file when some rendered element actually resolves to that family. So:
- No entry in the Network panel.
- No 404, no console warning, no build error.
- No visible layout bug, because the fallback was always the system font and the system font is fine.
- No performance regression to notice, because on the web the cost was zero bytes transferred.
The file was silently absent from every signal I normally use to catch mistakes. It only cost something in the packaged app, where public/ gets copied into the bundle whether the font is referenced or not. Five megabytes, doing nothing, on every install.
What is actually inside the 5.86 MB
I parsed the TTF tables by hand before touching anything:
| Property | Value |
|---|---|
| File size | 5,857,164 B (5.59 MiB) |
| Format | raw TTF (format('truetype')), no WOFF, no WOFF2 |
glyf table | 5,696,434 B — 97.3% of the file |
loca / post / GPOS | 28,068 / 69,190 / 5,446 B |
GSUB | 30 B — effectively empty, so no shaped substitutions |
numGlyphs | 7,016 |
| Mapped codepoints | 7,015 (7,016 glyphs − 1 for .notdef) |
| Han codepoints | 6,763 |
| ASCII codepoints | 95 |
unitsPerEm | 1000 |
6,763 Han characters is not a coincidence — that is exactly the GB2312 level-1 + level-2 set. This font was built to cover "Chinese text, generally". My app does not render Chinese text generally. It renders a fixed, enumerable set of strings.
The display path skipped the font too
The second finding was worse than the dead file. My share-card generator builds an SVG string in a React component and rasterises it to PNG. The typeface for the seal is:
const fontFamily = "'STSongti-SC','Songti SC','Noto Serif SC','SimSun',serif"; Four families, none of them bundled with the app. public/fonts/ contains exactly one file, and it is not any of these. So the single most brand-critical piece of typography in the product — the large calligraphy characters on a 1080 × 1080 share image — resolves differently on every device and every OS version. On a device with none of the four, it lands on the generic serif mapping, which for CJK is not the same face across platforms.
Meanwhile the actual in-app calligraphy turned out to be 23 pre-rendered .webp images, keyed by name. Which is why nobody ever noticed the font had no job: the font had already been replaced, and removing it was never on the checklist.
So the situation was:
- A 5.86 MB display font that nothing referenced.
- A display path that depends on fonts the app does not ship.
- Pre-rendered images covering 23 names, presumably intended as the real answer.
Three different mechanisms, all doing part of the same job badly.
The measurement I should have started with: the charset
Before deciding whether to delete, subset, or actually use the font, I needed the only number that matters: how many glyphs does the app's text really use?
I extracted every Han codepoint from the data files separately, per role:
| Role | Distinct Han |
|---|---|
surname_map_v2.json (surname pool) | 647 |
characters_master.json (given-name pool) | 1,154 |
| Union of the two = the name charset | 1,388 |
past_life_stories.ts (narrative prose) | 3,826 |
hero_db.json | 2,872 |
beast_db.json | 2,406 |
| Union of all data files | 4,196 |
| Union of all 52 source files (UI labels included) | 4,626 |
Against the font's 6,763 Han, utilisation is:
- 20.5% if the font is used only for names
- 62.0% for all data files
- 68.4% for the whole source tree
But "utilisation" flatters the file, because these are three different type roles wearing one costume. Names are display type: huge, decorative, at most 3 characters at a time. Story prose is body type: thousands of characters, small sizes, where a calligraphic face is unreadable and belongs to the system UI font anyway. Bundling both in one file means the display face carries 3,000+ glyphs it will never be asked for in that role.
What subsetting actually buys
I installed fonttools and ran the real thing rather than estimating. Subsetting with --text limited to each charset, --no-hinting --desubroutinize --layout-features=*:
| Build | TTF | WOFF | WOFF2 |
|---|---|---|---|
| Shipped today (full font) | 5,857,164 | — | 3,234,084 |
| Subset to name charset (1,388 Han + 95 ASCII) | 1,060,368 | 696,116 | 604,040 |
| Subset to all source charset (4,626 Han) | 3,741,344 | — | 2,100,704 |
Three things fall out of that table:
- Format alone is a 1.8× win. The same full font as WOFF2 is 3,234,084 B instead of 5,857,164 B. Shipping a raw TTF in 2026 means handing the client a file that Brotli would have shrunk by 45%.
- Subsetting is a 9.7× win. 604,040 B for the name charset — 10.3% of the file that ships today.
- Subset size scales with the role, not the font. Names cost 604 KB. Prose costs 2.1 MB. If the same face were serving both roles, you would pay the prose price for the display benefit.
gzip -9 on the raw TTF gives 3,803,252 B — that is what a plain HTTP-compressed TTF looks like, and it is still 35% worse than converting to WOFF2. The glyf outlines are already compact; generic deflate does poorly on them, which is precisely why WOFF2 exists.
The 2 characters with no glyph
Here is the finding that would have bitten me if I had shipped the font as an actual fix.
I intersected the name charset with the font's cmap. Of 1,388 characters, two are not in the font: 昇 (U+6607) and 珅 (U+73C5). Both are in characters_master.json — the given-name pool that feeds generated names.
Neither char is in GB2312, so a font built to the GB2312 spec has no reason to include them. But a name is the one string in the whole product where the user looks at two characters at maximum size and zero tolerance. If this font had been wired up, the failure mode would have been one character in a different typeface, mid-name, only for users who happened to draw that character — which is to say: never reproducible in my own testing, always visible to a stranger.
The lesson is not "check your font's coverage". It is that coverage must be checked against the strings you will actually render, computed from data, in CI — not eyeballed against "the file has 6,763 characters, that's plenty".
The audit, as ~40 lines
This is the part I am keeping. The script below is the one I wrote for this audit; it goes into a pre-commit hook so that any @font-face without a consumer fails before the commit lands.
// audit-fonts.mjs — flag declared-but-unreferenced font files import fs from 'node:fs'; import path from 'node:path'; const walk = (dir, out = []) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (!['node_modules', 'dist', '.git'].includes(e.name)) walk(p, out); } else if (/\.(css|ts|tsx|js|jsx|html)$/.test(e.name)) out.push(p); } return out; }; const files = walk('src'); const text = files.map((f) => fs.readFileSync(f, 'utf8')); // 1. every family declared by @font-face, plus the file it points at const declared = []; for (const src of text) { for (const m of src.matchAll(/@font-face\s*\{([\s\S]*?)\}/g)) { const block = m[1]; const family = /font-family:\s*([^;]+);/.exec(block)?.[1].trim().replace(/^['"]|['"]$/g, ''); const url = /url\((['"]?)([^'")]+)\1\)/.exec(block)?.[2]; if (family) declared.push({ family, url }); } } // 2. count consumers: any occurrence outside the @font-face blocks themselves const stripped = text.map((s) => s.replace(/@font-face\s*\{[\s\S]*?\}/g, '')); const sizeOf = (url) => { const p = path.join('public', (url || '').replace(/^\//, '')); return fs.existsSync(p) ? fs.statSync(p).size : null; }; let failed = false; for (const { family, url } of declared) { const hits = stripped.filter((s) => s.includes(family)).length; const bytes = sizeOf(url); const status = hits === 0 ? 'ORPHAN' : 'used'; if (hits === 0) failed = true; console.log( `${status.padEnd(7)} ${family.padEnd(20)} consumers=${hits} bytes=${bytes ?? 'missing'} ${url ?? ''}`, ); } process.exit(failed ? 1 : 0); Output on my repo:
ORPHAN Ma Shan Zheng consumers=0 bytes=5857164 /fonts/MaShanZheng.ttf The two extra checks I added after this:
# charset actually rendered, per role — feed this to --text, then diff against cmap import json, re def han(s): return {c for c in s if 0x4E00 <= ord(c) <= 0x9FFF} pool = han(open('src/data/characters_master.json').read()) | han(open('src/data/surname_map_v2.json').read()) print(len(pool)) # 1388 and a coverage assertion: set(pool) - set(cmap_from_font) must be empty, or the build fails. Two characters would have failed it today.
What I am doing about it
The decision is not "delete the font" or "use the font" — it is to stop letting one file serve three roles.
- Body and UI text: system stack only. The
--font-cnvariable already does this, and it is the right call: the OS renders CJK body text better than anything I can bundle at 600 KB. - Display (names, seal characters): either keep the pre-rendered images, or subset to the 1,388-character name pool as WOFF2 (604,040 B) and bundle that, with a coverage assertion in CI and
font-display: swap+ aunicode-rangeso it never blocks first paint. - Share-card SVG: stop naming fonts the app does not ship. Either bundle the one face and pass it to the SVG, or draw from the pre-rendered assets. "Whatever serif this OS has" is not a brand decision, it is an accident that renders 1080 px wide.
The part I keep coming back to: the file was never wrong. It was correct, subsettable, and reasonably compressed once I stopped shipping it as a raw TTF. What was missing was any check that asked a font the only question that matters — is anyone rendering you?
Fonts are the largest binary assets in most frontends and the only ones whose absence is completely silent. A 40-line grep in a pre-commit hook would have caught three months of this.
I build NameSoul, an app that generates Chinese names with a past-life story attached, across 10 languages — which is where a 6,763-glyph calligraphy font and a 1,388-character name pool ended up in the same repository. The numbers above are from that repo, measured today.
![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)

