Two Weeks of Self-Hosting CJK Fonts: From Lighthouse to CLReq

1. The Trigger

It started with a screenshot a friend sent me one day. Which post it was of, I no longer remember; the details of the screenshot itself are gone too — but one glance was enough. On Windows, the blog really did look ugly.

Pinning the whole thing on Microsoft YaHei would be unfair. I’m not a type specialist; I can’t tell you which stroke weight, which hinting decision, or which spacing rule is the one that hurts. But there was no single cause. YaHei is part of it: Windows has shipped it as the default Simplified Chinese UI typeface since Vista1, and the design’s hinting choices, tuned for an earlier era’s screen-rendering pipeline, read heavy and slightly muddy at body-text sizes today. Windows’ font-rendering pipeline is part of it. And the display is part of it — my friend wasn’t on a cheap panel, but compared to the 6K display I use every day, the pixel density was a clear step down. That step down matters more for CJK than it would for Latin: a Han character carries more distinguishing detail per unit area than a Latin letter does — counters, internal stroke separations, component boundaries — and the same loss of pixel density that mildly degrades Latin text degrades CJK text more sharply.

This forced me to confront something I had never actually thought through: your website is not for you. As a long-time non-Windows user — over a decade on Linux before macOS — sitting at a 6K display every working day, I had only ever seen this blog under ideal conditions. Without realizing it, I had let those conditions become “what the site looks like.” Readers were not like that. The asymmetry between what I saw and what a typical reader saw was sharper than the equivalent gap on a Latin-script site would have been. To a working designer this is day-one common sense; for someone whose job is writing code, it took a screenshot to the face from a friend before any of it surfaced. Looking back, font rendering was never an isolated visual concern. It is structurally a slice of accessibility — making a paragraph readable across hardware, operating systems, and pixel densities is the same kind of problem as making it audible to a screen reader, or distinguishable to a color-blind eye — though the frame is probably wider than the actual job in front of me.

None of this contradicts my long-standing posture of “I don’t write with an audience in mind.” That posture means I don’t write for traffic; the content comes before every metric. But the site itself has to account for every reader — as a matter of parity, not pandering. I do full GDPR compliance for an EU readership that is effectively zero, and full a11y work for an equally negligible screen-reader audience. Optimizing fonts for readers on Windows with commodity displays is a different slice of the same commitment.

I opened a ticket that day. It sounds faintly ridiculous — running my own blog through that kind of process — but overengineering has always been my default mode. A week later, I started.

2. Phase One: Self-Hosting and Iterative Subsetting

When I opened that ticket, I had no idea what I was walking into. My expectation was a weekend-afternoon job: pick a decent Chinese typeface, hand it to an off-the-shelf subsetting tool, wire it up with @font-face and preload, done. The expectation was reasonable for Latin: a standard Latin web font runs a few hundred glyphs and weighs a few dozen kilobytes, and subsetting is a marginal optimization. CJK is not that. A single weight of a typical Han-coverage typeface runs to multiple megabytes2, and subsetting is the only path to anything shippable. Plenty of Chinese indie blogs accept the alternative and just hand the reader the whole file; I wasn’t willing to.

GDPR ruled remotely-hosted fonts — Google Fonts and the like — out from the start, since those send each reader’s IP address to a third party. That left self-hosting. Even without that hard constraint, no pre-sliced hosted font would have given me the degree of customization I would eventually realize I needed.

I picked Source Han Sans, the Adobe-Google Pan-CJK family released in 2014 by Adobe as Source Han Sans and by Google as Noto Sans CJK. It covers Simplified Chinese, Traditional Chinese, Japanese, and Korean through region-specific glyph sets held inside a unified design. In hindsight my pick wasn’t necessarily optimal: Source Han is heavy, and even subsetted it remains a megabyte-class load. The other widely-loved option in Chinese blog circles is LXGW WenKai, but I disliked it. It’s rooted in the kaiti tradition — a text family that is calligraphic in origin but standardized in use, which I return to in Section 5 — and reads as expressive rather than neutral, drawing the reader’s eye to the typeface before the content. My rough view of typography is this: good typography lets the reader forget typography is happening and returns attention to the words. A pretty typeface and good typography are not the same thing. Source Han Sans clears that bar — neutral, quiet, plain to the point of near-invisibility.

