# Why my Chinese OG images turned to tofu: satori, same-name fonts and download order

> CJK subsets shared a name, weight and style; satori 0.26.0 selected the first to settle. Local font slices with distinct names fixed the tofu. A reproduction is included.

Source: https://sheng.page/en/posts/og-image-tofu-and-satori-font-race/ · Published: 2026-11-13 · Author: Sheng

In early October the Chinese Open Graph cards on this blog broke twice. Every Chinese character became a blank box (tofu, in type-designer slang); the English cards were fine. I first blamed a failed Google Fonts download, added retries and made the build fail if the download still failed. The next day the cards broke again, though every download had succeeded.

The cause was a naming mistake. satori requests fonts for language segments with missing glyphs, and I registered every CJK subset as `Noto Serif TC`. For subsets with the same name, weight and style, satori 0.26.0 selects the first to settle, as both the reproduction and the source confirm. The full-width colon gets its own segment. I suspect its small subset usually arrived first, leaving the Chinese text without a usable font. I switched to local font slices with distinct names and made the build fail on missing glyphs. A minimal reproduction follows.

## How the card was built

This static Astro site is deployed with Cloudflare Workers Builds. Each OG card is a 1200x630 PNG: satori converts a node tree to SVG, which resvg rasterises. The code is in `src/lib/og.ts`, the route in `src/pages/og/[lang]/[slug].png.ts`. Cards carry the "Just a void" wordmark in Great Vibes, a four-character Chinese subtitle (學海無涯, roughly "learning has no shore"), a tag box, the post title and a date with the Chinese suffixes 年, 月 and 日.

Latin fonts came from @fontsource as WOFF files. A full CJK font was too large for one image, so the old build called the Google Fonts CSS2 API with `text=` to get a font file optimised for the request. Google's documentation says this can cut file size by up to 90% in some cases. satori 0.26.0 does not accept WOFF2, so, to get WOFF back, the request pretended to be Chrome 30 in its User-Agent.

satori takes TTF, OTF and WOFF fonts as `{ name, data, weight, style }` objects. It calls `loadAdditionalAsset(code, segment)` when text has no font: `code` is the detected language code (`'emoji'` for emoji), and `segment` is the text to draw. The old `loadAdditionalAsset` passed `segment` to `loadCJK(segment)` and returned `[{ name: 'Noto Serif TC', data, ... }]`. `loadCJK` always appended `：，。、；！？「」『』（）【】《》〈〉—…·`, giving every subset the common full-width marks.

Every call returned the same family name. The README does not explain how it handles two fonts that share a family name.

## Two fixes in two days

On 2 October I published [The Same Payment Sent Twice](/en/posts/idempotency-and-retries/). Its Chinese OG card was all tofu, including the subtitle and date; the English card was fine. I had already shared the Chinese post on Threads, where the preview showed the broken image.

The broken PNG was 33.5 KB, against roughly 52 KB for a good one. I blamed a failed Google Fonts request during the Cloudflare build: `loadCJK` had swallowed the error and returned null, so satori drew tofu while the build passed. The project notes even called this an occasional glitch fixed by rebuilding. That evening, commit 040df5c added up to three attempts per font request, with 0.5 s and 1 s between them, checked `res.ok`, and made production builds throw when the font still could not be fetched, so a deploy would fail and, as the commit message put it, the previous one would stay live. Development and tests kept the tofu fallback.

The next day, 28 of the 44 Chinese cards were tofu; some surviving cards also had colons in their titles. Every font download had succeeded, so neither the retry nor the throw fired. That evening's f412d80 commit message blamed segments: satori calls `loadAdditionalAsset` once per locale segment, a full-width `：` or `，` on these cards gets its own segment, and every subset was registered as `Noto Serif TC`, so "a punctuation-only subset shadowed the real one". It does not say which arrived first. satori only calls the function for segments the registered fonts cannot cover; with no CJK font registered up front, that meant every CJK segment. The fix loaded one subset covering the whole card and returned it for every segment, then parsed it with @shuding/opentype.js (satori's own parser) and threw for any CJK character without a glyph. The check ran in every environment without retries.

I recorded that failed downloads had not caused these tofu cards and that rebuilding would not reliably fix them: it might change which download finished first. After touching OG cards or fonts, I now check a Chinese card with a `：` in its title before publishing.

## Rebuilding the old commit

For this post I extracted 040df5c with `git archive`, then ran `npm ci` and `astro build`. It generated all 44 Chinese cards and exited 0; the network was healthy and every font request succeeded. Three cards showed the difference:

