DEV Community

Cover image for Why Server-Side PDF Generation Is Harder Than It Looks
Simon Briggs
Simon Briggs

Posted on

Why Server-Side PDF Generation Is Harder Than It Looks

Three days before launch, our "generate invoice PDF" endpoint worked perfectly. Every test invoice rendered cleanly, the totals lined up, the logo sat exactly where design wanted it. Then we deployed to staging, ran 40 invoices through it concurrently, and watched half of them come back with missing line items, a font that looked like Comic Sans wearing a Helvetica costume, and one PDF that was just... blank. No error. No stack trace. Just 340KB of nothing.

That was my first real lesson in what "generate a PDF" actually means once it leaves your laptop. It sounds like a solved problem. HTML in, PDF out, done by lunch. It isn't, and the gap between "works on my machine" and "works under load, on every OS, for every input" is where most PDF generation pipelines quietly go to die.

Here's what actually trips people up, and what to do about each one.

Fonts are not guaranteed to exist where you think they do

If you're rendering HTML/CSS to PDF (Puppeteer, Playwright, wkhtmltopdf, WeasyPrint), your renderer needs the actual font files installed on the machine doing the rendering. Your dev laptop has macOS system fonts. Your Docker container almost certainly doesn't.

# This is the line that fixes 80% of "why does my PDF
# look different in prod" tickets
RUN apt-get update && apt-get install -y \
   fonts-noto fonts-noto-cjk fonts-liberation \
   fontconfig && fc-cache -f -v
Enter fullscreen mode Exit fullscreen mode

Skip this and your renderer silently substitutes a fallback font, usually something narrower or wider than what you designed against. That's how "aligned columns" become "columns that drift by 4px per row and look terrible by row 30." If you support non-Latin text (Arabic, CJK, Cyrillic), you need those font families explicitly installed too, or you'll get boxes instead of characters.

Pagination is not a solved problem in HTML

Browsers were built to scroll, not to paginate. page-break-inside: avoid is a suggestion, not a law, and headless Chromium will still slice a table row or a card in half if the content above it doesn't leave enough room. CSS Paged Media (the spec that actually defines headers, footers, page counters) has patchy support depending on your renderer.

If your PDFs need real pagination logic (running headers, "Page 3 of 12," a table that repeats its header row on every page), you're often better off with a purpose-built PDF library that thinks in pages from the start, rather than fighting a browser engine into doing it:

// PDFKit thinks in pages natively, no CSS negotiation required
const doc = new PDFDocument({ margin: 50 });
doc.on('pageAdded', () => {
 doc.fontSize(8).text(`Page ${doc.bufferedPageRange().count}`, 50, 20);
});
Enter fullscreen mode Exit fullscreen mode

It's less pretty to write than HTML/CSS, but it's predictable, and predictable is what you want at 2 am when a customer's contract PDF has an orphaned signature line.

Headless browsers are heavy, and they leak

If you went the Puppeteer/Playwright route, you now have a full Chromium process per render, and Chromium does not always release memory the way you'd hope. Spin up a fresh browser instance per request under load, and you'll watch your container's memory graph climb until Kubernetes kills the pod mid-render.

The fix is a browser pool with a hard render timeout and a recycle limit, not a fresh instance every time and not one instance forever:

const pool = genericPool.createPool({
 create: () => puppeteer.launch({ args: ['--no-sandbox'] }),
 destroy: (browser) => browser.close(),
}, { max: 4, min: 1 });

async function renderPdf(html) {
 const browser = await pool.acquire();
 try {
   const page = await browser.newPage();
   await page.setContent(html, { waitUntil: 'networkidle0', timeout: 10000 });
   return await page.pdf({ format: 'A4', printBackground: true });
 } finally {
   await pool.release(browser);
 }
}
Enter fullscreen mode Exit fullscreen mode

Recycle each browser instance after, say, 50 renders. It costs a bit of latency and saves you from the 3 am page.

Large PDFs will blow up your memory if you buffer everything

Building a 200-page report by holding the entire document in memory, then writing it out at the end, works fine in testing with your 5-page sample file. It does not work well when someone requests a year's worth of transaction history. Stream where your library supports it, and set explicit limits on input size and page count before generation even starts. A generation endpoint with no upper bound is a denial-of-service vector waiting for someone to notice.

Rendering HTML from untrusted input is an SSRF risk, not just a formatting risk

If your PDF generator loads external resources (images by URL, remote CSS, an <iframe>), and any part of that HTML comes from user input, you've built a way for someone to make your server issue requests on their behalf. Headless browsers happily fetch http://169.254.169.254/... if you let them. Lock down outbound requests from the render process, or generate PDFs from server-rendered, sanitized templates instead of raw user HTML.

Where a plain conversion tool actually earns its keep

Most of the pain above is specific to generating a PDF from scratch, code, templates, dynamic data. It's a different problem from converting a document someone already has. I keep both in the toolbox: PDFKit or a headless-browser pipeline for the invoices and reports my app generates on demand, and for the one-off "a client sent me a Word doc, I need it as PDF for the audit trail" tasks, I just reach for something like PDF Conveter instead of writing a conversion script I'll maintain forever for a task that happens twice a month.

Building your own generation pipeline makes sense when PDFs are a core part of your product. It's overkill when you just need a file converted once and moved on.

If you're mid-build on something like this and hitting one of the walls above, happy to compare notes in the comments. There's always another edge case waiting.

Top comments (0)