HTML to PDF in Cloudflare Workers

HTML to PDF in Cloudflare Workers

HTML to PDF in Cloudflare Workers. Why Workers can't run Chromium, how Cloudflare Browser Run compares, and a fetch-based API Worker with R2.

Quick answer. A Worker can't launch Chromium, so call an HTML-to-PDF API with fetch and stream the response back. This is the request your Worker sends:

curl -X POST https://html-pdf.com/v1/pdf \
  -H "Authorization: Bearer $HTML_PDF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<h1>Hello from a Worker</h1>", "format": "A4"}' \
  -o document.pdf

Cloudflare Workers are fast, cheap and close to your users, which makes them a good front door for "download as PDF". Rendering the PDF is another matter. Workers can't run a browser themselves, so you need a browser somewhere else.

Why a Worker can't render PDFs on its own

This section explains the limits you will run into. Workers run in V8 isolates, not containers. There's no filesystem to install Chromium into, no way to spawn a process, and bundle size, memory and CPU time are limited.

Puppeteer or Playwright in their normal form simply can't launch. Pure-JS libraries like pdf-lib run fine, but they draw PDFs from coordinates rather than laying out HTML and CSS.

That leaves two realistic choices. You can use Cloudflare's own hosted browser, or an external HTML-to-PDF API.

Option A: Cloudflare Browser Run (formerly Browser Rendering)

Cloudflare's hosted browser gives you headless Chrome on its network. You can use it from a Worker through a browser binding with Cloudflare's forks of Puppeteer or Playwright. You can also call its REST "quick actions", which include a PDF endpoint.

At the time of writing, Cloudflare's pricing page lists these limits.

It's a good fit if you're all-in on Cloudflare and want full browser control (clicking, scraping, screenshots). You also need to be comfortable managing sessions, concurrency and Puppeteer code yourself. Check Cloudflare's docs for current limits before you commit.

Option B: call an HTML-to-PDF API from the Worker

If you just need "HTML in, PDF out", an API keeps the Worker tiny. html-pdf.com renders with headless Chromium and is billed per page (1 credit = 1 PDF page, 100 free credits, purchased credits never expire). There are no browser sessions to manage.

1. Add your key as a secret

Get a key at https://html-pdf.com/login, then store it as a Worker secret.

npx wrangler secret put HTML_PDF_KEY

For local dev, put HTML_PDF_KEY=... in .dev.vars.

2. A Worker that streams the PDF back

// src/index.ts
export interface Env {
  HTML_PDF_KEY: string;
}

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

    const { html, url } = await request.json<{ html?: string; url?: string }>();

    const res = await fetch("https://html-pdf.com/v1/pdf", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.HTML_PDF_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        ...(html ? { html } : { url }),
        format: "A4",
        margin: "12mm",
        print_background: true,
      }),
    });

    if (!res.ok) {
      // { "error": { "code", "message", "hint" } }
      return new Response(res.body, {
        status: res.status,
        headers: { "Content-Type": "application/json" },
      });
    }

    return new Response(res.body, {
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": 'inline; filename="document.pdf"',
        "X-Pages": res.headers.get("X-Pages") ?? "",
      },
    });
  },
};

Returning res.body passes the stream straight through, so the Worker never buffers the whole PDF in memory. Waiting on the upstream fetch is I/O, not CPU time. Deploy with npx wrangler deploy.

The url must be a public page. For private pages, render the HTML in your Worker (or fetch it with credentials) and send it as html.

3. Optional: save the PDF to R2

Add a bucket binding to wrangler.toml.

[[r2_buckets]]
binding = "PDFS"
bucket_name = "pdfs"

Then store the file instead of (or as well as) returning it.

export interface Env {
  HTML_PDF_KEY: string;
  PDFS: R2Bucket;
}

// inside fetch(), after a successful render:
const key = `invoices/${crypto.randomUUID()}.pdf`;
await env.PDFS.put(key, await res.arrayBuffer(), {
  httpMetadata: { contentType: "application/pdf" },
});
return Response.json({ key, pages: res.headers.get("X-Pages") });

Buffering with arrayBuffer() is the simplest way to upload, because R2 needs a known length for a stream. PDFs are usually small, so this is fine.

If you don't need your own storage, send "response": "url". You get JSON { "url": "...", "expires_at": "..." } with a link valid for 24 hours.

Running it in the background

You can also generate PDFs outside the request path. For "generate on order" flows, push a message to a Cloudflare Queue and let a consumer Worker call the API and write to R2.

For scheduled statements, use a Cron Trigger. Either way the Worker code is the same fetch call.

Options and limits

These are the main options you can send with each request.

Requests can be up to 20 MB and each render gets 30 seconds. A 402 insufficient_credits error means your balance is empty, and X-Credits-Remaining tells you where you stand.

Full reference at https://html-pdf.com/docs, invoice layouts at https://html-pdf.com/generate-invoice-pdf, and Node.js examples at https://html-pdf.com/html-to-pdf-nodejs.

Which should you pick?

The right choice depends on how much browser control you need. Choose Browser Run if you need a programmable browser and want everything on Cloudflare.

Choose an API if all you need is reliable HTML-to-PDF with print options and per-page pricing, without managing browser sessions.

FAQ

Can I run Puppeteer in a Cloudflare Worker?

Not the standard version. Workers run in V8 isolates with no filesystem or process spawning, so Chromium can't launch. You can use Cloudflare's Browser Run forks of Puppeteer through a browser binding, or call an HTML-to-PDF API with fetch.

Is there a free way to generate PDFs from a Worker?

Yes. html-pdf.com gives you 100 free credits once, with no card required, and 1 credit = 1 PDF page. Cloudflare's Workers Free plan also includes 10 minutes of Browser Run time per day.

Does it support page numbers?

Yes. Pass header_html or footer_html with the pageNumber and totalPages placeholders.

How do I store generated PDFs in R2?

Add an R2 bucket binding in wrangler.toml and call env.PDFS.put() with the response's arrayBuffer(). If you don't need your own storage, send "response": "url" to get a download link valid for 24 hours.

This page as markdown · text