I had a separate Express server running headless Chrome just to generate PDFs. It was slow, expensive, and crashed under load. I replaced the entire thing with a single function call. Here's what happened.
The setup
I run a soil analysis API. Farmers submit samples, the API processes results, and generates a 4-page PDF report - cover page, nutrient analysis tables, product recommendations, the works.
The PDF pipeline looked like this:
- A 700-line function in
pdf-generator.tsbuilds an HTML string — concatenating divs, inline styles, table rows, all of it - POST that HTML to a separate Express server running Puppeteer
- Puppeteer launches headless Chrome, renders the HTML, calls
page.pdf() - Upload the PDF bytes to Vercel Blob
- Return the URL
It worked. Mostly.
The problems
It was slow. Each render took 1–5 seconds depending on Chrome's mood. Cold starts were worse.
It needed its own server. Chromium is 200MB+. Vercel functions have a 50MB bundle limit. So I ran a separate server just to host headless Chrome. That's a whole deployment, monitoring, and cost for one function.
It was fragile. Chrome processes would occasionally hang or crash. I had restart logic, health checks, timeout handling — infrastructure code that had nothing to do with making a PDF.
The HTML was a nightmare. 700 lines of string concatenation. No components, no reuse. Every change meant hunting through template literals for the right closing </div>. CSS print rules for page breaks were unreliable — content would split mid-row in tables.
The replacement
I wrote a JSX template:
export default function SoilReport(data) {
return (
<Document>
<Page size="Letter" margin={{ top: 54, right: 54, bottom: 72, left: 54 }}>
{/* Cover page */}
<View style={styles.coverPage}>
<Text style={styles.title}>{data.clientName}</Text>
<Text style={styles.subtitle}>Soil Analysis Report</Text>
</View>
</Page>
{data.samples.map(sample => (
<Page size="Letter" margin={{ top: 54, right: 54, bottom: 72, left: 54 }}>
<Fixed position="header">
<ReportHeader data={data} />
</Fixed>
<Fixed position="footer">
<ReportFooter />
</Fixed>
<StatsCards sample={sample} />
<NutrientTable nutrients={sample.nutrients} />
<Recommendations products={sample.products} />
</Page>
))}
</Document>
);
}
Components. Real page breaks. Headers and footers that repeat automatically. The layout engine handles pagination — if a recommendations section is too tall for the page, it splits cleanly and continues on the next page with the header intact.
The render call in my API route:
const doc = SoilReport(reportData);
const pdfBytes = await renderDocument(doc);
await put(`reports/${id}.pdf`, pdfBytes);
That's it. No HTTP call to another server. No browser. The Rust engine compiles to WASM and runs in-process. The whole thing fits in a Vercel function.
The commit
14 files changed, 39 insertions(+), 1018 deletions(-)
What got deleted:
-
pdf-generator.ts— 700 lines of HTML string building - The entire Puppeteer Express server
- Chrome launch config, restart logic, health checks
- The
templates/directory with HTML partials
What got added:
- One JSX template file
- Three lines wiring
renderDocument()into the existing API route
The numbers
| Puppeteer | Forme | |
|---|---|---|
| Render time | 1–5 seconds | 28ms |
| Bundle size | 200MB+ (Chromium) | ~3MB (WASM) |
| Infrastructure | Separate server | In-process |
| Page break control | CSS break-before (unreliable) |
Layout engine (automatic) |
| Template format | HTML string concatenation | JSX components |
When Puppeteer is still the right call
If you're rendering arbitrary web pages — screenshots, HTML emails with complex CSS, anything where you need a real browser — Puppeteer is the tool. It's a browser automation library and it's good at that.
But if you're generating structured documents — invoices, reports, statements, certificates — you don't need a browser. You need a layout engine that understands what a page is. That's a different problem.
The tool
This is Forme. It's open source — a Rust PDF engine with a React component layer. You write JSX, it renders PDFs. Page breaks work. I built it because I needed it.
npm install @formepdf/react @formepdf/core
If you're running a Puppeteer service just to make PDFs, you might not need to.
Top comments (0)