Rendering share cards on Cloudflare Workers with satori and resvg-wasm, and the 4 things that broke
Commit Archive is a yearbook for GitHub repos. Every archived project gets a share card, and every contributor gets a portrait card with their numbers and a superlative like "Most Likely to Commit After Midnight". That m
Commit Archive is a yearbook for GitHub repos. Every archived project gets a share card, and every contributor gets a portrait card with their numbers and a superlative like "Most Likely to Commit After Midnight". That means a lot of PNGs: a 1200x630 Open Graph image per project and a 1080x1350 card per contributor, generated on demand.
The whole app runs on one Cloudflare Worker (Next.js through OpenNext, D1, Queues, R2), so the cards had to render inside the Worker too. This is what that took, and the four things that broke along the way.
The stack: satori + resvg-wasm
The pipeline is two steps:
- satori turns a layout tree into SVG.
- @resvg/resvg-wasm turns the SVG into a PNG.
Card templates are plain { type, props } object trees rather than JSX, so the same renderer works from an API route and from a queue job without pulling React into the job path. Fonts are vendored TTFs (satori can't read woff2), and the same files serve the website, so cards and pages match.
On a warm isolate the numbers were comfortable:
| Render | Avg time | PNG size |
|---|---|---|
| OG 1200x630 | ~56 ms | ~38 KB |
| Portrait 1080x1350 | ~82 ms | ~42 KB |
| wasm init (once per isolate) | ~93 ms | n/a |
Rendered cards get cached in R2. Live cards use short cache headers, and once the year's edition is sealed they become immutable.
Break 1: "Wasm code generation disallowed by embedder"
Everything worked in Node. On workerd, WebAssembly.instantiate from raw bytes is refused. Workers want wasm imported as a module that's compiled at deploy time.
That had two knock-on effects:
-
satori is pinned to 0.15.x. Newer versions depend on
harfbuzzjs, which finds its own wasm file throughlocation.hrefat import time, and that can't work on Workers. 0.15.x uses thesatori/wasmentry plusyoga-wasm-web, the same patternworkers-oguses. -
/cards/*is served by a custom worker entry, not the Next.js route. ACompiledWasmrule inwrangler.jsoncimports the yoga and resvg modules there. That rule is load-bearing, so it's commented as such.
Lesson: test the renderer under wrangler dev, not only in Node.
Break 2: "Illegal invocation", but only in the queue
The GitHub client took an injectable fetch and stored it in a field:
this.fetchImpl = opts.fetchImpl ?? fetch;
// later
await this.fetchImpl(url);
Repo validation, which runs inside a Next.js request handler, worked fine. The first real generation job, which runs in the queue consumer, died with TypeError: Illegal invocation.
The difference: Next patches globalThis.fetch with a plain function, so calling it as a method is harmless. The queue consumer runs through the raw worker entry with the runtime's native fetch, which has to be called as a free function. The fix was one line:
this.fetchImpl = opts.fetchImpl ?? ((input, init) => fetch(input, init));
Lesson: code paths that share a client can still run on different globals.
Break 3: GitHub says "202, come back later"
Contributor line counts come from GET /repos/{owner}/{repo}/stats/contributors. The first time anyone asks about a repo, GitHub answers 202 Accepted with no body while it computes the stats. For my first test repo that lasted about 15 minutes, and the job used up all five retries before the data showed up.
Now the job publishes without line counts from the first retry on, and a later scheduled refresh fills them in. A slow stats endpoint no longer means a failed archive.
Break 4: another Worker ate the daily quota
For two days both environments kept returning Cloudflare error 1027 ("temporarily rate limited") a couple of hours after midnight UTC. Nothing was wrong with the app. A different Worker on the same free account was taking a few hundred thousand requests a day, and the Free plan's 100k requests per day are shared across the whole account.
Blocked requests never reach your Worker, so your own logs look quiet. The fix was moving that other Worker off its public route, and later moving the account to Workers Paid, which also raised the CPU limit that large ingestion jobs were hitting.
Lesson: when every Worker on an account fails at the same time of day, check account-level limits before your code.
What I'd do again
- Spike the scariest piece first. Card rendering was the riskiest part, so it was built and measured before the rest.
- Keep templates framework-free so any runtime path can render them.
- Write the gotchas down. Every one of these is now a line in the repo's known-limitations doc.
The result is live at commitarchive.lol. The Ghostty page, with 161 contributors, is a good one if you want to see the renderer working hard.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.