DOC# OGPNGS SLUG og_pngs_cf_workers PRINTED 2026-05-06 03:47 UTC

Five Commits to Get an OG Image Out of a Cloudflare Worker

A 24-minute slog where I got dynamic OG PNG generation to work on Cloudflare Pages Functions. The bug is WebAssembly. The fix is a build-time WASM import.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/og_pngs_cf_workers/
FILED
2026-02-15 17:14 UTC
REVISED
2026-02-15 17:30 UTC
TIME
6 min read
SERIES
Zera Origin Stories
TAGS
#cloudflare #workers #wasm #og-image #svg #solana #devops

The OG image is the thing that decides whether your link gets clicked on Twitter, Discord, or Telegram. If you ship a Solana DEX without per-token OG images, your share buttons are wallpaper. If you ship them as SVG, half the social platforms render them as blank cards because half the social platforms don’t render SVG.

So you ship them as PNG. Which means you generate them on the edge. Which means you call into WebAssembly from a Cloudflare Pages Function. Which means you bang your head against the wall five commits in a row.

This post is a real-time receipt of that head-banging from 2026-02-15 between 17:03 and 17:30 UTC.

The problem

The function in question lived at functions/og/default.ts — a Cloudflare Pages Function that takes a token mint, builds a stylized SVG card with live AMM stats, and converts it to a PNG with svg2png-wasm. The conversion is the hard part. Everything else is sed-replacing tokens into a template string.

The naive thing is what I shipped first in 1bac3bb — Convert OG images from SVG to PNG:

import { initialize, createSvg2png } from "svg2png-wasm";

const wasmRes = await fetch(WASM_URL);
const wasm = await wasmRes.arrayBuffer();
await initialize(wasm);

This works locally. This works on Vercel. This does not work on Cloudflare Workers.

Stage 1: dynamic import → static import (17:03)

81d3f16 — Fix OG PNG: use static import for svg2png-wasm instead of dynamic import.

CF’s bundler doesn’t bundle dynamic imports the same way it bundles static imports. Static import. Move on.

Stage 2: self-fetch → unpkg (17:06)

e2b0c76 — Fix OG PNG: fetch WASM from unpkg CDN instead of self-fetch.

I had been serving the .wasm file from app/public/ and fetching it via fetch(env.url + "/svg2png_wasm_bg.wasm"). CF Workers cannot fetch from themselves the way Node servers can — the request loops or 503s depending on the moon phase. I switched to unpkg’s CDN. That worked, but introduced a runtime dependency on a third party. We come back to that.

Stage 3: see the actual error (17:09)

102f485 — debug: show OG PNG error details instead of silent fallback.

Two hours into a deploy fight and you realize you’ve been catching the error and rendering the SVG fallback. Take the catch out. Suffer.

The error: WebAssembly.instantiate() of bytes from request body is not allowed in this Worker.

CF Workers block WebAssembly.instantiate() from raw bytes. Not deprecated. Not slow. Just blocked. They want you to use a build-time import so the WASM binary becomes a real module they can compile during deploy, not at runtime in your handler. This is a real security stance — they don’t want Worker code instantiating arbitrary blobs at runtime — but it’s not great when your library (svg2png-wasm) is built around a fetch-and-init pattern.

Stage 4: build-time WASM import (17:12)

962d55c — Fix OG PNG: use build-time WASM import for CF Workers compatibility.

This is the actual fix:

// @ts-ignore — CF Workers WASM import (compiled at build time)
import wasmModule from "./svg2png.wasm";

let svg2pngConverter: Svg2png | null = null;

async function ensureSvg2png(): Promise<Svg2png | null> {
  if (svg2pngConverter) return svg2pngConverter;
  if (!initPromise) {
    initPromise = (async () => {
      await initialize(wasmModule);
      svg2pngConverter = createSvg2png();
    })();
  }
  await initPromise;
  return svg2pngConverter;
}

You commit svg2png.wasm (~2MB) inside the Functions directory. CF picks it up at deploy time, treats it as a Worker-managed module, and binds the import to a real WebAssembly.Module. initialize(wasmModule) then takes a Module instead of bytes, which is the pre-compiled path that CF allows.

Stage 5: directory math (17:14)

9ccab18 — Fix WASM import path.

The per-token endpoint lives at functions/og/token/[mint].ts. The wasm I committed lives at functions/og/svg2png.wasm. The relative import was wrong. ../svg2png.wasm. Done.

Stage 6: fonts don’t ship with the bundle (17:30)

1c91af7 — Fix OG images: register Inter + JetBrains Mono fonts for svg2png-wasm.

Same idea. svg2png-wasm rasterizes text by looking up the font registered in its own runtime, not the host’s. The OG card uses Inter and JetBrains Mono. If you don’t registerFont(await loadFontBytes()) for both before calling the converter, your text rasterizes as □□□□. Hilarious in test environments. Catastrophic on a public DEX.

What this actually looked like deployed

The card is a 1200x630 SVG composed inline in TypeScript. The interesting part is the data fetch — I’m pulling live pool reserves from the cached market-data API I’d shipped one commit earlier in 5627d4d — Add edge-cached market data API, so the OG card always reflects the current price, capped to the cache TTL. That’s the entire reason this had to live on the edge: a static image generated at build time would show stale prices forever.

Trade-offs

Why not use @vercel/og? Because we’re on CF Pages, and Vercel’s OG library is bound to React + Satori in a way that’s genuinely hard to extract. svg2png-wasm is 4 dependencies and one WASM file. The cost of “just write the SVG yourself” turned out to be lower than I expected.

Why commit the wasm file to git? It’s 2MB. My repo is not a museum. I’d rather have a deterministic deploy that doesn’t depend on unpkg being up.

Why not pre-render on cron and serve static PNGs? Because there are 50+ tokens at any given moment, and pre-rendering all of them on a cron is busywork that wastes cycles 99.9% of the time. The right shape is “render on cache miss, serve from cache for 24h.” Which is what shipped.

What this taught me

Cloudflare’s WASM contract is real and you cannot work around it. The error message is clear once you stop swallowing it. The ecosystem of WASM libraries is mostly written assuming Node-style runtime fetch, so half of the porting work is going to be “convince this library to take a WebAssembly.Module instead of a BufferSource.” Some libraries refuse to accept that as a PR; in those cases you write a thin wrapper or you fork.

Five commits in 24 minutes is not a flex. It’s a confession that the only way I could solve this was to ship to production and let the runtime tell me what was wrong, because there is no other place that runs this stack the way Cloudflare does. CI didn’t catch it. Local wrangler pages dev didn’t catch it. Production caught it in 30 seconds.

Further reading

← Back to article