For subsetting, I looked at cn-font-split. It is a CJK-specific subsetting tool with strong opinions baked in: automatic frequency analysis, automatic slicing decisions, a workflow shaped around a particular vision of what CJK web typography should look like. Its craft is careful; the conclusion wasn’t that it’s bad. The problem was structural. Once your customization needs cross a certain threshold, even a well-built opinionated tool starts to fight your intent at the design level — every problem you solve, you solve by routing around two decisions it has already made for you. At that point, building your own pipeline against a generic low-level library is cheaper than negotiating with one that has its own ideas. So I dropped cn-font-split and built a pipeline of my own on top of subset-font.

This was the mouth of the rabbit hole, though I didn’t see it at the time.

My CI runs Lighthouse on every change; the Performance score had been part of the pipeline long before this ticket. For the next full week, I did nothing but add — chasing it. Add the font: the homepage’s score tanks. Slice by weight, splitting 400/500/600/700 into separate WOFF2 files: it climbs back. Long-form posts are still slow; slice per page so each page only downloads the characters it actually uses. List pages and detail pages need different weights anyway, so split the weights along the same page-type axis. By this point a single page type at a single weight is one file, and the site is generating dozens. High-frequency characters appear on every page, so factor them out into a cross-page common pool, preload it once, let HTTP caching take care of the rest. To avoid making readers re-download fonts they already have locally, @font-face’s src lists local(...) before url(...), written down to the PostScript name3 per system and per weight — so any reader with PingFang or Source Han already installed skips the CJK chain entirely; only the small preloaded floor slice still downloads. The chain reads like a sequence of reasonable decisions, but none was planned in advance. Each was forced by the loose end the previous step left behind.

By the end of that week, the major pages’ Lighthouse Performance scores had crawled back to roughly 90. Before merging the PR, I had already seen the first crack — but I merged the foundation in anyway.

3. Two Cracks

The first crack was coverage. Before merging the PR I’d done a check: static pages were fine — every character in use was in the subset. Dynamic content was impossible to guarantee that way: features like AI search can produce any character, and a build-time scanner cannot see what hasn’t been written yet. Whenever the page rendered a character outside the subset, the browser fell back to the system font, and with Source Han Sans and Microsoft YaHei sitting in the same sentence the seam was instantly visible — picture a sans-serif paragraph with a single serif letter dropped in, and you have the rough texture.

The coverage gap is uniquely a CJK problem. The Latin alphabet is small — a few hundred glyphs cover the basic letters, punctuation, and diacritics for most European languages — and a Latin web font ships them all by default; the developer never thinks about character coverage. A Han character is a different kind of unit. There is no sub-character alphabet that combines into characters the way letters combine into words; each character is a self-contained two-dimensional shape, and a Chinese font has to ship every character it might render as its own glyph. Common Chinese coverage starts around eight thousand characters; the CJK Unified Ideographs blocks together approach one hundred thousand encoded characters. A site can’t ship them all; it ships only what it uses, and anything outside that subset falls through to the reader’s system font.

Two paths from there: let the dynamic regions fall back to system fonts wholesale, accepting uniformity at a coarser grain; or redo the subsetting so every CJK character the typeface covers makes it into some slice. I picked the second — I couldn’t accept a font that wasn’t uniform within a rendered run of text. (A page set entirely in PingFang is acceptable; a sentence split between Source Han Sans and YaHei is not.) There’s no objective scale on which I made that call. It was “this looks wrong to me,” and that’s how every typography judgment of mine has always been made.

That was the first crack. The second surfaced around the same time and looked unrelated to coverage. They turned out to be the same problem; the next section is where that becomes visible.