- idempotency-and-retries: every CJK character is tofu, but the full-width colon is drawn. The file is 33,536 bytes.
- jaeger-distributed-tracing-with-otel, whose Chinese title has a full-width colon: all tofu apart from the colon.
- postgres-scheduled-sql-pitfalls, whose Chinese title has no full-width punctuation: correct.

![Chinese OG card for the idempotency post rebuilt from 040df5c: Just a void renders, but all Chinese text in the subtitle, title and date is tofu; the full-width colon renders](/images/og-image-tofu-and-satori-font-race/old-card-040df5c.png)

In a scratch copy I added `console.log` to print each segment satori passed to `loadAdditionalAsset`:

```text
OGSEG "同一筆付款送了兩次：從 " "學海無涯同一筆付款送了兩次從的聖誕節講冪等與重年月日"
OGSEG "同一筆付款送了兩次：從 " "："
OGSEG "PostgreSQL 排" "學海無涯排程的三個坑年月日"
OGSEG "Jaeger 入門：用 " "學海無涯入門用串起分散式追蹤年月日"
OGSEG "Jaeger 入門：用 " "："
```

The first quoted string is the start of the title; the second is the segment satori requested. In this log, satori merges each card's CJK text into one segment and requests the full-width colon separately. The title without full-width punctuation produces one request, matching the three cards above.

## Minimal reproduction: which subset settles first

Segment counts alone did not explain it. A separate script rebuilding the layout as satori nodes, still fetching from Google Fonts, rendered the idempotency card correctly. Making the colon request return nothing produced a PNG byte-identical to the good one. An extra segment alone does not break a card.

Timing remained. `race.mjs` registers two @fontsource/noto-serif-tc slices as `Noto Serif TC`: slice 120 contains 幾布落 (three ordinary Chinese characters), and slice 123 contains the full-width colon. satori splits these four characters into two segments. The script renders twice; on the second run, `loadAdditionalAsset` sleeps for 50 ms for the Chinese segment, simulating a larger subset taking longer to download and letting the colon's promise settle first. Nothing else changes.

```js
// Two segments, two subsets, one family name. The only difference between runs is
// which loadAdditionalAsset promise settles first.
import { readFileSync, writeFileSync } from 'node:fs';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

const slice = (n) => readFileSync(`node_modules/@fontsource/noto-serif-tc/files/noto-serif-tc-${n}-400-normal.woff`);
const latin = readFileSync('node_modules/@fontsource/noto-serif/files/noto-serif-latin-400-normal.woff');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const text = '幾布落：';            // 幾布落 live in slice 120, the full-width colon in slice 123

for (const slowFirst of [false, true]) {
  const order = [];
  const svg = await satori(
    { type: 'div', props: { style: { display: 'flex', fontSize: 64, background: '#fff', padding: 40 }, children: text } },
    {
      width: 600,
      height: 160,
      fonts: [{ name: 'Noto Serif', data: latin, weight: 400, style: 'normal' }],
      loadAdditionalAsset: async (code, segment) => {
        const isColon = segment === '：';
        if (slowFirst && !isColon) await sleep(50);   // the big subset takes longer to download
        order.push(segment);
        return [{ name: 'Noto Serif TC', data: slice(isColon ? 123 : 120), weight: 400, style: 'normal' }];
      },
    },
  );
  const file = `race-${slowFirst ? 'colon-first' : 'han-first'}.png`;
  writeFileSync(file, new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng());
  console.log(`${file}: settled in order ${order.map((s) => JSON.stringify(s)).join(' then ')}`);
}
```

Install with `npm i satori@0.26.0 @resvg/resvg-js@2.6.2 @fontsource/noto-serif-tc@5.3.0 @fontsource/noto-serif@5.3.0` in a directory with its own `package.json`, then run `node race.mjs`. Node 26.13.0 on a Mac and Node 22.23.4 in a Linux container produced identical output and byte-identical PNGs:

```text
race-han-first.png: settled in order "幾布落" then "："
race-colon-first.png: settled in order "：" then "幾布落"
```

![race-han-first.png: the three Chinese characters render; the full-width colon is tofu](/images/og-image-tofu-and-satori-font-race/race-han-first.png)

![race-colon-first.png: the three Chinese characters are tofu; the full-width colon renders](/images/og-image-tofu-and-satori-font-race/race-colon-first.png)

When the Chinese subset settles first, the characters render and the colon is tofu; when the colon's subset settles first, the reverse happens. Both subsets have the same weight and style, and neither specifies a language. Under that family name, satori 0.26.0 selects the first to settle. It stores the later subset but never uses it to fill missing glyphs.

