Skip to main content
If you are using Puppeteer (or Playwright) to generate PDFs from HTML, you have probably hit at least one of these: slow renders, high memory usage, Chrome crashes in production, or unreliable CSS page breaks. Forme replaces the entire headless browser pipeline with a WASM module that runs in-process and produces PDF bytes in milliseconds.

Common problems Forme solves

Puppeteer’s cold start is slow. Launching Chrome costs seconds (3-10s cold on serverless, ~400ms even on a warm machine). Forme has no browser to start: a typical document renders in roughly 15-25ms from a warm engine, and cold-starts in tens of milliseconds. Since the September 2026 allocation fixes, Forme also leads a warm, pooled Chrome on the large table-heavy corpus documents — narrowly on the 500-page stress document — and every figure, including the earlier losing ones, stays published with provenance. Chrome uses too much memory. Each Chrome process consumes 50-200MB of RAM. Forme’s footprint scales with the document: about 7MB for a one-page render, ~85MB for a 50-page statement, and ~1.1GB for a 500-page ledger (the full layout tree is held until serialize; measured at 0.20.0). For most documents that is far below a browser process; for very large ones, mind your memory ceiling. CSS page breaks are unreliable. page-break-inside: avoid and break-before: always are hints that Chrome frequently ignores. Forme uses page-native layout where every break is deterministic. Table headers don’t repeat across pages. HTML <thead> repetition in Chrome’s print mode is inconsistent. In Forme, mark a row as header and it repeats on every page, guaranteed. Docker images are huge. Chromium adds 400MB+ to your container. Forme has no native dependencies, so your image stays the size of your Node.js base. Fonts differ between your laptop and the server. Chrome falls back to whatever the OS provides, so the PDF that looked right locally ships with substituted fonts in the container. Forme never reads system fonts implicitly: you pass TTFs explicitly, and the same bytes render everywhere — native and WASM output is byte-identical in CI. Serverless cold starts are painful. Chrome takes 3-10 seconds to start in Lambda or similar environments — and on many consumption tiers it cannot boot at all. Forme cold-starts in roughly 64ms on a Cloudflare Workers isolate and ~110ms on Node, measured at 0.20.0 (provenance); the 7.66 MB WASM module instantiates in 1-10ms; the rest is first-render warmup.

You may not need to rewrite anything

If your Puppeteer setup renders HTML you already have, the shortest migration is not JSX — it is the HTML input path:
That is the whole replacement for launch / newPage / setContent / page.pdf / close: one synchronous call, no browser, and a warnings list that tells you exactly which parts of your CSS the subset does not cover. Measured against 15 production templates collected from GitHub (Bootstrap grids, wkhtmltopdf-era tables, email HTML, modern CSS grid), 14 render correctly at 0.20.0, 1 degrades legibly with its cause named in warnings, 0 render broken (how it was measured). The rest of this page shows the JSX path, which is the better end state when you control the template: typed components, no string templating, and the same engine underneath.

The Puppeteer pipeline

A typical Puppeteer PDF generation setup looks like this:
This approach has several costs:
  • Chrome dependency. You need Chromium installed in your environment. Docker images with Chromium are 400MB+.
  • Startup time. Launching Chrome takes 500ms-2s. Even with browser pooling, each page creation adds latency.
  • Memory. Each Chrome process uses 50-200MB of RAM. Under load, memory pressure causes crashes.
  • Page break fragility. CSS page-break-inside: avoid is a suggestion, not a guarantee. Chrome sometimes ignores it, especially with complex layouts.
  • No repeating headers. There is no CSS mechanism to repeat table headers on every page. Chrome’s thead repetition is inconsistent.
  • Security surface. Running a headless browser in production introduces a class of vulnerabilities (navigation to malicious URLs, resource exhaustion, sandbox escapes).

The Forme equivalent

No browser. No subprocess. No cleanup. The renderDocument() call runs a WASM module in-process and returns PDF bytes.

Performance comparison

