← Back to blog

PDF generation at the edge: why WASM beats headless Chrome

Headless Chrome is a browser automation tool pretending to be a PDF engine. Here's what happens when you use an actual layout engine instead.

The default answer to "how do I generate a PDF in JavaScript" has been the same for years: spin up headless Chrome, render some HTML, call page.pdf(). Puppeteer, Playwright, or whatever wrapper you prefer. It works. But it's a hack, and the industry has been so deep in it for so long that we forgot it's a hack.

You're launching a full web browser to do page layout. A browser that was designed to render interactive web applications, handle user input, execute arbitrary JavaScript, manage a DOM, paint pixels to a screen. You're using approximately 0.1% of its capabilities. The other 99.9% is dead weight that you're paying for in memory, startup time, and infrastructure complexity.

There's a better way.

The architecture problem

Here's what happens when Puppeteer generates a PDF:

  1. Launch a Chromium process (200MB+ binary)
  2. Create a new browser context
  3. Open a new page
  4. Set the HTML content
  5. Wait for rendering to complete
  6. Call page.pdf() which triggers Chrome's print-to-PDF pipeline
  7. Receive bytes
  8. Close the page, context, and maybe the browser

Steps 1-3 take 500ms to 2 seconds on a cold start. Step 6 takes another 500ms-2s depending on document complexity. You're spending most of the time on overhead that has nothing to do with making a PDF.

A dedicated layout engine does this:

  1. Parse the document structure
  2. Calculate layout (flex, flow, pagination)
  3. Write PDF binary

That's it. No browser, no DOM, no paint step. Input is structured data. Output is bytes. For a typical invoice, this takes 20-40ms.

The deployment problem

Chromium is 200MB. That's a problem everywhere:

Serverless functions have bundle size limits. Vercel caps at 50MB for Node.js functions. Lambda caps at 250MB unzipped, but cold starts scale with bundle size. You end up running a dedicated server just for PDF generation, which is a separate deployment, separate monitoring, separate scaling.

Edge runtimes can't run it at all. Cloudflare Workers, Deno Deploy, Vercel Edge Functions, Fastly Compute. None of them support spawning child processes or loading a 200MB binary. If your app runs at the edge, your PDFs can't.

WASM doesn't have these constraints. A compiled layout engine is 2-3MB. It runs in the same process as your application. It works on every JavaScript runtime: Node.js, Bun, Deno, Cloudflare Workers. No file system access needed. No child processes. Just computation.

The page break problem

This is the one that really matters to anyone who's shipped a PDF feature.

Chrome's page break support is based on CSS Paged Media. The spec is fine. The implementation is not. break-before: page works on block elements, sometimes. break-inside: avoid works on elements that fit on a single page, sometimes. What happens when a table row lands exactly on a page boundary? Chrome clips it. Half a row on one page, the other half missing.

The workaround ecosystem is extensive. People calculate element heights in advance, manually insert break points, split tables into chunks, add invisible spacer divs. There are entire npm packages dedicated to "make CSS page breaks not terrible."

A layout engine designed for paged documents doesn't have this problem. Page breaks are a first-class concept, not a CSS property bolted onto a browser rendering pipeline. The engine knows the page height, knows the content height, and flows content across pages. If a table row doesn't fit, it moves to the next page. If a section has break-inside: avoid and it's too tall for the remaining space, it starts on a new page. This isn't heroic engineering. It's just what happens when your layout engine was designed for documents instead of web pages.

The resource problem

Each Puppeteer render spins up a browser tab. That's real memory: 50-100MB per concurrent render. Under load, your PDF server is managing a pool of Chrome instances, queueing requests, handling timeouts, restarting crashed processes.

WASM runs in the same V8 isolate or process as your application. Memory usage for a render is the document size plus a small working buffer. There's no process pool, no queue, no crash recovery. It's a function call.

When Puppeteer is still right

If you need to render arbitrary web pages to PDF, you need a browser. Screenshots of URLs, HTML emails with complex CSS, anything where "render this the way Chrome would" is the requirement. That's browser automation, and Puppeteer is the right tool.

But if you're generating structured documents from data, if you know the layout in advance, if your "template" is a function that takes data and returns a document, then you don't need a browser. You need a layout engine. The distinction matters because one approach scales and the other doesn't.

The shift

The PDF generation ecosystem has been browser-first because browsers were the only layout engines available in JavaScript. That's no longer true. Rust compiles to WASM. Fast, correct layout engines can run anywhere JavaScript runs. The edge, serverless functions, in-process, in the browser itself.

The next time you reach for Puppeteer to generate a PDF, ask yourself: do I need a browser, or do I need a layout engine?

Forme is an open source PDF engine that takes the layout engine approach. Rust core, WASM runtime, React component layer. It runs everywhere, including places Puppeteer can't.