PDF Generation on Cloudflare Workers: Why You Can't Just Ship Chrome to the Edge

Cloudflare Workers give you a V8 isolate with no filesystem, no process spawning, and no shell to exec anything, all under a hard memory and CPU-time budget…

Cloudflare Workers give you a V8 isolate with no filesystem, no process spawning, and no shell to exec anything, all under a hard memory and CPU-time budget per request [1]. A headless Chrome binary is roughly 150 MB. Even if you could squeeze it past the bundle limit, there is nothing inside a Worker to run it.

Why Chromium cannot run inside a Worker

A Worker is not a container. It is a V8 isolate with no operating system underneath it. A Chrome binary is roughly 150 MB and cannot be bundled into a Worker, and there is nothing to exec() it even if it fit [1].

Some developers try to work around this by calling out to a browser running somewhere else. That is a valid pattern, but it is not "running Chrome in a Worker." The browser lives on a different host, and your Worker is just a thin proxy.

The four paths that actually work

Cloudflare and the ecosystem have settled on four approaches for PDF generation at the edge.

  1. Browser Run sessions with Puppeteer or Playwright bindings. Cloudflare Browser Run (formerly Browser Rendering) lets developers programmatically control and interact with headless browser instances running on Cloudflare's global network. It offers no-code Quick Actions for simple, stateless browser tasks like screenshots, PDFs, and scraping.
  2. The /pdf Quick Action endpoint. Cloudflare's /pdf Quick Action endpoint instructs the browser to generate a PDF of a webpage or custom HTML using Cloudflare's headless Browser Run service. The /pdf endpoint supports custom page headers and footers via HTML templates using the headerTemplate and footerTemplate options, enabled with displayHeaderFooter.
  3. WASM/browser-less engines like Forme. Forme is an open-source PDF engine in Rust + WASM that converts HTML and print CSS to PDF without a browser, from JavaScript or Python, with no system libraries to install.
  4. A hosted rendering API called via one fetch(). Your Worker becomes a pass-through: collect the HTML or template parameters, POST to an external service, and stream the response back [1].

Where each workaround hits its ceiling

Browser Run bills only for browser time used and has a generous free tier, but that free tier is bounded. Workers Free plan accounts are limited to 10 minutes of Browser Run usage per day. Paid plans get higher defaults that Cloudflare can raise on request, yet you are still paying for browser minutes with concurrency caps. A burst of invoice generation at month-end can throttle or surprise you.

The /pdf Quick Action sits on the same Browser Run service, so it inherits the same billing model. For a Worker that needs to generate thousands of PDFs without queuing, that cost structure compounds.

Forme removes the browser entirely, with no system libraries to install. The practical constraint on Workers is the free plan's 10 ms CPU cap against a ~20 ms typical render, so real workloads need the paid plan.

Calling AWS Lambda from a Worker for PDF work adds cold starts and a trans-Pacific round trip for distant users [1]. You have defeated the edge by routing through us-east-1. A hosted API colocated with your users avoids that latency, but you still want to be sure you are not buffering multi-megabyte PDFs inside the 128 MB isolate.

The hosted-API pattern in a Worker

A Worker can stream a hosted PDF API's response body straight back to the caller without buffering the whole document in memory [1].

The flow looks like this in practice. Your Worker receives a POST with order data. It fetches a prebuilt HTML invoice template from your own origin or constructs one inline. It POSTs that HTML to the hosted API with headers, footers, and margins specified. The API returns a PDF URL or a buffer. The Worker streams that response directly to R2 or back to the caller, never holding more than a chunk in memory.

No binary to ship, no isolate memory ceiling, no browser-minute billing surprises. The trade-off is a network hop and a dependency on an external service. For most teams already running Workers, that dependency is preferable to maintaining a Chromium fleet.

MarkupGo as the one-fetch implementation

MarkupGo's /pdf endpoint generates PDFs from URLs, HTML, templates, and markdown via a single POST with source, options, and optional expiration fields [2]. PDF options include custom header/footer HTML, PDF/UA accessibility, emulated media type, wait delays, cookies, and metadata [2]. You can set an expiration option that auto-deletes generated files between 60 seconds and 90 days, with expired tasks swept every minute [2].

For templates you reuse, MarkupGo's Magic Template URL lets you generate images or PDFs from an existing template via a plain URL with query parameters, no code required [3]. Magic URLs are public, so anyone with the URL can consume your credits, but json=true or redirect=true parameters can hide the generated file URL from clients [3]. Reusing the same Magic URL within 15 days serves a cached result without consuming additional credits [3].

Pricing starts at $29/month for the Lite plan with 2,000 credits and 5 templates, $49/month Plus with 10,000 credits, and $99/month Pro with 20,000 credits and unlimited templates [4]. A free trial offers 100 credits per month with no credit card required [4].

A complete Worker that streams HTML to PDF to R2

Here is a Worker that accepts HTML inline, sends it to MarkupGo, and streams the resulting PDF into R2 without buffering the full file in memory.

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const { html, filename = 'document.pdf' } = await request.json();

    // Call MarkupGo's /pdf endpoint
    const pdfResponse = await fetch('https://markupgo.com/api/v1/pdf', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': env.MARKUPGO_API_KEY,
      },
      body: JSON.stringify({
        source: {
          type: 'html',
          data: html,
        },
        options: {
          properties: {
            printBackground: true,
            margins: { top: 10, bottom: 10, left: 10, right: 10 },
          },
        },
        expiration: { expiresInSeconds: 3600 },
      }),
    });

    if (!pdfResponse.ok) {
      return new Response('PDF generation failed', { status: 502 });
    }

    const pdfTask = await pdfResponse.json();
    // pdfTask.url is the generated PDF file

    // Stream the PDF from MarkupGo into R2
    const pdfStream = await fetch(pdfTask.url);
    if (!pdfStream.ok || !pdfStream.body) {
      return new Response('Failed to fetch PDF stream', { status: 502 });
    }

    const r2Object = await env.PDF_BUCKET.put(filename, pdfStream.body, {
      httpMetadata: {
        contentType: 'application/pdf',
        contentDisposition: `attachment; filename="${filename}"`,
      },
    });

    return new Response(JSON.stringify({
      r2Key: r2Object.key,
      markupgoTask: pdfTask.id,
    }), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};

The Worker never loads the full PDF into a string or ArrayBuffer. It pipes the response body from MarkupGo directly into R2, staying within the isolate's memory limits even for large documents. The expiration field ensures the generated file is cleaned up from MarkupGo's storage after an hour, while R2 keeps your own copy as long as your retention policy requires.

If you prefer to return the PDF directly to the caller instead of storing it, replace the R2 put with return new Response(pdfStream.body, { headers: { 'Content-Type': 'application/pdf' } }).

For invoices or reports that repeat the same layout, consider creating a template in the MarkupGo dashboard and switching the source type to template. That moves the HTML and CSS out of your Worker code and lets non-developers adjust styling without a deploy. For OG images or social posts, a Magic Template URL can generate the asset from query parameters alone, with caching across repeated requests [3].

Start with the free trial to validate the streaming pattern against your own HTML and R2 bucket.

Sources

  1. Fill a PDF form inside a Cloudflare Worker (dev.to)
  2. markupgo.com
  3. markupgo.com
  4. markupgo.com