DEV Community

Cover image for Everything That Breaks When You Generate PDFs with Headless Chrome in a Container
scubaDEV
scubaDEV

Posted on

Everything That Breaks When You Generate PDFs with Headless Chrome in a Container

Server-side PDF generation with headless Chrome is the kind of feature that demos perfectly and then ambushes you in production. The "how to render a PDF with a headless browser" tutorials all stop exactly where the real problems start: the container, the fonts, the hangs, the bill. This is the operational minefield, one mine at a time. Each of these cost me real debugging hours.

1. The distro's Chromium package is a trap

On a Debian/Ubuntu container image, installing the chromium package often gives you a snap stub that simply won't launch in a headless container. The error is unhelpful and the fix is non-obvious: install actual Google Chrome from Google's official apt repository, at image build time, and point your driver at it explicitly:

# install Chrome from Google's repo at BUILD time, never at runtime
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
 && echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" \
    > /etc/apt/sources.list.d/google-chrome.list \
 && apt-get update && apt-get install -y google-chrome-stable
ENV CHROME_PATH=/usr/bin/google-chrome
Enter fullscreen mode Exit fullscreen mode

Doing this at runtime instead means your first request after every cold start pays a download, or fails offline. Bake the browser into the image.

2. Fonts aren't ready when you think they are

This one produced wrong output, not a crash, which is worse. If you measure content height to decide page scaling before the fonts have loaded, you measure against fallback metrics and your scaling is off by 10–15%. The page looks subtly wrong and you blame your CSS.

The fix is to wait for the browser to tell you fonts are ready before you measure anything:

await document.fonts.ready;   // only now is it safe to measure layout
Enter fullscreen mode Exit fullscreen mode

3. A hung Chrome must never hold a slot forever

Headless Chrome can hang. When it does, the danger isn't the one stuck render — it's that the stuck render holds a concurrency slot indefinitely and slowly starves everything behind it. You need two guards: a hard per-render timeout, and a bounded pool so a bad render can't take the whole service down.

private static readonly SemaphoreSlim _renderPool = new(2, 2);   // max concurrent
private const int RenderTimeoutMs = 60_000;                      // hard ceiling

await _renderPool.WaitAsync();
try
{
    using var cts = new CancellationTokenSource(RenderTimeoutMs);
    await RenderAsync(html, cts.Token);   // killed if it overruns
}
finally { _renderPool.Release(); }
Enter fullscreen mode Exit fullscreen mode

4. The same binary path won't exist everywhere

Chrome lives at one path in your container, another on a developer's laptop, and maybe nowhere on a fresh CI box. Hardcoding the path makes the code run in exactly one environment. A three-tier resolution chain lets one codebase run everywhere: honor an explicit environment variable first, auto-detect a known host install second, and fall back to downloading a managed browser only as a last resort.

5. The cost nobody budgets for

Here's the one that didn't show up in any tutorial: moving rendering from the client's browser to your server means you now run a full browser per render. In our case it roughly tripled the CPU and memory the service needed. The feature was free when the client's machine paid for it. Server-side, it's a line item. Size your pods accordingly before you ship, not after the autoscaler panics.

The shape of the lesson

None of these are about Chrome's API. They're about the gap between "renders a PDF on my machine" and "renders PDFs reliably for everyone." Bake the browser into the image, wait for fonts before measuring, never let a hung render hold a slot, resolve the binary by environment, and budget for the compute you just moved onto your own servers. The happy-path code is the easy 20%. This is the other 80%.

Top comments (0)