Engineering8 min read

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:

  1. Identify your most-rendered document. If it's invoices, start there.
  2. Build the react-pdf version as a sibling route. Don't delete the Puppeteer one.
  3. Feature-flag the endpoint so that some % of traffic goes to the new renderer.
  4. Compare. Font fidelity, pagination correctness, file size, latency. Easy to measure.
  5. Flip the flag when it's clearly better.
  6. 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