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: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:- 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: avoidis 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
theadrepetition 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
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:4. Remove Puppeteer dependencies
When Puppeteer is still the right choice
Forme is not a drop-in replacement for every Puppeteer use case. Keep Puppeteer if:- 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.
- 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.)
-
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. - 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:
Page numbers
Puppeteer: CSS@page { @bottom-center { content: counter(page); } } (limited styling)
Forme:
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: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.