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

It's 2 AM, and your on-call phone is buzzing. The invoice generation service that passed every test in staging is now throwing errors in production. Customers can't download their receipts. You SSH into the container, check the logs, and find the culprit: Error: Failed to load font "Helvetica Neue". The font exists on your laptop. It does not exist on the Docker image running in production. Nobody thought to check, because nobody expected a PDF to care.

This is the moment most developers meet the real complexity of server-side PDF generation. On paper, it looks like a solved problem: take some data, run it through a library, get back a file. In practice, PDFs sit at an uncomfortable intersection of typography, layout engines, binary file formats, and server infrastructure, and each of those layers has its own way of breaking your afternoon.

The Font Problem Nobody Warns You About

Fonts are the single most common source of "it worked on my machine" bugs in PDF generation. Your local development environment has a full desktop OS with dozens of installed fonts. Your production server, especially if it's a slim Docker image based on Alpine or a minimal Debian build, has almost none.

If your PDF library falls back silently when a font is missing, you get inconsistent output: text that looks fine locally renders in a generic fallback font in production, quietly breaking your brand guidelines. If it fails loudly, you get outages like the one above.

The fix isn't glamorous, but it's non-negotiable: bundle the fonts you need directly into your build, and pin the exact font files rather than relying on font family names that might resolve differently across environments.

# Don't rely on system fonts being present
COPY ./fonts /usr/share/fonts/custom
RUN fc-cache -f -v
Enter fullscreen mode Exit fullscreen mode

Headless Browsers Are Not Free

A huge share of modern PDF generation runs through headless Chromium via Puppeteer or Playwright, because it's the easiest way to get accurate CSS rendering. This works beautifully in a demo and becomes a resource management problem at scale.

Each browser instance can consume 100–300MB of memory, and spinning one up per request is a fast route to an out-of-memory crash under load. The common mistake is treating page.pdf() like a stateless function call instead of what it actually is: launching and tearing down an entire browser process.

// This pattern will eventually take down your server under concurrent load
app.post('/generate-pdf', async (req, res) => {
 const browser = await puppeteer.launch();
 const page = await browser.newPage();
 await page.setContent(req.body.html);
 const pdf = await page.pdf({ format: 'A4' });
 await browser.close();
 res.send(pdf);
});
Enter fullscreen mode Exit fullscreen mode

A browser instance pool, with a hard cap on concurrent renders and a queue for overflow requests, is what actually survives production traffic. It's more code, but it's the difference between a feature and an incident.

Layout Engines Don't Think in Pages

Browsers were built to render infinite scrolling documents, not fixed-height pages. The moment you ask a browser-based renderer to paginate content, you inherit every quirk of how CSS handles page breaks: tables that split awkwardly mid-row, images that get sliced in half, and headers that repeat on every page whether you want them to or not.

break-inside: avoid and page-break-before help, but they're inconsistently supported depending on which rendering engine sits underneath your library. Testing a single template against a handful of edge cases (a table with 200 rows, a table with one row, an empty state) will save you from discovering these issues from an angry customer email instead.

The Async Timing Trap

This one catches almost everyone at least once. If your HTML content loads data asynchronously, fetches an image from a CDN, or renders a chart with JavaScript, your PDF renderer might snapshot the page before any of that finishes loading. The result is a PDF with blank spaces where charts should be, and it's maddening to debug because it happens intermittently, usually correlating with server load rather than anything in your code.

// Race condition waiting to happen
await page.setContent(html);
const pdf = await page.pdf();

// Better: wait for network activity to settle
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf();
Enter fullscreen mode Exit fullscreen mode

Even networkidle0 isn't bulletproof for JS-rendered charts, so many teams add an explicit signal, like a window.renderComplete = true flag set after their chart library finishes drawing, and poll for it before capturing the page.

File Size Creeps Up Silently

A PDF with embedded high-resolution images and full font files can balloon in size fast, which matters when you're emailing invoices or serving downloads to users on slow connections. Compressing images before embedding them and subsetting fonts (including only the glyphs you actually use, not the entire character set) can cut file sizes by 60–80% with no visible quality loss. It's an easy win that most teams skip until someone complains about a 40MB invoice.

Where a Simpler Tool Actually Helps

Not every PDF need justifies a server pipeline. A good chunk of the PDF work developers get asked for is one-off: convert a report to a different format, merge a couple of files, compress something before sending it. Standing up and maintaining a rendering service for that kind of task is overkill. For those cases, I've gotten into the habit of pointing teammates to PDF Conveter, a free browser-based toolkit that handles conversions, merging, and compression without needing infrastructure or code. It's not a replacement for a proper generation pipeline when you're producing PDFs programmatically at scale, but it removes a surprising amount of one-off busywork that would otherwise land on an engineer's plate.

The Real Lesson

Server-side PDF generation looks like a library integration problem until you're running it in production. Then it becomes a font management problem, a memory management problem, a layout engine quirks problem, and an async timing problem, all wearing the same trench coat. None of these are individually hard to solve, but each one is easy to miss until it costs you a 2 AM page.

The teams that get this right treat PDF generation the same way they'd treat any other stateful, resource-intensive service: with pooling, monitoring, explicit timeouts, and tests that check actual rendered output, not just that the function didn't throw. Do that upfront, and the next 2 AM page will be about something else entirely.

Top comments (0)