npm i -D unicode-range-split

Ship the font twice. Fetch it once.

A CJK face is megabytes because it covers everything. Cut it down to the characters your site uses and the day someone writes a rare kanji, it falls out of your typeface. This splits the font in two instead, and drops nothing.

この見出しは、分割した書体で描かれています。

the whole font — 16,732 characters5.5 MiB
common tier — 1,193 characters, every page125 KiB
rare tier — 15,539 characters, on demand2.0 MiB

This page is set in the two files above, produced by this package from Noto Sans JP. Nothing here is a mock-up: the figures come from the build.

Watch the second file arrive

The text below is rendered in the split font. Green characters are in the file your browser already has. Type one that is not, and the browser fetches the other file — the readout is its own timing.

Type something

common tier 125 KiB — fetched when this page loaded.

rare tier 2.0 MiB — not fetched. Every character above is in the file you already have.

Point it at your font and your text

It reads the files you name, keeps every character they use in the common tier, and puts everything else behind a unicode-range. Run it whenever your writing changes; the site is not broken if you forget, it only fetches the second file more often.

// unicode-range-split.config.js
export default {
  fonts: [
    {
      source: "src/fonts/NotoSansJP-400.ttf",
      family: "Noto Sans JP",
      outDir: "public/fonts",
      cssPath: "src/app/noto.css",
      // every file whose characters must be in the small tier
      scan: ["src", "content", "messages"],
    },
  ],
};

npx unicode-range-split

import { splitFont } from "unicode-range-split";

const { css, tiers } = await splitFont({
  source: "src/fonts/NotoSansJP-400.ttf",
  family: "Noto Sans JP",
  outDir: "public/fonts",
  scan: ["src", "content"],
});

tiers.common.url; // "/fonts/NotoSansJP-400-common.1f4a9c2b.woff2"
tiers.rest.url;   // fetched only when a page needs one of its glyphs

Why the order of the two rules matters

The common tier is declared first with no range, so it claims every character. The rare tier follows with an explicit range, and the later rule wins for the characters it names. Swap them and the second file is never fetched.

@font-face {
  font-family: "Noto Sans JP";
  src: url("/fonts/noto-common.woff2") format("woff2");
}

@font-face {
  font-family: "Noto Sans JP";
  src: url("/fonts/noto-rest.woff2") format("woff2");
  unicode-range: U+4E18-4E19,U+4E32,U+4E39, /* …and the rest */;
}