Because of an integration error I’d made earlier, the JS half of Heti had never run on my blog. The name comes from hètí, the classical Chinese word for paper; Sivan’s library is one of a small cluster of community-maintained tools that handle the CJK web typography rules CSS doesn’t yet provide. The CSS half ran perfectly — text-block geometry, line-height, blockquotes, lists, all the structural rules — and I had no visual cue that anything was missing. But the JS half is precisely what handles the fiddly details of CJK typography: kerning between adjacent full-width punctuation, spacing between CJK characters and Latin ones4. Those details are easy for non-designers to miss in the first place — and the most visually obvious of them, CJK/Latin spacing, was hidden from me by an unrelated coincidence: VSCode’s autocorrect extension was inserting real space characters between CJK and Latin runs on save. The Markdown source carried those spaces; the browser rendered them and the result looked “normal.” But that was VSCode doing the work, not Heti. I hadn’t noticed any of this until I went back to check. A system that looks like it’s working is often just a system no one is paying close attention to.

That settled another decision: rather than keep trying to repair the runtime integration, move the text rewriting out of the browser entirely and into the build step.

I packed both fixes into the phase-two PR: redo the coverage strategy, and replace the Heti library with a build-time pipeline. At the time I thought I was bundling two unrelated problems. The work itself corrected that assumption.

4. Phase Two: From Per-Page Slicing to unicode-range, and Retiring Heti

When I opened the phase-two PR, I added a long-form post of roughly half a million characters to the Lighthouse evaluation list. That is a self-imposed-difficulty game — someone else’s blog with a perfect 4×100 may only have measured the default homepage; mine got measured against pages like that one.

The subsetting strategy moved from “slice per page” to “slice the entire site by character frequency.” The mechanic is CSS’s unicode-range: each @font-face declaration carries a list of codepoint ranges, and the browser fetches that font file only when it encounters a character whose codepoint falls within the range. Stack several such declarations and the browser handles the lazy-loading itself: when layout needs a codepoint covered only by a later declaration, it fetches that slice.

The remaining decision is how to bucket characters into slices. Han characters follow a steep frequency power law — the most common few thousand cover most of any natural text, with a long tail trailing into the tens of thousands. What you want is the inverse of distributed-storage thinking: a sharded system wants entropy maximized so no shard becomes a hot spot, but font slicing wants entropy minimized — the common characters concentrated into a tiny slice every page draws from, the long tail segregated into slices most pages never touch. The hot spot is the goal.

The recipe: rank characters by site-wide frequency, produce three slice tiers — floor, cjk-common, and a series of cjk-extended buckets — each declaration carrying its own unicode-range; whenever the page hits a character it hasn’t loaded, the browser auto-fetches the slice that covers it. Three wins, in rough order of weight: characters in dynamic content match the right slice as long as the typeface itself covers them — that’s the big one; slice filenames are stable, so HTTP caching transfers cleanly across pages; build-time bookkeeping no longer needs page keys, prop drilling, or any of that brittle abstraction stack.

Slices are not treated equally. The floor slice at the 400 (regular) weight — basic punctuation, ASCII, the Latin supplement — ships with the HTML, lives in the critical CSS5, and is preloaded; the cjk-common slice at the same weight enters critical CSS but is not preloaded, so the browser only fetches it after seeing a unicode-range hit; everything else is deferred until idle. The local()-before-url() strategy carries over — and with more slices in the chain, local hits become more visible: a macOS reader with PingFang installed pays only for the preloaded floor slice; the rest of the chain downloads zero bytes.

The adjacent-punctuation kerning that previously ran in Heti’s runtime JS was rewritten as a rehype plugin6, which traverses the HAST tree to identify seven categories of adjacent punctuation, classifies each pair as full-to-full or half-to-full, applies either half or quarter compression, and emits an <x-h> wrapper element with the corresponding class. Static text that doesn’t go through Markdown rendering — post titles, the table of contents — gets the same treatment via a small HetiAdjacentText.astro component. Heti’s own structural CSS — text-block geometry, line-height, lists, blockquotes, headings — was vendored wholesale into the project.

