Skip to content
Back to blog
engineering pdf generation architecture

Why We Don't Render PDFs With a Headless Browser

pdfs.build Team

Almost every “HTML to PDF” service is a headless browser with an API in front of it. You send markup, a Chrome process renders it, page.pdf() prints the result, and you get a file back. It works, it is straightforward to build, and it is what most of the category does.

pdfs.build does not do that. Documents here are compiled by a typesetting engine — software built to lay out pages, not screens — and the difference shows up in exactly one place: what happens when your data is bigger than the sample you designed against.

The problem with printing a web page

A browser is a screen renderer. Screens are one continuous surface: infinitely tall, reflowing, scrollable. Nothing in that model has to answer the question a document has to answer constantly — what happens at the bottom of page 3?

Print CSS bolts an answer on afterwards. break-inside: avoid, page-break-after, @page margins: a second layout system layered onto one that was never designed for pages. It mostly works. Then a customer with ninety line items arrives, the totals block splits across a page break with the subtotal on one page and the tax on the next, and you are reading CSS specifications at eleven at night.

The failure mode is the tell. Browser-rendered documents do not break loudly — they break plausibly, on one invoice in two hundred, in a way nobody notices until a customer asks why their bill looks like that.

There are second-order costs too. You are running a browser in production, so concurrency is bounded by RAM rather than by anything about the document. Cold starts dominate render time in serverless environments, which is why browser pools and warm-up hacks exist. And browser updates change text metrics, so a document that has to look identical this quarter and next inherits that drift.

What a typesetting engine does differently

Typesetting systems have been solving the page problem for decades, because paper was the only output they ever had. Four things follow from that.

Pagination is the model, not an afterthought. The engine does not lay out a continuous surface and then slice it — it composes pages. Concepts a browser bolts on afterwards, like a block that must not split, a table header that repeats on every page, or a footer pinned to the bottom, are ordinary parts of the layout language rather than hints you hope the renderer honours.

Layout is programmable. Templates take data and make layout decisions with it — loops, conditionals, computed values. You are not string-concatenating markup and hoping the escaping holds.

Compilation is cheap. A render is milliseconds of CPU, not the seconds a browser needs to boot, navigate and paint. Nothing has to be pooled or kept warm.

Output is deterministic. The same template and the same data produce the same document. There is no upstream browser release that can shift your text metrics between one quarter and the next.

What this looks like in practice

Every design in our template gallery ships with a stated contract for reflow: what repeats per record, what grows to absorb long values, what holds its position across page breaks, what may split and what must not, and how the last page is allowed to end.

That is not documentation written after the fact. It is a property of how the document is built, which is why it can be written down at all. Ask a browser-rendered template what happens when the line-item table runs to a fourth page and the honest answer is “whatever the CSS does”. Ask a page-composed template and the answer is in the template.

The practical version: an invoice designed against three line items behaves the same at ninety. The table header repeats, the totals panel moves as a unit rather than splitting, long descriptions push the table down instead of overflowing their column, and payment terms land after the totals wherever that turns out to be.

What you give up

This is a real trade, and it is not the right one for everyone.

No JavaScript, and no arbitrary HTML. If your document is a web page — you are archiving live pages, or the layout depends on scripts running at render time — a browser is the correct tool and nothing else will do. We said the same thing on our page about moving off Puppeteer.

A different model to learn. Page composition is not HTML and CSS. The concepts transfer; the syntax does not. Our answer is that you mostly should not have to write it — templates are edited through an assistant that reads and writes the template, its schema and its sample data — but underneath it is genuinely a different system.

A smaller ecosystem. The browser platform is the most heavily invested-in rendering target in software. Nothing else has that surface area. For business documents the gap rarely comes up; for anything that leans on a specific web API, it will.

Pixel parity with a browser is impossible. If the requirement is that the PDF match what a Chrome user sees on screen exactly, only Chrome does that.

Why this ends up mattering for an API

Anything that generates documents from data eventually hits the same wall. It is not hard to render one document. It is hard to render the hundred-thousandth one correctly, when the data has drifted further from the sample than whoever designed it imagined.

Choosing an engine built for pages moves that class of bug from runtime to design time. The template states what it does under pressure, and you can read that before you ship rather than discovering it in a support ticket.

If you would rather see the output than take our word for it, every design in the gallery shows the real compiled document — every page of it, not a mockup — and the API reference covers rendering one from your own JSON.

Back to blog