← Back to blog

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.

Update (2026-08-25): a react-pdf maintainer got in touch after this post went out. Two things to correct: react-pdf has since shipped a rewritten pagination engine with widows/orphans support (react-pdf #3504), and the "limited font support" line was never accurate — react-pdf has full custom TTF support with fontkit-based OpenType shaping (GSUB/GPOS, ligatures, kerning, feature settings) and fontFamily arrays for fallback. The reason to reach for Forme over react-pdf is more specific: page-native layout (as opposed to slice-and-paginate), first-class <Table> with automatic header repetition, CSS Grid, and enterprise features (PDF/UA, PDF/A, digital signatures, redaction, merging).

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 well under 500ms end-to-end, including the HTTP round trip when calling a self-hosted Forme server.

The WASM binary is 6.8 MB (3.1 MB gzipped; measured from the published @formepdf/core package at 0.20.0). 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'd rather call an HTTP endpoint than manage the WASM binary, the self-hosted server (docker run formepdf/forme) gives you one:

curl -X POST http://localhost:3000/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

Everything above runs from npm install -- no service, no API key. (The hosted API this post originally mentioned has been retired; Forme is now open-source only.)

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.