CJK/Latin spacing was not given a build-time path. As the previous section noted, autocorrect had already been inserting real space characters into the Markdown source. This pipeline simply ratifies that as the single source of truth. Heti’s old runtime JS would trim those spaces and fake them back in with margin; the new approach does nothing at all. git diff, grep, and copy-paste all now see the same text the browser does.

The moment the coverage refactor was finished, the long-form post’s Lighthouse score went from 89 — its level on main immediately before the phase-two PR merged — down to 35. The score makes the two weeks of font work look wasted; but Lighthouse only measures request transfer size, and megabyte-class CJK fonts are the unavoidable cost there. What the slicing strategy actually bought — coverage, dynamic content, a sane fallback path — sits outside what the metric measures. Anything past 35 had to come from outside the font pipeline. After those adjustments went in, the score climbed back; it has since stabilized somewhere between 69 and 91 — across multiple runs the spread between best and worst is 22 points.

Twenty-two points of variance pushed me to start questioning metric-driven optimization itself. Lighthouse simulates a slow mobile network, and in the environment I was measuring against, no matching CJK font was available locally, so the run counted every subset slice the test page triggered. Real mobile readers are not in that situation: Android ships Noto Sans CJK, iOS ships PingFang, and both are on my local() list — once local() matches, the url() subsets are never fetched at all. Which means I had been adding to a build in pursuit of a bottleneck that, for most real readers, did not exist. Goodhart’s law says that when a measure becomes a target, it ceases to be a good measure. The shape of that law slowly emerged in this work, and I started thinking about where the boundary between yak shaving and reasonable optimization actually sits — a question I do not have an answer to.

Phase two took a week. Combined with phase one, two weeks total.

Even so, the final performance score still doesn’t fully satisfy me. There’s another direction I plan to take separately: split long-form posts into a second-pass load — render the first few screens up front, append the rest as soon as the first screen is ready. The side effect is a race in the first-screen window: a reader who hits End during it will catch a flicker. But that’s another story, and not one to open here.

Stranger to me than the performance curve, though, was what I picked up that week from skimming CLReq and a handful of W3C drafts. That was when it became clear: what I had thought I was doing was not, in fact, just a font problem.

5. Chinese Typography Was Never Just About Fonts

CLReq, formal name Requirements for Chinese Text Layout, is a document maintained by the W3C’s internationalization working group. It is neither a normative specification nor a browser implementation requirement — it is a requirements document, an inventory of what Chinese text layout needs the platform to do, intended to inform the specs that act on those needs. Its sister documents are JLReq (Japanese), KLReq (Korean), and ILReq (Indic). I opened CLReq one day intending to spend a few hours on it. I ended up putting in a meaningful chunk of that week and still didn’t finish it. The list of things Chinese typography has to do is long enough that CLReq has the body length of a mid-sized technical book.

None of what’s inside is exotic — punctuation kerning, line-break rules7, hanging punctuation, CJK/Latin spacing, quotation-mark localization, ruby8, emphasis marks, vertical writing (including tate-chu-yoko9 and switching between vertical and horizontal). The set I already knew, plus a set I hadn’t realized existed, laid out structurally in one place. Each item read on its own is not complex. Read together — and with their interactions accounted for — it is a book.

Many of these problems didn’t appear with the web. Chinese typography was already a craft of its own in the print era: page composition, type sizes, character spacing, leading, hanging punctuation, head-and-end break avoidance, variant and archaic glyph handling, mixed Simplified-Traditional setting, conversion between horizontal and vertical layouts — typesetters and designers have lived with these rules for a span far longer than HTML’s.

The print tradition also gave Chinese its standard type-family system: Songti, descended from Song-dynasty woodblock cutting, roughly the analogue of Latin serif; Heiti, modern and even-weighted, the analogue of sans-serif; Fangsong, a Song-derived imitation style often used for formal or official text; and Kaiti, descended from the regular-script calligraphic hand. Kaiti may read as paradoxical against Latin typographic intuition, where calligraphic styles default to decorative use. The duality isn’t exotic, though. The Latin reading tradition also has calligraphic ancestry: roman type draws on humanist book hands and inscriptional capitals, while italic comes from chancery script. In Chinese typography the calligraphic ancestries remain visibly active in the modern type stack, running in parallel. The web is just the most recent machine carrying all of this across.

