Cloudflare Workers gives you three real paths to a PDF in 2026: the Browser Rendering binding when you need full Chromium and CSS fidelity, pdf-lib when your document is structured data and you want zero infra, or an external HTML-to-PDF API called over fetch() when you want managed Chromium without the Cloudflare Browser Rendering bill. Rule of thumb: Browser Rendering when you control the template, external API when you want zero-ops.
This guide walks through every option, what workerd's runtime constraints actually mean for PDFs, and a working Worker for each path.
Why classic Playwright and Puppeteer do not work on Workers
The Cloudflare Workers runtime is workerd, a V8 isolate host designed for short-lived, sandboxed, stateless requests. workerd's constraints are the reason a stock npm install puppeteer does not run on the edge: no native binaries, no process spawning, no shared libraries, no filesystem, no large heap.
A Worker has roughly 128 MB of memory, a 30-second CPU cap on most requests, and runs inside an isolate that boots in single-digit milliseconds. Chromium needs the opposite of every one of those: 300 MB of memory at idle, a 500ms-3s startup, and a fork into a multi-process tree.
The first thing a tutorial that says "just run Puppeteer on Workers" hides is that workerd refuses to spawn a child process. There is no child_process, no fs.spawn, no dlopen. The call fails on import or on first launch with puppeteer.launch is not a function or Cannot find module 'child_process'. The workerd source code is public; the missing primitives are missing on purpose.
The constraints in one table:
| Workerd capability | Available? | Implication for PDF generation |
|---|---|---|
| Native binaries (Chromium, fonts) | No | Cannot bundle a browser |
| Spawn child process | No | Cannot launch a renderer |
| Filesystem | No | Cannot unpack Chromium |
| Shared memory / IPC | No | Cannot drive a separate Chromium |
| Heap size | ~128 MB | pdf-lib works; large embeds choke |
| CPU time | 30s standard | Enough for most renders |
| Wall-clock time | 6m on paid | OK for queued batches |
| Bundle size | 1 MB free, 10 MB paid (compressed) | pdf-lib fits, browsers do not |
The Cloudflare Workers limits page documents these caps in detail at developers.cloudflare.com/workers/platform/limits. They are not bugs to work around; they are the price of the boot time and the global edge presence.
The practical consequence: three viable PDF paths, none of which involve running a browser inside the isolate.
Option 1: Cloudflare Browser Rendering binding
Browser Rendering is Cloudflare's managed Chromium fleet. Workers do not run the browser; they call into a pool of remote Chromium instances over a durable RPC channel exposed as a binding. The client library is @cloudflare/puppeteer, an API-compatible fork of Puppeteer that swaps the launch path for a binding call.
It went GA in 2025 and is the only first-party way to drive a real browser from a Worker.
Add the binding to wrangler.toml:
name = "pdf-worker"
main = "src/index.ts"
compatibility_date = "2026-06-01"
compatibility_flags = ["nodejs_compat"]
[[browser]]
binding = "MYBROWSER"Install the client:
npm install @cloudflare/puppeteerThe Worker:
import puppeteer from "@cloudflare/puppeteer";
export interface Env {
MYBROWSER: Fetcher;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const html = await request.text();
const browser = await puppeteer.launch(env.MYBROWSER);
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
const pdf = await page.pdf({
format: "A4",
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
printBackground: true,
});
return new Response(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'inline; filename="document.pdf"',
},
});
} finally {
await browser.close();
}
},
};Three things to know before sizing this for production:
- Concurrent browser cap. Each Worker account has a maximum number of concurrent Chromium sessions. The Workers Paid plan starts low and scales on request. A burst of renders past the cap returns an error; queue the overflow.
- Session start latency. A cold browser session takes 1-3 seconds. Reusing a session across requests with
browser.connect()and the session API avoids that cost for hot paths. - Pricing. Workers Paid is $5 per month. Browser Rendering bills on top, per browser minute and per concurrent browser. For a workload of 10K renders at 500ms each, expect single-digit dollars of Browser Rendering on top of the $5 plan, but check the published prices before you commit.
When this fits: you control the HTML template, you want full Chromium fidelity, and you are happy to stay inside the Cloudflare ecosystem.
When this stops fitting: you need 50+ concurrent renders, your render time is over 10 seconds, or you want to control the Chromium version yourself.
Option 2: pdf-lib for programmatic-only PDFs
pdf-lib is pure JavaScript. No native code, no Chromium, no dependencies that touch the filesystem. It runs unchanged on workerd because it does not need any of the primitives workerd lacks. The trade-off: it is a programmatic API, not an HTML renderer. You build a PDF by calling drawText, drawRectangle, embedFont, and addPage. CSS is irrelevant; there is no layout engine.
For invoices, receipts, badges, certificates, shipping labels, and anything else where the structure is rigid and the values change, this is the cheapest path on Workers. CPU time on a typical invoice is 20-80ms, well under any limit, and the bundle adds about 250 KB compressed.
Install:
npm install pdf-libWorker that builds a basic invoice:
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
export default {
async fetch(request: Request): Promise<Response> {
const data = (await request.json()) as {
invoice_number: string;
client_name: string;
lines: { description: string; qty: number; subtotal: number }[];
total: number;
};
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
const page = pdf.addPage([595, 842]); // A4 in points
const { height } = page.getSize();
let y = height - 60;
page.drawText(`Invoice ${data.invoice_number}`, { x: 50, y, size: 20, font: bold });
y -= 30;
page.drawText(`Client: ${data.client_name}`, { x: 50, y, size: 11, font });
y -= 40;
page.drawText("Description", { x: 50, y, size: 10, font: bold });
page.drawText("Qty", { x: 360, y, size: 10, font: bold });
page.drawText("Subtotal", { x: 470, y, size: 10, font: bold });
y -= 18;
page.drawLine({ start: { x: 50, y }, end: { x: 545, y }, thickness: 0.5, color: rgb(0.7, 0.7, 0.7) });
y -= 12;
for (const line of data.lines) {
page.drawText(line.description, { x: 50, y, size: 10, font });
page.drawText(String(line.qty), { x: 360, y, size: 10, font });
page.drawText(line.subtotal.toFixed(2), { x: 470, y, size: 10, font });
y -= 16;
}
y -= 10;
page.drawText(`Total: ${data.total.toFixed(2)}`, { x: 400, y, size: 12, font: bold });
const bytes = await pdf.save();
return new Response(bytes, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="invoice-${data.invoice_number}.pdf"`,
},
});
},
};Embedding assets from R2. pdf-lib accepts Uint8Array for fonts and images, so an R2 binding gives you a logo or a brand font with no extra plumbing:
const logoBytes = await env.ASSETS.get("logo.png").then(o => o!.arrayBuffer());
const logo = await pdf.embedPng(logoBytes);
page.drawImage(logo, { x: 50, y: y, width: 80, height: 30 });Sibling libraries that also work on Workers:
- pdfme, a higher-level template engine built on pdf-lib. Good for designer-friendly JSON templates.
- jsPDF, pure JS, older API, runs on Workers but its CSS-to-PDF mode is fragile.
- pdfkit, originally Node-only. Recent versions are usable on Workers via the
nodejs_compatflag, but the streams API needs care.
When this fits: structured documents at high volume, where 95% of the PDF is the same and only data changes.
When this stops fitting: anything driven by a designer in HTML/CSS. There is no reasonable way to translate Tailwind to drawRectangle calls.
Option 3: Call an external HTML-to-PDF API from inside the Worker
The third path uses Workers for what they are good at (fetch, routing, auth, R2 storage) and pushes the Chromium problem off the edge entirely. The Worker authenticates the request, calls an external HTML-to-PDF API over fetch(), and forwards the bytes. There is no Chromium binary, no @cloudflare/puppeteer binding, no pdf-lib dependency tree. Five lines of code.
Worker that calls PDF4.dev:
export interface Env {
PDF4_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { template_id, data } = (await request.json()) as {
template_id: string;
data: Record<string, unknown>;
};
const upstream = await fetch("https://pdf4.dev/api/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${env.PDF4_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ template_id, data }),
});
if (!upstream.ok) {
return new Response(await upstream.text(), { status: upstream.status });
}
return new Response(upstream.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'inline; filename="document.pdf"',
},
});
},
};upstream.body is a ReadableStream, so the Worker streams the PDF straight to the client. No buffering, no 128 MB memory ceiling, no double download.
Cross-language reference for the same call (useful when your edge logic and your backend share a contract):
await fetch("https://pdf4.dev/api/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${env.PDF4_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ template_id: "invoice", data }),
});When this fits: full CSS fidelity, zero infrastructure ownership, templates edited outside the codebase, and predictable per-render pricing.
When this stops fitting: you have a strict data-residency policy that forbids sending HTML outside Cloudflare, or your volume is so high that per-render API pricing crosses Browser Rendering's flat fee.
Decision matrix
The three options serve different shapes of problem. The honest framing:
| Need | Best fit on Workers | Why |
|---|---|---|
| Full CSS and JS rendering, controlled volume | Browser Rendering binding | Real Chromium, native Cloudflare ecosystem, billed by browser minute |
| Programmatic invoices, badges, receipts, very high volume | pdf-lib | Pure JS, runs in-process, no binding, no second service |
| Full CSS plus zero infra ownership | External HTML-to-PDF API | One fetch(), no Chromium to manage, template editor included |
| Need to store and sign PDFs after generation | Any of the above + R2 | R2 PUT is identical across all three render paths |
| Need to add watermark or merge pages after render | pdf-lib as a post-processing step | Combine with Browser Rendering or an API; pdf-lib accepts existing PDF bytes |
| Long renders (over 30s of CPU) | External API with Durable Object queue | Workers have a hard CPU cap; offload long jobs |
A worked example: a SaaS that generates branded reports. The HTML uses Tailwind and a custom font. Volume is 10K renders per month, spiky. Two viable shapes:
- Browser Rendering shape. The Worker takes the report request, calls
puppeteer.launch(env.MYBROWSER), renders, stores in R2. Cost is the $5 Workers Paid plan plus a few dollars of Browser Rendering. Pros: one vendor, one bill. Cons: Cloudflare-only Chromium version, concurrent caps. - External API shape. The Worker takes the report request,
fetch()es PDF4.dev with the template id and the report data, stores the response body in R2. Cost is the per-render API price. Pros: zero Chromium ops, template lives in a designer-friendly editor. Cons: external network hop, per-render cost.
Both are correct. Pick the one that matches how you want to spend your operational budget.
Cost model on a realistic SaaS workload
Conditions: 10K renders per month, A4 portrait, ~5 pages per render, mid-weight template (Tailwind, one embedded font, small logo). Numbers are public-list-price estimates from June 2026, rounded to the nearest dollar, and they will drift; treat the ratios as the signal, not the absolute totals.
| Path | Fixed monthly | Variable | Estimated total | Hidden costs |
|---|---|---|---|---|
| Browser Rendering binding | $5 (Workers Paid) | Per browser minute + concurrent browser | ~$10-20 | None significant |
| pdf-lib in-process | $5 (Workers Paid) | CPU time billed in 100K-request blocks | ~$5-7 | Dev time to build template programmatically |
| External API (HTML-to-PDF) | $5 (Workers Paid) | Per-render API cost | ~$15-40 | None |
| External API on a free tier | $5 (Workers Paid) | $0 up to free quota | ~$5 + spillover | Free tier caps |
Three observations.
- pdf-lib is the cheapest in absolute terms for structured documents. There is no per-render cost on top of the Workers Paid plan, only CPU time, which is generous at 10M req/month free on the paid plan.
- Browser Rendering is the cheapest for HTML rendering inside Cloudflare, but only inside Cloudflare. Cross-region requests, version pinning, and concurrent caps are real constraints.
- External APIs cost more per render but bundle the template editor, designer access, log retention, and a managed Chromium fleet at a different scale.
The wrong question is "which is cheapest at 10K renders". The right question is "where do I want my engineering team to spend hours next year".
Storing the PDF with R2 plus signed URLs
The canonical Workers PDF pattern: generate the bytes, PUT them to R2, return a signed URL with a short TTL. The user downloads from R2 directly, the Worker stays cheap.
Bind R2 in wrangler.toml:
[[r2_buckets]]
binding = "PDF_BUCKET"
bucket_name = "pdf4-rendered"PUT after rendering:
const key = `renders/${crypto.randomUUID()}.pdf`;
await env.PDF_BUCKET.put(key, pdfBytes, {
httpMetadata: { contentType: "application/pdf" },
});Sign with HMAC and a TTL:
async function signRenderUrl(key: string, secret: string, ttlSeconds = 3600) {
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
const payload = `${key}.${expires}`;
const keyBytes = new TextEncoder().encode(secret);
const k = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(payload));
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `https://pdfs.example.com/${key}?expires=${expires}&sig=${sigB64}`;
}A second Worker, mounted on pdfs.example.com, verifies the signature and streams the R2 object back. This is the same pattern PDF4.dev's own lib/storage/sign.ts uses internally for signed render URLs, with BETTER_AUTH_SECRET as the HMAC key.
Why this pattern: R2 has no egress fees to the public internet, the signed URL caps exposure to a short window, and the rendering Worker does not keep the PDF in memory after the PUT.
When to use Vercel Edge or Deno Deploy instead
The same constraints apply to other edge runtimes, with small differences.
| Runtime | Native binaries | Browser primitive | PDF path |
|---|---|---|---|
| Cloudflare Workers (workerd) | No | Browser Rendering binding | Binding, pdf-lib, or fetch() |
| Vercel Edge Functions | No | None (uses workerd under the hood until 2026) | pdf-lib or fetch() to external API |
| Vercel Functions (Node) | Yes (with size limit) | None first-party | @sparticuz/chromium or fetch() |
| Deno Deploy | Limited | None | pdf-lib or fetch() to external API |
| Bun on the edge | Yes | None | Same as Node, less mature |
Vercel Edge is in practice equivalent to Workers: no Chromium, pdf-lib works, fetch() is your escape hatch. Deno Deploy is more permissive about FFI but does not ship a managed Chromium; the community libraries that download Chromium per request are not viable for production. The honest summary: Cloudflare is the only major edge platform with a first-party browser primitive in 2026.
If you cannot move off the edge and you cannot use Browser Rendering, the external-API path is the only realistic answer. That is true on every edge runtime, not just Cloudflare.
Frequently asked questions
Can I run Puppeteer on Cloudflare Workers?
Not stock Puppeteer. workerd cannot spawn processes or load native binaries, so launching Chromium from a Worker throws on the first call. The supported path is @cloudflare/puppeteer plus a Browser Rendering binding, which delegates to a Cloudflare-managed Chromium fleet over a durable RPC channel. The API surface matches Puppeteer; only the launch path changes.
How much does Cloudflare Browser Rendering cost?
Browser Rendering requires the Workers Paid plan at $5 per month and bills on top of that per browser minute and per concurrent browser. A free tier covers a small amount of usage. Pricing is published at developers.cloudflare.com/browser-rendering/platform/pricing/ and changes occasionally, so check before sizing a workload.
Does pdf-lib work on Cloudflare Workers?
Yes. pdf-lib is pure JavaScript with no native dependencies, so it runs unchanged on workerd. It does not render HTML or CSS; you build the PDF programmatically with shapes, text, and embedded fonts and images. Good fit for invoices, receipts, and badges; wrong tool for design-driven brand PDFs.
Can I generate a PDF from HTML on Workers without Chromium?
Not in a way that matches modern CSS. Pure-JS HTML-to-PDF libraries ignore Flexbox, Grid, and JavaScript-driven layout. If you need full CSS fidelity, you either use Browser Rendering on the same Worker, or call an external API like PDF4.dev with a fetch() from inside the Worker.
How do I store the generated PDF on R2?
Bind an R2 bucket in wrangler.toml, then call env.MY_BUCKET.put(key, pdfBytes) after rendering. R2 PUT is synchronous and returns once the bytes are durable. Pair it with a signed-URL service or a fetch handler on the same Worker to serve the file with a short-lived token.
Can I serve a PDF from a Worker as a download?
Yes. Return a Response with Content-Type: application/pdf and a Content-Disposition header set to attachment; filename="invoice.pdf". The Worker streams the body to the client without buffering the full file in memory if you pass a ReadableStream instead of a Uint8Array.
Is Cloudflare Browser Rendering production-ready?
It went GA in 2025 and powers screenshot and PDF flows for thousands of teams. Caveats apply: per-Worker concurrent browser caps, a session idle timeout, and the same 30-second CPU limit as any Worker. For heavy or long renders, queue the job and process it in a Durable Object or a background Worker.
How do I add a watermark to a Worker-generated PDF?
Render the PDF first (Browser Rendering, pdf-lib, or an API), then open the bytes with pdf-lib in the same Worker and draw a text or image watermark on each page. pdf-lib runs in workerd, so the entire pipeline stays on the edge with no second hop.
Can I call PDF4.dev's API from a Worker?
Yes. fetch() is the canonical way to call external APIs from a Worker. POST your template id and data to https://pdf4.dev/api/v1/render with a Bearer API key, receive the PDF as the response body, and forward it to the client or store it in R2. No SDK, no Chromium, five lines of code.
What is the maximum PDF size I can generate on Workers?
A Worker's response body is unlimited if you stream it, but the in-memory working set is capped at 128 MB. pdf-lib materializes the whole document in memory, so practical limits are tens of MB. For hundred-page or image-heavy PDFs, generate in chunks and concatenate on R2, or delegate to an external API that streams the output.
PDF4.dev exposes a single POST /api/v1/render endpoint that accepts a template id plus a JSON data object and returns a PDF. From a Cloudflare Worker, that is one fetch() call with a Bearer token; from anywhere else, it is the same call in your language of choice. Templates are edited in a web dashboard (raw HTML plus Handlebars variables), so designers can ship new layouts without redeploying the Worker. Free tier covers the first hundreds of renders per month.
The three paths on Workers compress to a single question: do you want Cloudflare to run Chromium for you (Browser Rendering), do you want to skip the browser entirely (pdf-lib), or do you want someone else to run Chromium and bill you per render (external API)? Each is the right answer for a different shape of PDF workload, and switching between them is a few lines of code, not a rewrite.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



