Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

A 5.86 MB font sat in my app for three months. No CSS rule ever referenced it.

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-wei

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:

  1. A 5.86 MB display font that nothing referenced.
  2. A display path that depends on fonts the app does not ship.
  3. 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:

  1. 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%.
  2. Subsetting is a 9.7× win. 604,040 B for the name charset — 10.3% of the file that ships today.
  3. 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-cn variable 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 + a unicode-range so 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.