Kadiaak
Production Pdf Puppeteer

Turning HTML into a PDF is harder than it looks

A short tour of every HTML to PDF approach we tried, the ways each one failed in production, and why we ended up running the pipeline as a product for our clients.

Martin Martin 5 min read

Generating a PDF from HTML sounds like a solved problem. You have a web page, you want a document, surely there is a button for that. We thought so too. Then we shipped a few invoicing and reporting features for clients, and HTML to PDF turned into one of the most stubborn corners of our work. Here is the tour, in roughly the order we lived it.

Attempt one: just print the page

The first idea is always the cheapest. Add a print stylesheet, call window.print, and let the browser do the work. This is fine for a human who wants a copy of one page. It falls apart the moment you need it automated, identical for every user, and generated on the server without anyone clicking anything. A print dialog is not an API.

Attempt two: the server-side libraries

So we reached for the usual suspects: Dompdf, mPDF, TCPDF. They are clever pieces of software, and for a simple invoice with a table and a logo they can be enough. The trouble starts when the design uses anything modern. Flexbox and grid are mostly ignored. Web fonts need manual wrangling. A layout that looks perfect in the browser arrives in the PDF with collapsed columns and the wrong line breaks. You end up maintaining a second, stripped-down stylesheet that exists only to please the PDF engine, and every design change means touching it twice.

Then there was wkhtmltopdf. For a while it was the pragmatic favourite, because it rendered with a real engine. The catch is that the engine was a very old WebKit, the project went quiet, and our reports slowly drifted away from what the library could draw. Compiling it into a modern container was its own small adventure.

Attempt three: headless Chrome with Puppeteer

If you want a PDF that matches the browser, the honest answer is to use a browser. So we moved to headless Chrome, driven by Puppeteer (and Spatie Browsershot in our Laravel apps, which wraps the same idea). Suddenly the output was right. Flexbox worked, grid worked, our actual stylesheet worked, and page breaks could be controlled with print CSS and the @page rule.

This is where most write-ups stop and assume the story is over. It is not. Running a real browser in production is where the real work begins.

The part nobody warns you about

Chrome is hungry. Each render spins up a browser context that wants real memory, and under load those contexts stack up fast. We once watched a small reporting service quietly climb past a gigabyte of RAM during a Monday morning export rush, because every request held a tab open a little too long.

Then the failure modes arrived one at a time. A crashed render that left a zombie Chrome process behind, and a few hundred of those over a weekend that slowly ate a server. Web fonts that had not finished loading when the snapshot was taken, so the first page used a fallback face and the rest did not. Emoji that rendered as empty boxes until we installed the right font package in the container. Headers, footers, and page numbers, which Puppeteer supports through a fiddly template system with its own rules and its own surprises. Large documents that timed out at the worst possible moment, on the one invoice that actually mattered.

None of these are hard on their own. Together, on a Friday afternoon, they are a tax you pay forever. Rendering HTML to PDF reliably is not a library choice. It is an operations problem wearing a print stylesheet.

Why we turned it into a product

After the third client hit the same wall, the pattern was obvious. Everyone wanted the same thing: send some HTML or a URL, get a clean PDF back, and never think about Chrome again. We already had the unglamorous parts solved. A warm pool of browser instances, so there is no cold start on every call. A queue that smooths out spikes instead of melting under them. Sane defaults for fonts, margins, and page sizes. Caching for documents that do not change. An API that fails loudly and clearly when something is wrong, instead of returning a blank page.

So we packaged it. URL to Doc is that pipeline, run as a service, so our clients get the fidelity of real Chromium without operating a browser farm of their own. It is the same building block we trust in our own products, offered as an endpoint. That is the whole pitch: the boring, battle-tested plumbing, so your team can go back to building the feature that actually needed a PDF.

We also put real work into how it feels to use, on both sides. The product is built so a non-developer can produce a clean document without reading a manual, and the developer experience is the kind we wanted for ourselves: predictable parameters, honest error messages, a quickstart that works in minutes, and SDKs that stay out of the way. Good UX and developer experience are not decoration here, they are what turns a tool people try into a tool people keep.

If you are fighting this exact problem, we are happy to compare notes. Write to us at hi@kadiaak.com, or follow along in the newsletter, where we open the workshop once a month.

The newsletter for builders.

One email a month: the tools, patterns, and production lessons behind what we ship.

Get the newsletter

Keep reading