A concrete measure: what CSS Text 4’s text-spacing-trim is implementing had already been standardized in Chinese metal typesetting back in the 1950s. Browsers started shipping it in 2024. From print specification to screen implementation, roughly seventy years. The gap between those two dates is its own essay.

Heti’s place in my mental model shifted as a result. I had defaulted to thinking of it as an optional “Chinese typography enhancement” tool — a layer that adds some punctuation kerning and some spacing. Once CLReq became visible, Heti’s actual value became clearer: it covers classical-Chinese setting, vertical writing, and other niche territory my personal blog will never need. Vertical writing is in fact the older mode — Chinese was inscribed top-to-bottom on bamboo slips long before hètí (paper) made horizontal layouts physically possible — though script inertia kept practice vertical for most of the centuries that followed. That part of Heti is the project’s accumulated, multi-year response to the Chinese typography tradition. I retired Heti and replaced it with a build-time pipeline of my own because my needs are narrow, not because Heti is unnecessary.

But even taking only the most common slice — in-line punctuation kerning, CJK/Latin spacing — the real problem in front of me wasn’t “can I implement this in CSS plus a build-time pipeline.” It was something more structural: how ready is the browser itself to help me do this work.

6. The Web Platform’s Debt to CJK

The answer is: not particularly well-prepared.

Recent W3C drafts — CSS Text 4, Inline 3, Ruby 1, and others — have been folding a batch of CJK typography rules into spec, and browser engines have been shipping their pieces. But implementation is fragmented. A few examples: text-spacing-trim (kerning of adjacent full-width punctuation) is Chromium-only; hanging-punctuation (letting line-edge punctuation hang into the margin) is WebKit-only; word-break: auto-phrase (breaking on phrase boundaries) is Chromium-only and only takes effect for lang="ja"; text-transform: full-width / full-size-kana (converting characters to full-width forms or rescaling small kana) is unimplemented in Chromium; ruby-overhang (letting ruby annotations extend past the base text) is Safari-only; text-emphasis-skip (controlling which characters take emphasis marks) is in none of the three.

It isn’t that any one engine is uniformly behind. Each leads in some places and lags in others, and the leadership doesn’t overlap — Chromium is ahead on punctuation kerning, WebKit on hanging punctuation and ruby, Firefox is missing several of the pieces that matter for this particular pipeline — but that isn’t the point. The point is that no one engine offers complete CJK typography.

Any author who wants consistent Chinese typography across all three engines has to write a polyfill or wrapper for whatever’s missing. There is no engine you can pick that gets you out of writing it — this isn’t “paying the cost of one engine’s lag,” it’s “paying the costs of three non-overlapping gaps at once.” The vision of handing CJK typography over to CSS and the browser is therefore structurally impossible at this point in time. CSS originated in a Latin-script web context; its early design assumptions did not bring CJK-scale, CJK-precision typography into the mainline. Rich-text editors are universally complicated for the same family of reasons; bidirectional (LTR/RTL) mixing is another slice of the same cause.

So in the new pipeline I did not pick between “fully custom” and “fully delegated to CSS standards.” I chose progressive enhancement: ship the wrapper and the CSS together. On engines without text-spacing-trim, the wrapper uses margin to enact the adjacent-punctuation kerning; on engines with it, CSS uses @supports (text-spacing-trim: normal) to zero out the wrapper’s margin, letting the native property take over. Browsers without support ignore the relevant CSS block automatically; supporting browsers get an equivalent, natively-executed version. The day all three engines fill in their implementations, the rehype plugin, the wrapper, and the @supports gate that bridged them all come out together; what stays is a single line of native CSS.

