You moved your invoice/label/receipt service to Cloudflare Workers because the rest of your stack already runs there, and the latency math looks beautiful: 5 ms to the nearest colo, 1 ms of CPU, request done.
Then PDF generation lands on you, and suddenly you’re staring at 800 ms p99s, 50 MB worker bundle warnings, and the persistent feeling that you’re using the wrong tool. Here’s why that happens, and the actual bottlenecks you can fix in an afternoon.
Workers are not Lambda — and that matters
Before diagnosing, get the runtime model right. Cloudflare Workers are NOT serverless containers. They’re V8 isolates with these constraints:
- CPU time limit: 50 ms per request on the free plan, 30 seconds on Workers Paid (Bundled), 5 min on Unbound. Wall time is unbounded but billable.
- Memory cap: 128 MB per isolate.
- Bundle size: 1 MB for the free plan, 10 MB on paid.
- No filesystem. No
fs.readFileSync. Everything is in-memory or fetched. - No native binaries. Pure JavaScript / WebAssembly only — no
node-canvas, no native zlib calls, no shelling out to Ghostscript. - Cold-start: ~5 ms. Astonishingly fast — but only because there’s nothing big to boot.
Most “slow PDF in Workers” issues come from violating one of those constraints (usually the CPU limit or the bundle size) and getting silently throttled.
The five things that are actually slow
In rough order of how often they bite teams:
1. Pulling Chromium-based renderers into Workers
This doesn’t work, full stop. Puppeteer needs ~250 MB of Chromium and a real OS. Browser-rendering services (Cloudflare’s own Browser Rendering API, Browserless) work, but they’re not Workers — they’re a separate service you call FROM a Worker, paying ~500 ms round-trip + render time.
If your “Worker-based PDF” is actually “Worker that calls a remote browser-rendering API”, your latency floor is ~500 ms. That’s not a Worker problem; that’s the browser tax following you to the edge.
Diagnosis: check if your code does fetch("https://browser-rendering.cloudflare.com/...") or similar. If yes, the latency you’re measuring is the upstream service’s, not the Worker’s.
2. Doing layout in JavaScript
If you’ve written your own JS-based layout engine (“I’ll just compute box positions manually”), you’re hitting the CPU limit. JS is fast but layout for 30+ elements with text wrapping crosses 50 ms easily on Workers Free, and 100–300 ms on Bundled.
A render pipeline shaped like:
JSON → JS layout pass → SVG generation → SVG-to-PDF library → emit
…is doing four CPU-bound passes over the same data. Each one in JS, each one with garbage-collection overhead, each one re-allocating intermediate trees.
Diagnosis: find your wrangler tail log for a render. If you see > 50 ms of CPU before any I/O, it’s a compute problem.
3. Loading fonts on every request
Fonts are 50–250 KB each. If your renderer reads them from KV / R2 on each render, that’s a network round-trip per font, per request. Five fonts = five RTTs = 50–150 ms before render starts.
Diagnosis: add timing to your font-loading code. If it dominates p50, this is the issue.
Fix: load fonts once at module-init time (top of your Worker file, NOT inside the request handler). The isolate caches the bytes for the life of the isolate (minutes to hours).
// Module-init: runs ONCE per isolate
import notoSans from "./fonts/noto-sans.woff2";
const FONT_BYTES = new Uint8Array(notoSans);
export default {
async fetch(request) {
// Per-request: zero font I/O
return renderPdf({ fontBytes: FONT_BYTES, ...data });
}
};
If your bundler inlines the font as bytes, even better — no I/O at all.
4. Using a JS PDF library that wasn’t built for Workers
pdfkit, pdf-lib, jsPDF — all work in Workers, but they have characteristics that hurt:
pdfkitrequires NodeBuffershims. Possible, but adds ~500 KB and slows compute by ~30%.pdf-libis great for editing existing PDFs, less great for emitting from scratch — its abstraction layer adds ~10 ms of overhead per page.jsPDFis browser-first; same Buffer issue, plus a sprawling API surface that’s hard to tree-shake.
For a render pipeline that’s mostly “read JSON, write PDF bytes”, a purpose-built engine that emits PDF directly (without going through a generic-PDF abstraction) will be 5–20× faster. WebAssembly engines compiled from Rust or C++ benefit further from the JIT-friendly tight loop.
5. The bundle that’s secretly 4 MB
Workers Free caps the bundle at 1 MB. Workers Bundled at 10 MB. Most teams discover the cap when their wrangler deploy rejects with “Script exceeds size limit”. Some discover it earlier, when an enormous import slows cold-start (because V8 has to compile all of it).
wrangler will tell you bundle size in its deploy output. Anything over 500 KB warrants investigation. Common culprits:
- Bundled fonts. Move them to Workers Assets and
fetchonce at module-init. - The
node:shim layer. If you’re seeing__cf_KVorpolyfills:in the source map, your bundler is shimming Node APIs you don’t actually need. - Unused dependencies.
npm run build -- --analyze(on Wrangler 4+) gives a treemap.
What “fast PDF in Workers” actually looks like
A purpose-built edge renderer for structured documents (gPdf is one example, but the architecture applies to any well-built one):
| Metric | Typical | Why |
|---|---|---|
| Cold-start | 5–20 ms | V8 isolate boot + WASM module first-load |
| Per-render CPU | 1–4 ms | WASM tight loop, no GC pressure |
| Per-render wall | 3–8 ms | CPU + a few µs of crypto for PDF object IDs |
| Bundle size | 4–6 MB | Renderer + bundled fonts (Latin + CJK NotoSans) |
| Memory peak | 8–20 MB | Document tree + emitted PDF buffer |
Compare to the typical “Puppeteer-on-Workers via remote browser rendering” path: 500–1000 ms p50, 1–2 GB browser memory hosted elsewhere, ~$0.001/render upstream cost.
A quick triage
If you’re hitting a slow PDF in Workers right now, run this checklist before anything else:
- Where’s the time going? Add timestamps in
wrangler tail. Establish whether the bottleneck is:- CPU (in-process work)
- Egress fetch (calling an upstream service)
- Cold start (only the first request after a quiet period)
- Are you running JS layout? If yes, that’s most of your CPU. Switch to a renderer that pre-computes layout.
- Are you loading fonts per request? Move font loading to module init.
- Are you calling an external browser? Then your latency floor is that service’s response time. Move to a same-isolate renderer (no fetch).
- Is your bundle over 1 MB? Cold-start scales with bundle size. Trim unused deps.
The fastest possible PDF on Workers is one where your document data → emitted PDF bytes happens entirely inside a single fetch handler call, with no fetch() calls of its own and no CPU-heavy JS layout. Most of the “Workers are slow for PDF” complaints we hear are actually “we put a non-Workers-shaped PDF stack on Workers and got the worst of both worlds”.
The short version
Cloudflare Workers can render PDFs at single-digit milliseconds — but only if your renderer is built for an isolate-shaped runtime. JS-based PDF libs designed for Node, browser-rendering services called from a Worker, fonts loaded per-request, layout passes done in JavaScript: each of these caps your p50 somewhere between “slower than your origin” and “indistinguishable from a regional Lambda”.
If you’d rather not engineer your own way out of this, the gPdf Playground is a fully-edge-deployed renderer running on this exact runtime. Hit Render PDF, watch the network tab — that p50 is what a Worker can do when nothing in the pipeline is fighting it.