The Best Puppeteer Alternative for PDF Generation
Puppeteer boots a full Chrome instance to generate PDFs. Here's how to replace it with a Rust WASM engine that runs anywhere JavaScript runs — in milliseconds, not seconds.
Puppeteer works. Until it doesn't.
You start with a simple use case -- generate an invoice, export a report. You install Puppeteer, write some HTML, and it works on your laptop. Then you try to deploy it.
Suddenly you're configuring sandbox flags, installing Chrome dependencies in Docker, hitting memory limits on Lambda, dealing with 8-12 second cold starts, and fighting page break CSS that works in the browser but not in headless mode.
This is the Puppeteer tax. Every team that uses it pays it eventually.
Why Puppeteer Is Hard to Deploy
Puppeteer is a browser automation tool that happens to generate PDFs. It wasn't designed for this -- it boots a full Chrome instance, renders your HTML in a real browser context, and exports the result.
That's why it's painful:
Binary size. Chrome is 200-300MB. In a Docker image that means a large base layer, slow builds, and cold starts on every Lambda invocation.
Cold starts. Spinning up a Chrome instance takes 1-3 seconds in the best case. On Lambda with a cold container, 8-12 seconds is common. For a user waiting on an invoice download, that's unacceptable.
Memory. Chrome needs 256-512MB of memory per instance. On Lambda, that's expensive. On a small VPS, that's your whole machine.
Sandbox issues. Chrome requires specific kernel capabilities that aren't available in all container environments. The --no-sandbox flag is a security risk. Getting it right in Docker, Lambda, and Kubernetes is a rite of passage every backend developer endures once.
Page breaks. CSS page breaks are inconsistent across Chrome versions and render modes. Tables split in the wrong place. Headers don't repeat. Content that looks fine in the browser breaks when exported to PDF.
// The Puppeteer setup tax
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
executablePath: process.env.CHROME_PATH,
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '1cm', bottom: '1cm' },
});
await browser.close();
Every team writes a version of this. Every team eventually hits a production incident because of it.
The Alternatives
WeasyPrint / wkhtmltopdf
HTML-to-PDF converters that don't use Chrome. Lighter weight, but they implement a subset of CSS and frequently produce different output than a browser. Page break support is unreliable. Both are unmaintained or slow-moving.
react-pdf
A React component library for PDF generation. No Chrome dependency, runs in Node. The component model is good but the layout engine has known issues with flex containers at page boundaries -- a bug that has been open for years. No tables with repeating headers. Limited font support.
DocRaptor / PDFShift / APITemplate
Hosted HTML-to-PDF APIs. They handle the Chrome infrastructure for you, which solves the deployment problem. But you're still paying the Chrome tax in latency -- these services boot a browser per render, which means 2-5 second response times are common. And you're sending your document data to a third party.
Forme
A PDF engine written in Rust, compiled to WASM. No Chrome. No subprocess. Runs anywhere JavaScript runs -- Node, Cloudflare Workers, Vercel Edge, browser. Renders in milliseconds.
What Switching to Forme Looks Like
Before:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
executablePath: process.env.CHROME_PATH,
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({ format: 'A4' });
await browser.close();
After:
import { renderDocument } from '@formepdf/core';
import { Document, Page, Text, View } from '@formepdf/react';
const pdf = await renderDocument(
<Document>
<Page size="A4" margin={36}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>
Invoice #2024-001
</Text>
<Text>$1,200.00</Text>
</View>
</Page>
</Document>
);
No browser. No subprocess. No sandbox flags. No executablePath. No waitUntil: 'networkidle0'.
The Deployment Difference
Puppeteer on Lambda:
FROM node:20
# Install Chrome dependencies (~500MB)
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
libappindicator3-1 \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libxss1 \
libxtst6 \
xdg-utils \
--no-install-recommends
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
Forme:
npm install @formepdf/core @formepdf/react
That's it. The WASM binary is included in the package. It runs on Lambda, Cloudflare Workers, Vercel Edge, and in the browser with zero additional dependencies.
Performance
Puppeteer cold start on Lambda: 8-12 seconds. Subsequent renders: 1-3 seconds.
Forme on Lambda: No cold start overhead beyond the standard Node startup. Renders a 4-page report in under 500ms end-to-end including network round trip to the hosted API.
The WASM binary is 5.7MB. On Cloudflare Workers it loads once per isolate and stays warm -- you pay the load cost once, not on every request.
Page Breaks
This is where Puppeteer really falls short. CSS page breaks are a browser feature, not a PDF feature. The mapping is lossy and inconsistent.
/* Puppeteer: hope this works */
.section { break-inside: avoid; }
.table-row { page-break-inside: avoid; }
@media print {
.header { position: fixed; top: 0; }
/* Pray the browser repeats it on every page */
}
Forme has a layout engine built for pages. Content flows into pages, not onto an infinite canvas that gets sliced afterward.
<Table>
<Row header>
<Cell>Item</Cell>
<Cell>Price</Cell>
</Row>
{items.map(item => (
<Row key={item.id}>
<Cell>{item.name}</Cell>
<Cell>{item.price}</Cell>
</Row>
))}
{/* Headers repeat on every page automatically */}
{/* Rows never split across pages */}
</Table>
Widow and orphan control, table header repetition, and flex layout at page boundaries work correctly by default.
Running on Cloudflare Workers
Puppeteer cannot run on Cloudflare Workers. Chrome requires a full OS environment. Workers is a V8 isolate -- no native binaries, no subprocesses.
Forme runs natively on Workers:
import { Hono } from 'hono';
import { forme } from '@formepdf/hono';
import { Document, Page, Text } from '@formepdf/react';
const app = new Hono();
app.use('/pdf/*', forme());
app.get('/pdf/invoice', async (c) => {
const pdf = await c.forme.render(
<Document>
<Page>
<Text>Invoice</Text>
</Page>
</Document>
);
return new Response(pdf, {
headers: { 'Content-Type': 'application/pdf' },
});
});
The same WASM binary that runs in Node runs on Workers. No configuration changes, no separate deployment, no cold starts.
Using the Hosted API
If you don't want to manage the WASM binary at all, the Forme hosted API gives you a single HTTP endpoint:
curl -X POST https://api.formepdf.com/v1/render/invoice \
-H "Authorization: Bearer $FORME_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "customerName": "Acme Corp", "amount": 1200 }' \
--output invoice.pdf
Under 500ms end-to-end. No infrastructure to manage. Templates built in the dashboard, called from any language via API.
When to Keep Puppeteer
Puppeteer is still the right tool for:
- Web scraping -- that's what it was built for
- Screenshot generation -- capturing pixel-perfect browser renders
- E2E testing -- automating browser interactions
- Existing HTML templates -- if you have a large HTML template you can't rewrite
If your use case is generating PDFs from structured data -- invoices, reports, contracts, certificates -- Puppeteer is the wrong tool. It's a browser, not a PDF engine.
Getting Started
npm install @formepdf/core @formepdf/react
Or use the hosted API -- sign up at app.formepdf.com. Get an API key and start rendering PDFs with a single HTTP call. The free plan includes 50 operations per month.
The VS Code extension gives you live PDF preview as you write JSX -- install it from the marketplace and open any .tsx file with Forme components.