Invoice PDFs from HTML: A Practical Look at Puppeteer's page.pdf()
Rendering your existing HTML in headless Chrome beats rebuilding layout in a PDF library — if you handle print styles, readiness, and browser memory.
01 Feb 2026, 15:33 UTC

Two ways to build a server-generated PDF
Sooner or later, most products need to emit a PDF: invoices, monthly statements, tickets, certificates. You have two realistic paths. You can adopt a PDF library such as PDFKit or jsPDF and rebuild the layout in a new coordinate system. Or you can take the HTML and CSS you already have, render it in headless Chrome, and call page.pdf().
The second path is Puppeteer's quiet superpower. page.pdf() drives the same print pipeline you hit when you press Ctrl+P in Chrome, so the CSS you already maintain does the layout work. The thesis here: if your source of truth is HTML, Puppeteer is usually the pragmatic choice — provided you handle rendering readiness and treat the browser as a finite resource.
The worked example: an invoice from HTML
Setup: a Node project with Puppeteer installed. Run npm i puppeteer in the project root — the first install downloads a Chrome build pinned by your Puppeteer version, so rendering stays consistent until you deliberately upgrade (budget disk space and network for that download). Then run the script with node generate-invoice.js; it needs write permission in the working directory because it writes the PDF there.
// generate-invoice.js — run from the project root: node generate-invoice.js
const puppeteer = require('puppeteer');
// In a real service this comes from your template engine;
// the key detail is an element you can wait on, here #totals.
const invoiceHtml = `
<html><body>
<header style="background:#1a3c6e;color:#fff;padding:24px">
<h1>Invoice #1042</h1>
</header>
<table>/* line items */</table>
<p id="totals">Total due: $1,240.00</p>
</body></html>`;
async function main() {
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(invoiceHtml, { waitUntil: 'networkidle0' });
await page.waitForSelector('#totals', { visible: true });
// Chrome prints with "print" media styles by default.
// If your stylesheet is screen-scoped, switch it:
await page.emulateMediaType('screen');
await page.pdf({
path: 'invoice-1042.pdf',
format: 'A4',
printBackground: true, // default is false: backgrounds silently vanish
scale: 0.9,
margin: { top: '18mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
} finally {
await browser.close();
}
}
main();What each knob does: format sets A4 paper (use width/height, or preferCSSPageSize: true with @page rules, for custom sizes); scale shrinks or enlarges content within a documented 0.1–2 range; margin accepts CSS lengths.
Verify the result rather than assuming it worked. You should find invoice-1042.pdf in the project root; open it and check three things — the blue header band is visible (proves printBackground took effect), the totals line is populated (proves the waitForSelector wait beat font and image loading), and the page count matches expectations. A file that exists but is missing backgrounds is the classic silent failure.
The gotchas that produce blank or ugly PDFs
printBackgrounddefaults tofalse, so background colors and images disappear with no error or warning.- Printing uses
printmedia styles; if your CSS lives under@media screen, callemulateMediaType('screen')before generating. - Web fonts and images load asynchronously.
waitUntil: 'networkidle0'plus awaitForSelectoron the last-rendered element is cheap insurance against half-drawn pages. displayHeaderFootertemplates run in a restricted context without access to your page's CSS or scripts — inline all styles directly in the template string.
The APIs used here — setContent, pdf, emulateMediaType, waitForSelector — have been stable across recent Puppeteer major versions, but confirm the option names against the documentation for your installed version before shipping.
The trade-off: you are running a browser in production
Every open page is a full renderer with real memory cost. Keep dozens alive on a small VM and the out-of-memory killer will eventually find you. The habits that keep this sane:
- Launch one browser per process and reuse it. A fresh
puppeteer.launch()per request adds measurable startup time — often hundreds of milliseconds or more. - Close pages in a
finallyblock, always. Leaked pages are the usual cause of slow memory creep in long-running services. - Cap concurrency: push jobs through a queue with a small pool of pages rather than opening one per incoming request.
Version pinning matters too. Puppeteer pairs with a specific Chrome build, and upgrades can shift font metrics and page-break behavior enough to change your output. Pin the dependency, and keep a smoke test that generates a known invoice and asserts the page count plus a known string — a text-extraction tool such as pdftotext handles the string check.
Where page.pdf() stops being the right tool
It prints; it does not screenshot. If you need a pixel-perfect visual snapshot, page.screenshot() is the tool — but you get an image, not selectable text or vectors. And because page.pdf() goes through the print pipeline, some screen-oriented CSS behaves differently: position: fixed elements can repeat or shift between pages, and viewport units resolve against the print viewport rather than a browser window. Budget time to test layouts with real content, not placeholder text.
What to do next
If you are evaluating this for a service: prototype with one real invoice template, pin your Puppeteer version, put page cleanup and browser.close() in finally blocks from day one, and add a CI check that renders a sample PDF and verifies page count and key text. Those four habits cover most of the ways HTML-to-PDF pipelines fail in practice.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.