DEV Community

DeVoresyah ArEst
DeVoresyah ArEst

Posted on

6 Pitfalls of Dynamic OG Image Generation on Cloudflare Workers (Satori + resvg-wasm)

I've built a dynamic OG image generator for MachiCrate — a plugin marketplace for city-builder games. Each plugin gets a rich social preview card with its name, author, stats, and thumbnail, generated on-the-fly at the edge.

The stack:

  • Qwik City (SSR framework on Cloudflare Pages)
  • Satori — converts HTML/CSS to SVG
  • satori-html — converts HTML strings to VNodes that Satori understands
  • @resvg/resvg-wasm — converts SVG to PNG

Sounds simple. It wasn't. Here are the 6 issues we hit and how we solved them.


Issue 1: Cloudflare Workers Block Dynamic WASM Compilation

The Problem

resvg-wasm needs to initialize a WebAssembly module. The standard way is:

import { initWasm } from "@resvg/resvg-wasm";

// This loads the .wasm file and compiles it at runtime
await initWasm(fetch("path/to/index_bg.wasm"));
Enter fullscreen mode Exit fullscreen mode

Cloudflare Workers block dynamic WebAssembly compilation — calling WebAssembly.instantiate() with raw bytes throws an error. This is a security restriction on the Workers runtime.

The Solution

Static WASM imports in _worker.js are processed by wrangler's esbuild, which pre-compiles them into WebAssembly.Module objects that can be instantiated at runtime.

The trick: Vite's SSR build can't handle .wasm file imports either (Node.js throws ERR_UNKNOWN_FILE_EXTENSION). So we inject the import after the build via a post-build script:

// scripts/patch-worker-wasm.js
import { readFileSync, writeFileSync } from "node:fs";

const workerPath = "./dist/_worker.js";
const content = readFileSync(workerPath, "utf-8");

const patched = `import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
globalThis.__resvgWasm = resvgWasm;
${content}`;

writeFileSync(workerPath, patched);
Enter fullscreen mode Exit fullscreen mode

Then in the handler, we initialize from globalThis:

let wasmInitialized = false;

async function ensureWasmInitialized() {
  if (wasmInitialized) return;
  const wasmModule = (globalThis as Record<string, unknown>).__resvgWasm;
  if (!wasmModule) {
    throw new Error("resvg WASM module not found.");
  }
  await initWasm(wasmModule as WebAssembly.Module);
  wasmInitialized = true;
}
Enter fullscreen mode Exit fullscreen mode

The build script in package.json chains them:

{
  "build": "qwik build && node scripts/patch-worker-wasm.js"
}
Enter fullscreen mode Exit fullscreen mode

Issue 2: Satori Can't Fetch External Images on Workers

The Problem

