Stop using HTML-to-PDF for invoices
Puppeteer invoices look fine until you run 10,000 of them. Here's the hidden bill, and what to do instead.
The pattern is everywhere. You need an invoice, someone remembers Puppeteer or Playwright, and within an hour you have a route that takes some HTML, runs it through a headless browser, and hands back a PDF.
It works. It looks right. Then you ship it and the bill shows up.
This post is the bill.
The hidden cost of HTML-to-PDF in production
Let's do the math on a realistic invoice endpoint. You render 10,000 invoices a day on Vercel or a comparable serverless platform. Each one goes through Puppeteer.
1. Cold starts
Every serverless function has to load Chromium from scratch on a cold start. @sparticuz/chromium is 50 MB, Playwright-compatible builds are 120 MB, stock Chromium is 150 MB+. Cold-start latency on Vercel's hobby tier:
| Image | Cold start | Warm render |
|---|---|---|
| @sparticuz/chromium | 600-900ms | 400-700ms |
| chrome-aws-lambda | 800-1200ms | 500-800ms |
| Full Playwright | Often exceeds 3s | 900ms+ |
At 10k invoices a day, spread across time zones, roughly 15-25% hit cold starts depending on concurrency settings. You're paying 600ms of latency for the privilege of using a browser engine. And the user doesn't care, they just want their invoice.
2. Memory pressure
Chromium wants 512 MB of memory to be comfortable. On Vercel's hobby plan you get 1 GB. One PDF render spikes to 400-600 MB. If two requests overlap on the same Lambda instance, you OOM and get a 502.
You can work around it by provisioning larger functions and setting low concurrency, but now you're paying more per invocation and you've effectively serialized your invoice endpoint.
3. Font drift
The browser you're running doesn't have the same fonts as your dev laptop. What you test with Helvetica may render with Liberation Sans in production. If your invoice has Inter, and you don't register it manually, the production output will silently fall back.
The fix is to preload fonts:
await page.addStyleTag({
content: `
@font-face {
font-family: "Inter";
src: url(${fontUrl}) format("woff2");
}
* { font-family: "Inter", sans-serif; }
`,
});
await page.evaluateHandle("document.fonts.ready");But you still have to ship the font file, host it somewhere the browser can fetch it, and remember to do this every time. Miss a step, silent fallback.
4. Pagination lies
CSS page break rules like page-break-inside: avoid are advisory. Chrome honors them most of the time. But put a long table with rowspans at the bottom of a page, and Chrome will quietly split it anyway.
You'll know when a customer emails you a screenshot of an invoice where the total appears on one page and "THIS IS NOT A BILL" appears on the next.
5. Background colors get dropped
page.pdf({ printBackground: false }) is the default. Your beautiful card borders, subtle backgrounds, brand accent bars? All white unless you remember to set printBackground: true. One forgotten flag and the PDF looks like a Word doc from 2003.
The benchmark
We ran the same invoice through Puppeteer and through @react-pdf/renderer, both on Vercel hobby, same region, same payload. The invoice had a header, a key-value block, a 12-row table, totals, and a signature block.
| Renderer | Cold start | Warm render | p99 latency | Memory peak |
|---|---|---|---|---|
| Puppeteer + Chromium | 840ms | 520ms | 1.8s | 580 MB |
| react-pdf | 180ms | 65ms | 320ms | 90 MB |
Same design. 8x faster warm. 3x lower p99. 6x less memory. No font surprises.
And the react-pdf output was 40% smaller on disk, because Chromium's PDF writer is not optimized for file size.
"But HTML-to-PDF is easier"
This is the real reason people reach for it. You already have HTML in the app, and translating it to react-pdf feels like work.
Here's the honest trade-off. For the first invoice, HTML-to-PDF takes half the time. For the tenth, they're the same. For the hundredth, react-pdf is faster because you've built a component library and you're composing.
The moment you have more than one document type (invoice AND receipt AND report), react-pdf pulls ahead because headers, footers, tables, and typography styles become reusable components. With HTML-to-PDF you end up maintaining two CSS stacks: the one for your app and the one for your documents. That never pays.
When HTML-to-PDF is actually fine
- You're printing a blog post once in a while.
- The document is a single page of mostly text.
- You have one flavor of document and no plans to add more.
- Volume is under 50 a day.
For anything else, do the work. Move to react-pdf. It pays back within a sprint.
A migration path that doesn't hurt
If you already have Puppeteer invoices in production, you don't need to rip them out. Instead:
- Identify your most-rendered document. If it's invoices, start there.
- Build the react-pdf version as a sibling route. Don't delete the Puppeteer one.
- Feature-flag the endpoint so that some % of traffic goes to the new renderer.
- Compare. Font fidelity, pagination correctness, file size, latency. Easy to measure.
- Flip the flag when it's clearly better.
- Repeat for the next document type. Retire the Puppeteer endpoint when the last one moves.
Most teams finish in a couple of weeks. One team got to "Chromium retired" in five days because they'd been suffering long enough to move fast.
Summary
- Puppeteer invoices look fine until you count cold starts, memory, and font drift.
- react-pdf is 8x faster warm and 6x more memory-efficient for the same output.
- The "easier" label on HTML-to-PDF expires after the first document type.
- Migrate one endpoint at a time behind a flag. No big bang needed.
Your invoice endpoint is not where you want to burn serverless minutes.
Build one of these yourself.
Start free at pdfxbuilder.com. Three templates, no credit card.
Open PDFx Builder