Compression research · chss.chat
How slow can one chess preview image be?
You make a move and send the board to a friend. The link has to show a picture in iMessage or WhatsApp. That picture is not the page — it is a separate PNG the crawler fetches from /og/b-….png. How long can that take?
The obvious answer is “render a board.” That gets us surprisingly far. Except every unique position is a unique URL, so the first share of a position is almost always a cold miss. Share itself must stay instant — we never wait on the image before opening the system sheet.
Two different clocks
Origin render cost is how long it takes to produce the PNG when nothing is cached. That number is measurable on a laptop. Crawler TTFB is what the chat app actually waits for — CDN, cold start, and cacheReason — and that only shows up in production logs.
The first move was never prerendered
Our lookup book only stored even plies. After e2e4, it is Black to move — an odd ply. Baseline coverage of every legal first move was 0%. The second ply was already 100%. The gap was structural, not frequency.
Prediction before the fix: ply-1 coverage goes to 100%. After snapshotting intermediate plies along popular lines and adding all twenty legal first moves, the table grew from 151 to 314 codes and ply-1 hit 100%.
Before you look at the chart
Local Satori (next/og) sat around 55.7 ms p50. Production still felt like a second because cold Lambdas and CDN fill dominate. If we only swap the rasteriser, how much of the origin cost should disappear — 2×? 4×?
| Stage | Engine | p50 | p95 | PNG p50 |
|---|---|---|---|---|
| Baseline | next/og (Satori) | 55.7 ms | 80.7 ms | 24,854 B |
| After IT-02+03 | takumi-js | 7.0 ms | 8.1 ms | 15,786 B |
Takumi cut origin p50 by about 8× and the PNGs got smaller. Flattening the template (one board background plus positioned pieces) added another ~11%. The prediction of “bytes within ±10%” was wrong — they shrank ~37%, which is fine.
Could the phone do the work?
Takumi ships a WASM build. On the same machine, WASM render p50 was ~9 ms after a 17 ms init — competitive with native. The binary is 3.7 MB. Paying that over the network so the origin can skip a 7 ms render is the wrong trade. We measured it, wrote it down, and left origin rendering where it is.
What we tried, in order
- Prerender odd plies + all legal ply-1 — predicted: ply-1 coverage → 100%
Result: ply_1_pct=100, total_codes=314 (176w/138b). Verdict: kept. - Satori → Takumi — predicted: p50 2–4× lower; bytes ±10%
Result: p50 55.73→7.80 (~7.1×); bytes 24854→15786 (~63%). Verdict: kept. - Flatten OG template — predicted: measurable p50 drop vs Takumi alone
Result: p50 7.80→6.97 (~11%). Verdict: kept. - Browser WASM prewarm feasibility — predicted: WASM load+render <200ms; size acceptable
Result: wasm p50≈9ms but binary 3.7MB — wrong trade vs 7ms origin. Verdict: not productized. - after() + in-flight dedupe — predicted: action returns immediately; prod HIT after move
Result: shipped; prod HIT confirmation is PV-2. Verdict: shipped locally.
What production showed
After deploy we probed the same fixtures with crawler user-agents. The latency goal held: Twitterbot ply-1 landed around 173 ms (was ~1.1 s), and cold midgame misses sat near 160-220 ms. WhatsApp and curl then hit the CDN in ~24 ms.
The PRERENDER prediction was wrong for the interesting clients. Twitterbot and Facebook get BYPASS with cacheReason=crawler — they do not keep a CDN HIT the way WhatsApp does. Concurrent probes also collapse into BYPASS. So the win is mostly a faster origin (Takumi), not a free static edge hit for every crawler.
node benchmark/og/prod-probe.mjs --tag it01-03 vercel logs chss.chat --since 10m --json > /tmp/og.jsonl node benchmark/og/parse-vercel-logs.mjs /tmp/og.jsonl --tag it01-03
Reproduce locally from benchmark/og/README.md. Ledger: benchmark/og/LEDGER.md. Constraint throughout: Share is never gated on prewarm.