Our plugin thumbnails are served from CloudFront (e.g., https://cdn.example.com/user/project/thumbnail.png). In a normal Node.js environment, Satori fetches these URLs automatically when it encounters <img src="..."> in the template.

On Cloudflare Workers, this silently fails. No error, no crash — the image just renders as a blank space. Satori's internal image fetching doesn't work in the Workers runtime.

The Solution

Fetch the image manually and embed it as a base64 data URL:

async function fetchImageAsDataUrl(url: string): Promise<string | null> {
  try {
    const res = await fetch(url, {
      headers: {
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
        Accept: "image/png,image/*,*/*",
      },
    });
    if (!res.ok) return null;

    const contentType = res.headers.get("content-type") || "image/png";
    const mimeType = contentType.split(";")[0].trim();
    const buf = await res.arrayBuffer();

    return `data:${mimeType};base64,${arrayBufferToBase64(buf)}`;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now instead of letting Satori fetch the image, we pre-fetch it and pass the base64 data URL directly. But this leads to the next issue...


Issue 3: satori-html Chokes on Large Data URLs

The Problem

A typical thumbnail encoded as base64 is ~400-500KB as a string. When you try to embed that directly in a satori-html tagged template literal:

const dataUrl = "data:image/png;base64,iVBORw0KGgo..."; // ~500KB string

const content = html`
  <div>
    <img src="${dataUrl}" width="324" height="324" />
  </div>
`;
Enter fullscreen mode Exit fullscreen mode

satori-html's template literal parser breaks. It can't handle strings this large in interpolated positions. No useful error message — it just produces a malformed VNode tree.

The Solution

Use a short placeholder string in the template, then walk the parsed VNode tree and replace it with the actual data URL after parsing:

const IMG_PLACEHOLDER = "__IMG__";

// Template uses the short placeholder
const content = html`
  <div>
    <img src="${IMG_PLACEHOLDER}" width="324" height="324" />
  </div>
`;

// After parsing, replace it in the VNode tree
function replaceImgSrc(node: any, placeholder: string, newSrc: string): void {
  if (!node || typeof node !== "object") return;
  if (node.type === "img" && node.props?.src === placeholder) {
    node.props.src = newSrc;
  }
  const children = node.props?.children;
  if (Array.isArray(children)) {
    for (const child of children) replaceImgSrc(child, placeholder, newSrc);
  } else if (children && typeof children === "object") {
    replaceImgSrc(children, placeholder, newSrc);
  }
}

// Usage
replaceImgSrc(content, IMG_PLACEHOLDER, actualBase64DataUrl);
Enter fullscreen mode Exit fullscreen mode

This bypasses the template parser entirely for the large string. Satori itself handles the data URL fine — it's only satori-html's parser that can't.


Issue 4: CloudFront Returns 403 for Server-Side Fetches

The Problem

Our thumbnails are served via CloudFront CDN. When fetching from a browser, everything works. But when fetching from a Cloudflare Worker:

Fetch FAILED: 403 Forbidden
Enter fullscreen mode Exit fullscreen mode

CloudFront blocks requests that don't include browser-like headers. A bare fetch(url) from a Worker sends minimal headers, and CloudFront treats it as suspicious.

This was the most confusing issue because:

  • The URL works fine when you open it in a browser
  • The URL works with curl (which sends a User-Agent)
  • It only fails from server-side fetch() with no headers

The Solution

Include browser-like headers when fetching from CloudFront:

const res = await fetch(url, {
  headers: {
    "User-Agent":
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    Accept: "image/png,image/*,*/*",
  },
});
Enter fullscreen mode Exit fullscreen mode

That's it. Adding User-Agent and Accept headers was enough to get a 200 OK from CloudFront.


Issue 5: Satori Doesn't Support WebP

The Problem

Our thumbnails were originally stored as WebP files. After solving the CloudFront 403 issue, we tried fetching the WebP version. The fetch succeeded (200 OK), but Satori crashed with a cryptic error:

Error: l is not iterable
Enter fullscreen mode Exit fullscreen mode

No mention of "unsupported format" or "WebP" anywhere. Just a minified variable name in an error that tells you nothing.

Root cause: Satori's built-in image decoder supports PNG and JPEG but not WebP. When it encounters WebP image data, it fails with this unhelpful error deep in its internals.

The Solution

We stored both formats on upload (WebP for UI display, PNG for OG/social sharing), so the fix was simple — use the PNG variant:

if (plugin.thumbnailUrl) {
  // Use PNG variant — Satori doesn't support WebP decoding
  const pngUrl = plugin.thumbnailUrl.replace("thumbnail.webp", "thumbnail.png");
  const dataUrl = await fetchImageAsDataUrl(pngUrl);
  if (dataUrl) thumbnailDataUrl = dataUrl;
}
Enter fullscreen mode Exit fullscreen mode

Lesson: If you're building an image processing pipeline, always keep a PNG copy for compatibility. WebP is great for browsers but not all server-side tools support it.


Issue 6: node:buffer Unavailable in Vite SSR Build

The Problem

To convert an ArrayBuffer to a base64 string, the natural approach in Node.js is:

import { Buffer } from "node:buffer";

const base64 = Buffer.from(arrayBuffer).toString("base64");
Enter fullscreen mode Exit fullscreen mode

But Vite's SSR build (which compiles the code before it runs on Workers) can't bundle Node.js built-in modules:

Error: Cannot bundle Node.js built-in "node:buffer"
Enter fullscreen mode Exit fullscreen mode

Cloudflare Workers do support Buffer at runtime, but Vite doesn't know that and refuses to build.

The Solution

Use Web APIs that work everywhere — chunked String.fromCharCode + btoa:

function arrayBufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = "";
  // Process in 8KB chunks to avoid call stack overflow
  for (let i = 0; i < bytes.length; i += 8192) {
    const chunk = bytes.subarray(i, Math.min(i + 8192, bytes.length));
    for (let j = 0; j < chunk.length; j++) {
      binary += String.fromCharCode(chunk[j]);
    }
  }
  return btoa(binary);
}
Enter fullscreen mode Exit fullscreen mode

Why chunked? Because String.fromCharCode(...bytes) with spread on a large array (hundreds of thousands of bytes) will blow the call stack. Processing in 8KB chunks keeps it safe.


Key Takeaways

  1. Cloudflare Workers have WASM restrictions. You can't dynamically compile WASM — use static imports and let wrangler pre-compile them. Post-build scripts are your friend when your build tool can't handle .wasm imports directly.

  2. Satori's image fetching silently fails on Workers. Always fetch images manually and convert to base64 data URLs. Don't rely on Satori's internal fetch.

  3. satori-html has template size limits. Large interpolated strings break the parser. Use placeholder strings and patch the VNode tree after parsing.

  4. CDNs block bare server-side fetches. CloudFront (and likely others) return 403 when requests lack browser-like headers. Always include User-Agent when fetching from server-side code.

  5. Satori only supports PNG and JPEG. WebP will crash with an unhelpful error message. Keep a PNG variant of your images for server-side rendering.

  6. Avoid Node.js built-ins when targeting Workers via Vite. Use Web APIs (btoa, TextEncoder, crypto) instead of Buffer, crypto from node:.

Building OG images on the edge is worth it — sub-100ms generation times, no separate image service to maintain, and automatic global distribution. But the Workers runtime has sharp edges (pun intended) that aren't always well-documented. Hopefully this saves you a few hours of debugging.


Built with Qwik City + Cloudflare Pages. The full OG generator runs at the edge, generating dynamic social cards for every plugin in the MachiCrate store.

Top comments (0)