Why PDF Generation Is Hard (and How to Get It Right)
PDF generation is one of those problems that sounds completely solved. Libraries exist, documentation exists, Stack Overflow answers exist. You reach for one, write twenty lines of code, and it works. Then you deploy it, and discover that “it works” and “it works reliably” are different things entirely.
This is a writeup on how I approached the problem when building MyPdfBoy, a small TypeScript service I use for invoice generation, contract templates, and report exports across several projects.
Why the naive approach fails
The simplest possible PDF generation in Node.js is calling a library that converts HTML to PDF directly. It works for simple cases. It breaks down because these libraries typically use older, lighter rendering engines that do not implement the full CSS specification, and they do not have access to the system fonts your OS has installed.
The result: a document that looks correct on your machine produces a different layout in a Docker container on a Linux server, which produces a different layout in CI, which produces yet another layout in production. Not wildly different, usually — shifted elements, slightly wrong line heights, missing glyphs. Enough to be wrong. Enough to matter when you are generating invoices or legal documents.
The second failure mode is dynamic content. When the content driving the PDF is variable — customer data, line items, computed totals — page layout is not fixed. A table with five rows fits fine. A table with fifty rows needs pagination. Libraries that work well for static templates often produce garbled output or crash when content overflows a page boundary unexpectedly.
The headless Chrome approach
The answer most people arrive at eventually is headless Chrome. Instead of a rendering engine embedded in a library, you are using an actual browser to render actual HTML, then capturing the output as a PDF. The rendering is authoritative because it is the same engine that rendered the page in the first place.
Puppeteer (or Playwright, which uses the same Chromium engine) is the standard way to drive this. The basic setup is straightforward:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });const page = await browser.newPage();await page.setContent(htmlString, { waitUntil: 'networkidle0' });const pdf = await page.pdf({ format: 'A4', printBackground: true });await browser.close();This is already more reliable than library-based approaches because the rendering is consistent everywhere Chromium runs. CSS grid, flexbox, web fonts, complex selectors — all of it works exactly as it does in a browser.
But this code has production problems.
The operational issues
Cold starts are slow. Launching a fresh Chrome process takes 1-3 seconds depending on the machine. If your service handles requests one at a time, each request waits for that launch. For a user waiting on a download, that latency is noticeable.
The fix is a browser pool — keep a small number of Chrome instances warm and ready, check one out when a request arrives, return it when the render completes. In TypeScript, generic-pool works well for this. The tradeoff is memory: each Chromium instance uses around 100-200MB. A pool of three covers most traffic patterns without excessive overhead.
import genericPool from 'generic-pool';import puppeteer from 'puppeteer';
const pool = genericPool.createPool({ create: () => puppeteer.launch({ headless: true, args: ['--no-sandbox'] }), destroy: (browser) => browser.close(),}, { min: 2, max: 5 });Renders can hang. If the HTML being rendered has an infinite loop in a script, or waits on a resource that never loads, the page never finishes and the process never returns. Without protection, a single bad template blocks a pool slot indefinitely. You need both a per-render timeout and a mechanism to kill and replace the offending browser process when the timeout fires.
const renderWithTimeout = async (html: string, timeoutMs = 10_000): Promise<Buffer> => { const browser = await pool.acquire(); try { const page = await browser.newPage(); const result = await Promise.race([ renderPage(page, html), new Promise<never>((_, reject) => setTimeout(() => reject(new Error('render timeout')), timeoutMs) ), ]); await page.close(); return result; } finally { await pool.release(browser); }};When a timeout fires, you should also mark that browser instance as damaged and destroy it rather than returning it to the pool. A browser that timed out once may be in a bad state.
Output validation matters. A rendered PDF that is structurally corrupt will cause downstream failures that are hard to trace back to the generation step. Running a minimum-viable check before returning the buffer — at minimum, checking that it starts with %PDF- and is larger than a few kilobytes — catches the cases where rendering silently produced garbage.
Template management
The rendering infrastructure solves the reliability problem. What it does not solve is where the templates come from or how they stay consistent over time.
The approach I use is simple: templates are versioned HTML files stored alongside the service. A template ID in the request maps to a specific file. Templates include all styles inline or via a bundled CSS file — no external CDN references, no relative paths that could break. This makes templates portable and renders reproducible.
templates/ invoice/ v1.html v2.html <- current contract/ v1.htmlA request that specifies template: "invoice" gets the latest version. A request that specifies template: "invoice@v1" gets the pinned version — useful when you need to reproduce a document generated months ago.
Common pitfalls
waitUntil: 'networkidle0' is too slow for templates with no external resources. If your templates are self-contained, use domcontentloaded instead. It is significantly faster and safe when there is nothing external to wait for.
Page margins interact with headers and footers in non-obvious ways. Puppeteer’s headerTemplate and footerTemplate options render in a separate context. Margins must be large enough to accommodate them, and styles do not inherit from the main document. Set font size explicitly on header/footer elements.
--no-sandbox is required in most containerized environments but is a real security consideration. Chromium’s sandbox provides meaningful protection against malicious HTML. If you are rendering user-supplied content, running Chromium in a dedicated low-privilege container (or a VM) matters. If you control all templates, the risk is lower.
Page breaks in dynamic content. CSS page-break-before, page-break-after, and page-break-inside: avoid work in Chromium’s print mode. For table rows or list items that should never be split across pages, page-break-inside: avoid on the row or item does what you want.
The result
The service handles all PDF output for a handful of internal projects without incident. The API surface is small — one endpoint, a template name, a data payload. The reliability comes from the operational layer: warm browser pool, enforced timeouts, output validation, self-contained templates.
The full project is private, but the engineering decisions above apply to any implementation of the same pattern. If you are building something similar and running into specific issues, reach out.