6 Pitfalls of Dynamic OG Image Generation on Cloudflare…
    Neura MarketNeura Market/DeepSeek
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    DeepSeekBlog6 Pitfalls of Dynamic OG Image Generation on Cloudflare Workers (Satori + resvg-wasm)
    Back to Blog
    6 Pitfalls of Dynamic OG Image Generation on Cloudflare Workers (Satori + resvg-wasm)
    cloudflare

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

    DeVoresyah ArEst February 15, 2026
    0 views

    A deep-dive into the real issues we hit generating dynamic Open Graph images on Cloudflare Workers with Satori and resvg-wasm — and how we solved each one.


    title: "6 Pitfalls of Dynamic OG Image Generation on Cloudflare Workers (Satori + resvg-wasm)" published: true description: "A deep-dive into the real issues we hit generating dynamic Open Graph images on Cloudflare Workers with Satori and resvg-wasm — and how we solved each one." tags: cloudflare, webdev, javascript, satori cover_image:

    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"));
    

    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);
    

    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;
    }
    

    The build script in package.json chains them:

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

    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;
      }
    }
    

    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>
    `;
    

    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);
    

    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
    

    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/*,*/*",
      },
    });
    

    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
    

    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;
    }
    

    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");
    

    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"
    

    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);
    }
    

    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.

    Tags

    cloudflarewebdevjavascriptsatori

    Comments

    More Blog

    View all
    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught megemma

    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught me

    I ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...

    X
    xbill
    Hey DEV, I'm Tobore. Let's actually connect.community

    Hey DEV, I'm Tobore. Let's actually connect.

    Hey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...

    L
    Laurina Ayarah
    I burned through thousands of AI tokens. Then a friend did it for freeai

    I burned through thousands of AI tokens. Then a friend did it for free

    (yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...

    P
    Paulo Henrique
    Claude might be saturating your machineai

    Claude might be saturating your machine

    My laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...

    S
    Sidhant Panda
    Automated GitHub Code Reviews Using Google Geminigithubactions

    Automated GitHub Code Reviews Using Google Gemini

    I Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...

    D
    Darren "Dazbo" Lester
    What is an "agentic harness," actually?ai

    What is an "agentic harness," actually?

    I've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...

    T
    Tilde A. Thurium

    Stay up to date

    Get the latest DeepSeek prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for DeepSeek and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this DeepSeek resource

    • End-to-End Blog Generation for WordPress with LLM Agents & Image - GP-5 Optimizedn8n · $24.99 · Related topic
    • Automate Image and Blog Generation via Telegram with Pollinations and Geminin8n · $24.99 · Related topic
    • Real Estate Lead Generation with BatchData Skip Tracing & CRM Integrationn8n · $14.99 · Related topic
    • Generate & Publish SEO Articles with Claude AI, Webflow, & Image Generationn8n · $14.99 · Related topic
    Browse all workflows