DEV Community

Chen Yifeng
Chen Yifeng

Posted on AI-assisted

Shipping Chinese web fonts at ~100 KB per page instead of 14 MB

Chinese fonts are huge. A full Noto Serif SC woff2 is 8.6 MB; Noto Sans SC is another 5.8 MB. On Life K-Lines, a bilingual site about BaZi (Chinese Four Pillars astrology), both faces were declared in the global stylesheet, so every one of our 623 prerendered pages could pull down 14.38 MB of fonts on a first visit.

Today a typical content page ships about 100 KB of fonts (median 102 KB, p90 132 KB, worst page 465 KB). Here is how, and the two mistakes we made on the way.

The idea: subset per page, after the build

A page only uses a few hundred to a few thousand distinct characters. If you know exactly which ones, you can cut a tiny woff2 for that page.

We already prerender every content route to static HTML, so the character list is just sitting in dist/. The pipeline runs as an npm postbuild step, after Vite has finished writing files:

  1. Derive charsets. Parse each HTML file, walk the text nodes, and record which characters render in which font face (sans body text, serif headings, a calligraphic face for quotes).
  2. Cut subsets. Group by face and write small woff2 files into dist/fonts/.
  3. Inject. Replace the global @font-face rules on each page with an inline <style> that points only at that page's files.
// Simplified: one page, one face
import subsetFont from 'subset-font';

const chars = collectChars(html, face);          // e.g. "八字大运流年…"
const woff2 = await subsetFont(sourceTtf, chars, { targetFormat: 'woff2' });
const name = `${face}-${sha256(woff2).slice(0, 8)}.woff2`;
writeFileSync(`dist/fonts/${name}`, woff2);
Enter fullscreen mode Exit fullscreen mode

Content-hashed names need a deterministic cutter

File names are content hashes so they can be cached forever. That only works if the same input always produces byte-identical output. We checked it directly: running the cutter in a separate process, and from a woff2 source versus a pre-decompressed TTF source, produced the same sha256 for every file. If you upgrade the subsetting library, run that check again.

A fallback for characters you did not see

Text that appears at runtime (user input, a chart a visitor generates) is not in the prerendered HTML. For that we ship one shared fallback face, about 1.2 MB, listed last in the font-family stack. Browsers only download a font when a glyph actually needs it, so most visitors never fetch it. Since it is the same file on every page, it is cached once for the whole site.

Mistake 1: decompressing woff2 inside the hot loop

The first full run took 375 s on a 14-core laptop. Most of that was the subsetter decompressing the 8 MB woff2 source again for every page.

Decompressing each source font to TTF once, inside each worker, brought it to 145.8 s with byte-identical output.

Mistake 2: trusting a laptop speedup on CI

Next we used worker_threads. Locally: 4 workers took 38.3 s (3.8×), 8 workers took 22.5 s (6.5×). Total output bytes were identical across runs, so the result does not depend on concurrency.

Then we predicted CI times from the laptop ratios and got it wrong twice:

Setup Laptop (14 cores) Linux CI (2 cores)
Sequential, woff2 source 375 s ~1,013 s (estimated; would hit our 900 s watchdog)
Sequential, TTF source 145.8 s 273.4 s (measured)
2 workers, TTF source 81.6 s 165–180 s (measured)
  • Parallelism is not portable. We assumed 4 workers on CI. The runner has 2 cores and our worker count is cores - 1, so it actually ran one. The ratio between machines carried over; the worker count did not. Now the first log line prints how many workers it started.
  • Speedups measured on many cores are optimistic. Going from 1 to 2 workers locally was 1.90×, which predicted ~144 s on CI. CI measured 1.52–1.66×. On a 2-core box the main thread is not idle: it handles about 1,800 worker messages and competes for GC.

Memory surprised us too. Our first note said peak memory was 1.15 GB. That number missed the main thread holding JSDOM for all 623 pages. A full measurement showed 3.38 GiB, about 3× higher. That is why the worker cap is now 2.

The time budget is real: Netlify kills builds at 18 minutes, and we once lost a build at 17:59. Our gate keeps the whole build under 540 s, half the limit.

Bonus: content pages without the app bundle

The same postbuild step does one more thing. Wiki articles, book chapters and quiz pages are fully readable as static HTML, so we remove the <script type="module"> that loads the multi-MB React bundle from those pages. CSS stays, because Vite emits it as a separate <link>.

Two details made this safe:

  • Assert what a visitor can click. On chapter pages, every interactive element was already a real <a href> (breadcrumbs, previous/next). A snapshot test fails if a <button> ever appears there, because without the bundle it would do nothing.
  • Use literal expected counts. The script originally compared "pages stripped" with the length of the same route array it was looping over. That can never fail, because both numbers come from the same list. Each tier now has a hard-coded expected count, so a route that silently drops out of the build turns CI red.

"Zero JS" is not literally true: the pages still carry the analytics tag and a small inline theme script that sets light or dark mode before first paint. The precise claim is that they never fetch the app bundle, and the test checks exactly that.

Results

On a live wiki article today, the page HTML is about 31 KB, the per-page fonts add up to about 94 KB, and no application JavaScript is loaded. Before this work, the same page could cost 14 MB in fonts alone.

If you are building a CJK site with a static or prerendered build, per-page subsetting after the build is a good trade: it adds build time, but every visitor downloads far less.

You can see the result on any article at lifeklines.com. I'm happy to answer questions about the pipeline in the comments.

Top comments (0)