Chinese sites tend to score low on Lighthouse for several specific reasons that stack. CJK fonts themselves are one to two orders of magnitude heavier than Latin ones — even after weight and slice splitting, the generated font assets run into the megabytes, and a long page can still trigger a substantial subset of them; the post-processing CJK typography needs — wrappers, rehype plugins, polyfill JS — pushes both HTML size and DOM-node count up; combine that with the test environment’s lack of any CJK font, mentioned earlier, and a Chinese site is structurally much harder than an English one to score 4×100 on.

This really is an extension of Goodhart’s law — a single metric becoming a target already distorts the picture, and a metric system unfriendly to CJK amplifies that distortion. But I don’t intend to lay this at Lighthouse’s feet. On the contrary, its “unfriendliness” is precisely what makes it fair: Lighthouse doesn’t grant any language or script special treatment; it simply reflects, faithfully, the base assumptions the web platform makes about CJK. The problem is not the tool; it is somewhere further upstream.

Here’s a concrete example of what “upstream” looks like. HTML’s <em> tag is rendered as italic in browsers’ default stylesheets. But italic is a Western glyph form for emphasis; the traditional Chinese way to emphasize a sentence is emphasis marks or bolding, and slanting the characters isn’t a natural default. This CSS default is Western-first; CJK sites have to reset it to recover semantically correct emphasis. Defaults are not neutral; they carry the type-form habits of the script its authors wrote in.

On the surface this was font optimization for a Chinese blog. Looking back, the real difficulty wasn’t fonts; it was that the web platform’s support for CJK isn’t there yet. The rest — why do this, when is it enough, when do you stop — are questions I don’t have answers to.

Footnotes

  1. The Chinese text most readers see on screen comes from one of three places: Apple platforms ship PingFang (introduced with iOS 9 and OS X El Capitan in 2015), Simplified Chinese Windows ships YaHei (since Vista, 2007), and Android has shipped Noto Sans CJK as system fallback since Android 5 (2014), replacing the older DroidSansFallback. The three families share the same nominal coverage but differ in design lineage, hinting strategy, and how their glyphs land on the rendering pipelines beneath them — they are not interchangeable. ↩︎

  2. Source Han Sans covers tens of thousands of Han ideographs across the CJK regions, plus kana, hangul, and a full Latin set. Even after aggressive subsetting down to characters a single site actually uses, the resulting payload remains one to two orders of magnitude heavier than its Latin counterpart. ↩︎

  3. By “PostScript” I mean the PostScript name — the internal identifier the system uses for exact font matching, one level finer than the family name. Writing local() at this level avoids accidental matches against a same-named font of a different version. ↩︎

  4. Two CJK typographic concerns the essay returns to. Adjacent full-width punctuation kerning: full-width Chinese punctuation occupies an em-square with built-in blank area; when two punctuation marks land next to each other, the visible gap can double, and the convention is to compress it by a half- or quarter-em. CJK/Latin spacing: Chinese characters and Latin words sit on different metric grids; the boundary between them is unambiguous to the reader, but the convention’s thin gap — often around a quarter-em — gives the line the visual rhythm tradition calls for. ↩︎

  5. Critical CSS is the styling required for the first-screen render, which must be available during HTML parsing — typically inlined or delivered alongside the initial HTML to avoid blocking paint. ↩︎

  6. rehype is the HTML-AST toolchain in the unified ecosystem, commonly used as a post-processing layer in Markdown rendering pipelines; its companion HAST is the HTML Abstract Syntax Tree, representing HTML as a programmatically traversable tree. ↩︎

  7. One of the core Chinese typography rules, prohibiting certain punctuation marks or characters from appearing at the start or end of a line; often called “head-and-end avoidance” in Chinese contexts, with kinsoku as the analogous Japanese term. Browser support for this varies. ↩︎

  8. In typographic usage, ruby refers to the small annotation glyphs placed alongside Chinese characters to indicate pronunciation — equivalent to furigana in Japanese. Unrelated to the programming language of the same name. ↩︎

  9. A typographic move in vertical layouts: a short horizontal run of characters (numbers, Latin abbreviations) is set upright as a compact block within the vertical column. CSS expresses this through text-combine-upright. ↩︎