Open Graph images look like a solved problem until you need one per page, generated from live data, on Cloudflare Workers. That was me with eventcompletion.com.
Every event page has its own social card: the event name, a progress bar, the number of days elapsed and left, and a status (upcoming, live, completed). The card changes as the event moves forward, so a static image is out.
I got there in three attempts. The first two are in my git history as proof that they didn’t survive. This post covers all three, because the dead ends explain why the final setup looks the way it does.
What the card has to do
- Show the progress of the event, from 0 to 100%.
- Show its status and the days elapsed, left and total.
- Use the event’s own cover image when it has one.
- Come in English and French.
- Stay fresh while an event is running, without being regenerated on every crawler hit.
- Never break: a share link with a broken preview is worse than a generic one.
Attempt 1: @vercel/og and native resvg
@vercel/og is the obvious first stop. It didn’t work out of the box in my Astro-on-Workers build. I dropped it for plain satori plus @resvg/resvg-js to turn the SVG into a PNG.
That got me further, but resvg-js is a native Node addon. It needs a native binary, and Workers can’t load one. I was already marking it as external in the Vite config just to get the build to pass, which is a sign that the build and the runtime disagree.
Attempt 2: a real browser, plus R2
Next I did the thing that is guaranteed to render correctly: run an actual browser. Cloudflare’s Browser Rendering gives you Puppeteer as a binding, so the plan was:
- Render an HTML/CSS template in a headless browser.
- Screenshot it at 1200×630.
- Store the PNG in R2 and serve it from there.
- Pre-generate images for active events from an endpoint meant to be hit by a cron, and fall back to generating on demand.
It rendered beautifully, since it is just HTML and CSS in a real browser. It also meant a browser binding, a bucket, a secret-protected generation endpoint and a schedule to keep alive, all for a 1200×630 picture. I was maintaining a small pipeline instead of a template.
Attempt 3: Satori and resvg-wasm, inside the Worker
The pieces were right the first time; I had just loaded them the wrong way. Satori turns a tree of flexbox elements into SVG, and resvg has a WebAssembly build that runs anywhere. The trick on Workers is to import the .wasm files as modules and initialise everything once per isolate.
import satori, { init as initSatori } from 'satori/standalone';
import { initWasm as initResvg, Resvg } from '@resvg/resvg-wasm';
import yogaWasm from 'satori/yoga.wasm?module';
import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm?module';
let wasmReady: Promise<unknown> | undefined;
export async function renderPng(tree, assets, origin) {
wasmReady ||= Promise.all([initSatori(yogaWasm), initResvg(resvgWasm)])
.catch((error) => { wasmReady = undefined; throw error; });
const [fonts] = await Promise.all([loadFonts(assets, origin), wasmReady]);
const svg = await satori(tree, { width: 1200, height: 630, fonts });
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
const rendered = resvg.render();
const png = rendered.asPng();
rendered.free();
resvg.free();
return png;
}
A few details that matter:
satori/standaloneis the build that lets you hand it a pre-compiledyoga.wasminstead of fetching one.?moduleis how the Cloudflare adapter exposes a.wasmfile as aWebAssembly.Module. I also had to tell Vite to treat.wasmand font files as assets.- Initialise once. The promise is stored at module level, so only the first request in an isolate pays for it. If init fails, it is cleared so the next request retries instead of caching the error.
- Free the WASM memory. Call
free()on the render and on theResvginstance, or a long-lived isolate slowly leaks. - Fonts are static files (Geist, three weights) served from the Worker’s assets, fetched once and kept in memory.
The card itself is a plain JavaScript element tree. Satori needs display: flex on anything with several children, so a tiny helper adds it everywhere:
function h(style, ...children) {
const kids = children.filter((c) => c !== null && c !== false && c !== undefined);
return { type: 'div', props: { style: { display: 'flex', ...style }, children: kids.length === 1 ? kids[0] : kids } };
}
No JSX and no HTML template: the layout is code, next to the logic that decides what to show.
Loading the cover image safely
Satori can fetch images itself, but a slow third-party image shouldn’t hold a render hostage. So I fetch it first, with limits, and pass it in as a data URI:
const res = await fetch(url, { signal: AbortSignal.timeout(2500), cf: { cacheTtl: 86400 } });
const type = res.headers.get('content-type')?.split(';')[0] ?? '';
if (!res.ok || !/^image\/(png|jpe?g|webp|gif)$/.test(type)) return null;
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > 4_000_000) return null;
A timeout of 2.5 seconds, an allow-list of content types and a 4 MB cap. If anything goes wrong it returns null and the card falls back to a panel with the event’s initial. The card is slightly less pretty, and it still renders.
Keeping it fresh without re-rendering constantly
This is the part I would have gotten wrong with a cron. Instead of scheduling work, I let the URL carry the freshness. A small function computes a version token from the event state:
export function getOGVersion(startDate: string, endDate: string, now = Date.now()): string {
const { status } = getEventState(startDate, endDate, now);
if (status === 'completed') return 'done';
if (status === 'upcoming') return `u${Math.floor(now / DAY)}`;
return `o${Math.floor(now / 900_000)}`; // 15-minute buckets while live
}
- A live event gets a new token every 15 minutes.
- An upcoming event gets a new one every day.
- A completed event gets
done, forever, and its image is cached for a year.
The event page puts that token in its og:image URL. Anyone scraping the page, whether Slack, X or LinkedIn, sees a different URL exactly when the picture should be different, so platform caches refresh without me doing anything.
The endpoint uses the same token as its cache key, with the Cache API:
const version = getOGVersion(event.start_date, event.end_date);
// The cache key ignores client-supplied params: only the server-computed version counts.
const cacheKey = new Request(`${url.origin}/api/og/${slug}?v=${version}&lang=${lang}&r=${event.image ? 'i' : 'n'}`);
const cached = await caches.default.match(cacheKey);
if (cached) return cached;
Building the key on the server matters: if the cache key included whatever ?v= a client sent, anyone could fill the cache with arbitrary variants of your image. The TTL follows the state too: 15 minutes for live, an hour for upcoming, a year for completed.
If rendering throws for any reason, the endpoint answers with a redirect to a static fallback image. Social platforms still get a valid picture.
What changed compared with the Puppeteer version
| Browser Rendering + R2 | Satori + resvg-wasm | |
|---|---|---|
| Moving parts | Browser binding, R2 bucket, generation endpoint, schedule, secret | One endpoint |
| Template | HTML/CSS | JS element tree |
| Freshness | Pre-generated on a schedule | Versioned URL, rendered on first request |
| CSS support | Everything a browser does | Satori’s flexbox subset |
| If rendering fails | Depends on the job | Redirect to a static image |
The trade-off is real. Satori supports a subset of CSS (flexbox, gradients, borders, shadows, but no grid or arbitrary selectors), so the design has to be built with that in mind. For a social card with a heading, a bar and a few numbers, that is plenty, and I get a simpler system in exchange.
What I would tell my past self
- A failure in one setup doesn’t condemn the library. Satori and resvg were fine. What I needed was the WASM builds, loaded explicitly, and the right way to import them on Workers.
- Let the URL carry freshness. A version token derived from the data beats a scheduler: nothing to run, nothing to forget.
- Bound everything that touches the network (timeouts, size limits, content types), then decide up front what the fallback looks like.
- Always have a static fallback. A preview that is slightly generic is better than a broken one.
The language support is, for now, English and French; the copy lives in a small object keyed by language, so adding another one is a matter of adding its strings.
Want to see the result? Open any event on eventcompletion.com and paste its link into a chat or a post. Questions or a better way to do this? I’m on X at @_stanleon.