All figures below are measured on identical HTML and published per-document at parity.formepdf.com/#benchmarks (dev machine, warm; Puppeteer reuses one browser across renders). The honest shape: Forme wins cold start decisively (and runs where Chrome can’t), is ~2.5-3x faster on the small documents most apps generate, produces far smaller files, and — since the September 2026 allocation fixes — leads on the large table-heavy documents too, narrowly at 500 pages (it trailed ~3.8x before; the remaining known inefficiency, a second layout pass for page-number widths, is documented in the artifact). For serverless specifically, the cold-start gap is the whole story: Puppeteer is measured in seconds, Forme in tens of milliseconds.

Step-by-step migration

1. Install Forme

2. Convert your HTML template to JSX

Map your HTML structure to Forme components:

3. Replace the render call

Before:
After:

4. Remove Puppeteer dependencies

Remove any Chromium-related Dockerfile steps, browser pool management code, and Chrome process monitoring.

When Puppeteer is still the right choice

Forme is not a drop-in replacement for every Puppeteer use case. Keep Puppeteer if:
  1. You are rendering an app, not a document. A dashboard screenshot, a page that assembles itself in JavaScript, arbitrary third-party HTML of unknown shape — that is a browser’s job. Forme renders documents: HTML you (or your template author) wrote, through a documented CSS subset that warns by name on everything outside it. If you cannot enumerate what CSS your input uses, keep the browser.
  2. You depend on page JavaScript. If scripts build the DOM before printing — charting libraries, client-side templating, dynamic calculations — Puppeteer executes them and Forme never will. Move that logic out of the page first, or stay. (Two reasons that used to be on this list are gone: existing HTML no longer requires a JSX rewrite — see the HTML input path above — and CSS Grid is now a supported subset, not an exclusion.)
  3. You need complex SVG rendering. Forme supports basic SVG elements (rect, circle, ellipse, line, polyline, polygon, path), but not advanced SVG features like filters, gradients, masks, or CSS styling within SVG. If your PDFs contain complex charts from D3 or Chart.js, Puppeteer may still handle more of the SVG spec.
  4. You need screenshots, not PDFs. Puppeteer captures screenshots of web pages. Forme only produces PDFs.

Common patterns

Conditional page breaks

Puppeteer: @media print { .section { page-break-before: always; } } (unreliable) Forme:

Repeating headers

Puppeteer: Use <thead> and hope Chrome repeats it (it often does not) Forme:
Header rows repeat on every page, guaranteed.

Page numbers

Puppeteer: CSS @page { @bottom-center { content: counter(page); } } (limited styling) Forme:
Full styling control over the page number element.

Verifying the migration across your whole template set

The real fear is not “will one document render” — it is “will I silently break 400 of them.” That is a structural question, and pdf-testkit answers it by diffing PDFs structurally rather than by pixels:
Pixel diffing cannot do this across renderers. Chrome and Forme rasterize with different font engines, different anti-aliasing, different sub-pixel rounding — every page differs benignly, the diff is all noise, and you are back to opening PDFs by hand. Structural comparison checks what you actually care about: all the text is present, in the same reading order, on the same pages, in roughly the same places. It reports semantic events with confidence scores, so a loop over the whole template set produces a short list of documents worth opening instead of all of them. Once migrated, keep the same tool as your regression gate: toMatchPDFSnapshot() in Vitest or Jest baselines every template, and with Forme’s LayoutInfo fast path it skips PDF parsing entirely.

The honest close

Migrate if you are generating documents — invoices, statements, reports — from HTML or templates you control, and the browser is pure overhead: cold starts, memory, a binary to babysit, fonts that differ by environment. Don’t migrate if you are rendering an app rather than a document, or your pages need their JavaScript to exist. Those are browser jobs and Puppeteer is good at them. What to try first: one real template, one command — npx @formepdf/html your-template.html -o out.pdf — and read the warnings. It costs a minute and tells you which of the sections above apply to you. Every performance number on this page is measured per commit and published with provenance at parity.formepdf.com; if something renders wrong without a warning naming it, file it — that is a bug on our side by contract.