Skip to main content

page.pdf() is not the hard part

puppeteer.launch(), page.setContent(html), page.pdf(). Three lines, and the file that comes out is genuinely good: it is Chromium's own print pipeline, so your CSS renders the way it renders in the browser you tested in.

That is not a starter approach to be talked out of. It is the correct call for a script, an internal report, a batch job someone runs on a Monday. This article is about what changes when the same three lines become a feature with a queue in front of them, and about which of the things people buy an API for you can straightforwardly do yourself.

Chrome is a process you now operate

A render is a browser. Under any real load that means a pool of browsers, and a pool of browsers is a supervision problem: memory ceilings, restart policy, crash recovery, and a watchdog so one pathological page does not take the host with it. A tab that hangs on a never-resolving promise holds its memory until something kills it, and processes that exit badly leave children behind.

Containers add their own edge. Chromium uses shared memory heavily and the default /dev/shm in a container is small enough that renders fail under concurrency in a way that looks like random flakiness. Either give the container more shared memory or tell Chromium not to use it. Running as root in a container also pushes people toward disabling Chromium's own sandbox, which is the one mitigation you least want to give up once untrusted HTML is in scope.

None of this is hard. All of it is yours, forever, including on the Friday the base image bumps Chromium a major version.

Concurrency is where the one-liner stops being one line

Launching a browser per request is too slow, so you keep warm instances. Now you own pool sizing, back-pressure when more requests arrive than you have slots, per-render timeouts, and what the caller sees when the pool is saturated. Reject, queue, or block are three different products.

You also own the interaction between that and your web framework. A render is CPU-heavy and multi-second; sharing an event loop with your API means one document delays every other request on the box.

Fidelity is a long tail, and fonts are most of it

The demo works because the demo runs on your laptop, which has your fonts.

  • The server has none of them. Text silently falls back and reflows, which changes pagination, which changes where things break.
  • Emoji and non-Latin scripts need font packages installed deliberately. Missing glyphs render as boxes, and nothing errors.
  • Backgrounds and box shadows do not print unless you ask for them. printBackground is the single most common surprise.
  • @page size and margins, page-break-inside, and running headers and footers with page numbers each behave differently from screen CSS and need their own pass.
  • Waiting for the network to go idle is not the same as waiting for layout to settle. Web fonts and lazily-loaded images can land after idle, so snapshot too early and you capture a half-drawn page. Waiting on font readiness and on the images you actually care about is more reliable than any single network heuristic.

Each item is an afternoon. Together they are weeks, and they come back every time a designer touches a template.

Untrusted input is a security surface, not a validation problem

The moment a caller can supply a URL or HTML, the renderer is a fetch primitive inside your network. It will follow a link to a cloud metadata endpoint, to an internal admin service, to file:// paths on the host. It executes arbitrary JavaScript by design, because that is what a browser is.

The mitigations are real work and mostly not application code: egress restrictions that default to deny, a genuine sandbox, memory and CPU limits, and a redirect policy that re-checks the destination after every hop rather than only the URL the caller passed. An allowlist implemented in your request handler is not a boundary if the browser can still reach the network on its own.

Tagging is not conformance, and Chromium does not check either

This is the part where the free rail is more capable than most comparisons admit, and also where "it produces the format" and "it produces a conformant file" get conflated.

Chromium can emit a tagged PDF, so structure from your DOM does survive into a structure tree. Tagging is a prerequisite for PDF/UA, not the whole of it: PDF/UA-1 also wants a document title set in the catalog, a declared natural language, and an XMP identifier asserting the conformance level. PDF/A is a separate axis with its own requirements, mainly an output intent, fully embedded fonts, and matching XMP metadata. A tagged PDF with no output intent is not a PDF/A file, and a file that declares PDF/UA-1 in its metadata is not conformant because it says so.

And nothing in the render path tells you which of those you achieved. The reference open-source validator is veraPDF; it is what turns "we set the flags" into a fact. Which two thirds of PDF/UA a validator can actually settle, and which third it cannot, is the subject of WCAG passes, your PDFs still fail.

So the honest shape of the build-versus-buy question on compliance is not "can the free tools do it". It is who runs the validator on every render and treats a failure as a failure.

Renders your CSSTagged outputPDF/APDF/UAVerified per renderYou operate it
Puppeteer / Playwright on Chromiumyesyesassemble itassemble ityou add ityes
Gotenbergyes (Chromium)yesyesyesyou add ityes
WeasyPrintCSS Paged Media, no JSyesyesyesyou add ityes
Hosted conversion APIvariesvariesvariesvariesvariesno

"Assemble it" means post-processing with a PDF library to attach an output intent and metadata. It is a known recipe, not research.

When self-hosting is the right answer

Own it when any of these is true, and own it on purpose:

  • Volume is high and predictable. Per-render cost dominates and a pool you already operate is cheap at the margin.
  • You have ops appetite. A team that already runs stateful workloads absorbs a browser pool without noticing.
  • Data locality is a rule you would rather satisfy yourself than verify in somebody's subprocessor list.
  • You need to modify the pipeline. Custom fonts, a rendering engine swap, a step between render and delivery.

Gotenberg is the strongest starting point if the answer is yes: a maintained Docker service wrapping Chromium and LibreOffice, with PDF/A and PDF/UA conversion built in, so you skip most of the assembly above and inherit someone else's opinions about process supervision. WeasyPrint is the better fit for print-shaped documents whose layout is CSS Paged Media rather than JavaScript, and it is lighter than a browser by a wide margin.

When a hosted API is the right answer

Reach for one when the browser pool would be a new category of thing in your on-call rotation, when volume is spiky enough that capacity sits idle, or when rendering is a cost centre rather than something your product competes on. The thing you are buying is not page.pdf(). It is the font packages, the pool, the egress policy, the validator, and whoever gets paged when Chromium changes.

The decision

Start with Puppeteer. It is three lines and it works. Move off it when the babysitting outweighs the control it buys you, and not before.

If you get there: PolyDoc renders on Chromium, emits PDF/A and PDF/UA output, and can run veraPDF as part of the same request with enforcement on, so a non-conformant file comes back as an error instead of a document. That last part is the reason it exists, since it is the step that gets skipped on every rail above. See the accessible PDF overview or the PDF/UA guide for what it does and does not claim. The free plan is 150 conversions a month without a card, which is enough to compare output against whatever you are running now.

Behaviour described here was checked against current Chromium, Gotenberg and WeasyPrint releases on 23 August 2026. Renderer defaults move; verify against the tool's own documentation before relying on any single flag.