The source explains what the README omits. In `src/satori.ts`, each `loadAdditionalAsset` result enters a shared array in its own `.then`; `addFonts` receives that array after `Promise.all` finishes, in settlement order. In `src/font.ts`, lookup starts from the first font in the same-name list and switches only for a closer weight or style match. Both slices are 400 normal, so the first to settle wins. No comment says whether this is deliberate. The f412d80 commit message says only that the punctuation subset shadowed the Chinese one; the reproduction and the source establish which was selected.

My guess is that the old build's colon subset, containing only the fixed punctuation, was small enough that it usually arrived first. Cards with full-width punctuation then usually broke, and they survived when the timing reversed. That would explain the 28 broken cards out of 44 and the surviving cards with colons. I cannot replay the 3 October build's timing, so this remains a guess.

## What the build does now

f412d80 still fetched fonts during the build. Commit 1b7f522 removed the fetch later that evening; `og.ts` still works this way.

Noto Serif TC now uses the WOFF files in @fontsource/noto-serif-tc, split by unicode-range into a little over a hundred slices and locked by `package-lock.json`. Each slice has a distinct name, `Noto Serif TC 0`, `Noto Serif TC 1` and so on; satori falls back along the list for missing glyphs. AGENTS.md summarises the limitation for matching weights and styles (in Chinese): satori keeps one font per family name and treats other names as fallbacks, so each slice needs a different name. Great Vibes comes last, giving the Latin and CJK fonts priority for missing glyphs.

`loadAdditionalAsset` now returns an empty string for emoji and throws `OG card text has no glyph in the bundled fonts` otherwise, failing the build before it writes a tofu card. Two tests check that a Korean title, 한국어 제목, throws and a Chinese card renders with `fetch` stubbed to reject, without calling `fetch`. The comment in `og.ts` reads: "The build never fetches a font, so the same commit always renders the same cards and a network blip cannot produce tofu."

The same day, commit f777174 disabled resvg's system font scan: satori has already converted text to paths, leaving resvg with no fonts to find. This saved about 0.4 s per card, cutting the build from 63 s to between 28 and 34 s.

## Checks worth keeping

These checks went into the repo's rules and apply to CJK cards drawn with satori. Watch PNG size: in this incident, the tofu cards compressed to a much smaller size, so a drop from 52 KB to 33 KB is a useful clue. Check font bytes as well as HTTP status: a successful fetch can return a subset without the glyphs you need. After fixing a card, check whether Threads, Facebook and similar platforms still show the old preview. Meta's Sharing Debugger can request a re-scrape for Facebook; other platforms need their own supported process.

## Notes

- The old build fetched CJK subsets from Google Fonts, all registered as `Noto Serif TC`.
- In this setup, satori calls `loadAdditionalAsset` for language segments with missing glyphs. The full-width colon gets its own segment, so cards with `：` loaded two subsets.
- For subsets sharing a name, weight and style, satori 0.26.0 selects the first to settle; I suspect the small colon subset usually won.
- Every download succeeded, so the retry and throw added on 2 October never fired. The next day, 28 of 44 cards were still broken.
- The build now uses local @fontsource slices with distinct names and fails on missing glyphs.
- After touching fonts, check a Chinese card with a colon and its file size. After a fix, check whether social previews need a re-scrape.

## Further reading

- Vercel, [satori README](https://github.com/vercel/satori): the `fonts` option and `loadAdditionalAsset`. The 0.26.0 README lists TTF, OTF and WOFF, excluding WOFF2; the current GitHub README includes WOFF2. Neither explains how fonts sharing a name, weight and style are resolved.
- Vercel, [satori 0.26.0 src/font.ts](https://github.com/vercel/satori/blob/0.26.0/src/font.ts) and [src/satori.ts](https://github.com/vercel/satori/blob/0.26.0/src/satori.ts): storage and selection of same-name fonts, and collection of `loadAdditionalAsset` results.
- Google Fonts, [CSS2 API](https://developers.google.com/fonts/docs/css2): the `text=` parameter used to fetch subsets.
- Wikipedia, [Noto fonts](https://en.wikipedia.org/wiki/Noto_fonts): the name comes from "no tofu".
- Wikipedia, [Race condition](https://en.wikipedia.org/wiki/Race_condition): behaviour that depends on the timing or order of events outside the program's control, as with the font loading here.
- [The Open Graph protocol](https://ogp.me): `og:image` is one of the four required properties and identifies the image representing the page.

> Ideas and technical judgement by Sheng; drafted with Claude · Drafted by Claude Opus, coordinated by Claude Fable, research and tone passes by Grok (grok-4.7), review and tightening by Codex (gpt-6-astra), second review by Claude Opus 5.5; examples run on satori 0.26.0, @resvg/resvg-js 2.6.2, @fontsource/noto-serif-tc 5.3.0, Node 26.13.0 and Node 22.23.4.