Headless Browser PDF Generation in Production

The original headless Chrome was a stripped-down, separate renderer. It occasionally produced output that diverged from what a real browser would display, which meant developers had to maintain two mental models at once: how the page looked in the browser, and how it rendered headlessly. That divergence was not always predictable, and sometimes it was substantial.
In 2023, Chrome introduced "new headless mode," flagged as --headless=new. This was not a patch. It was a real browser running without a window, sharing the same underlying code path as desktop Chrome. Chromium 132, released in January 2025, made this the default. When you spawn Chrome with --headless today, you get the full browser.
The practical payoff is substantial. Any modern CSS that works in desktop Chrome, including Container Queries, @layer, :has(), color-mix(), variable fonts, and print-color-adjust, now works identically in headless rendering. An entire class of "works in the browser, breaks in headless" bugs is simply gone. Teams still debugging those issues in 2025 or later should first check whether they are running an outdated launch flag or following documentation written before the default changed. More often than not, that is exactly the problem.
One structural detail that matters downstream: two binaries now exist. Full Chromium, which is functionally identical to the desktop browser, and chrome-headless-shell, a stripped-down derivative optimized specifically for automation. That distinction becomes operationally significant when you get to Docker and serverless deployment, because the two binaries have meaningfully different size and dependency profiles.
Choosing Between Puppeteer and Playwright for PDF Work in 2026
Both Puppeteer and Playwright drive Chromium over the DevTools Protocol. For PDF export specifically, Playwright uses Chromium only; there is no cross-engine PDF parity with Firefox or WebKit. That is not a real limitation, because the goal is Chrome print fidelity, and both tools deliver it through the same underlying browser.
The download trajectory tells you something real. As of June 2026, Playwright ships roughly five times Puppeteer's weekly npm downloads, approximately 57.6 million versus 10.7 million per week. Adoption at that scale reflects production usage, not experimentation.
Performance on warm instances favors Playwright significantly. Per 2026 benchmarks, Playwright renders a complex document in approximately 13ms versus Puppeteer's 58ms. Cold-start gaps are similar: roughly 42ms versus 147ms. File size also diverges. Puppeteer produces approximately 197 KB on a given document; Playwright produces 59 to 125 KB on the same content. That difference points to distinct PDF compression strategies, and Playwright's output is smaller across the board.
For new projects, start with Playwright. Puppeteer remains valid for existing codebases where the migration cost is real and the performance gap is tolerable. The one narrow exception: for PDF-only workloads with no broader automation suite, Puppeteer is slightly leaner, because you are not carrying the multi-browser abstraction overhead that Playwright's design requires.
WeasyPrint is a different tool for a different job. It produces the smallest output files, approximately 21 KB on the same document used in the benchmarks above, and is genuinely useful for text-heavy documents where JavaScript rendering is unnecessary. But it spawns a fresh process per render and cannot compete on warm-path latency. Comparing it to Puppeteer or Playwright for general PDF work is like comparing a scooter to a car on highway distance; the categories overlap on a map but not in practice.
One API behavior that connects directly to the next section: both Puppeteer and Playwright expose a waitUntil: 'networkidle' variant, which waits for fonts and images to finish loading before capture. Skipping it, or misconfiguring it, is one of the most reliable ways to ship PDFs with missing assets in production.
The Singleton Browser Pattern and Why Launching Per-Request Destroys Throughput
Launching a new browser instance for every PDF request adds 200 to 400 milliseconds before any rendering work begins. At low volume, that is tolerable. Under real concurrent load, it compounds into a throughput ceiling and a memory crisis simultaneously. The browser is not a stateless function you can invoke and discard. It is a long-running process with real startup cost, and treating it otherwise is the single most common architectural mistake in production PDF pipelines.
The singleton pattern is the baseline. Maintain one warm browser instance and reuse it across requests. A single instance handles roughly five to ten parallel pages before degradation begins; beyond that, you need a pool of instances rather than a single one.
The failure mode for getting this wrong is specific. Running concurrent rendering in Puppeteer without a pool frequently leaves orphaned browser processes that pin server CPUs to 100% and refuse to release. These zombie processes accumulate until the service requires a manual restart. I have seen this take down invoice generation for an entire billing cycle while engineering scrambled to identify what looked, at first glance, like a memory leak. It was not. It was a resource management decision that had never been made explicitly.
Warm render speed for a single A4 page sits at approximately 800 milliseconds to 2 seconds, depending on font loading, image weight, and JavaScript execution complexity. That is the budget. Memory consumption under concurrent load runs approximately 85 to 200 MB per conversion without pooling, and compounds directly with request volume.
There is a second failure mode teams consistently underplan for: Chromium crashes under memory pressure. A pool without restart logic and health checks is not a pool; it is a pool that degrades silently until it stops serving requests. You need both the pooling and the watchdog logic. One without the other is a deferred incident.
CSS and Layout Properties That Require Explicit Attention for Print Output
Start with @page. It controls page dimensions, margins, and named pages, and all major browsers support it as of late 2024. Most print layout problems I have seen in the wild trace back to teams who skipped this rule entirely and inherited whatever defaults the browser chose.
Margin at-rules, specifically @top-center, @bottom-center, and their counterparts, handle running headers and footers through CSS. These are Chrome-only. If your documents require headers and footers controlled through CSS margin boxes, you are locked to Chromium for rendering. That is not a problem if you have already committed to a headless Chromium stack, but it is worth knowing before you discover it mid-project.
There is a specific inconsistency between headless and graphical Chrome that catches developers off guard: graphical Chrome ignores CSS-set page dimensions and uses window size instead. Headless Chrome correctly uses the CSS dimensions. The behavior you want for print output is the headless behavior. This sounds obvious in retrospect and is reliably surprising the first time it surfaces.
One practical pitfall that cost a team I know a full day of debugging: headless Chrome silently refuses to fetch external resources referenced in @page CSS rules. No error, no warning, Just missing content in the output PDF. The fix is base64 data URLs for any images used in margin boxes. Inelegant, but it works.
For page breaks, use break-before, break-after, and break-inside. The page-break-* properties still work but are legacy aliases now. Chromium handles orphans and widows reliably for body text. For multi-page tables, wrapping headers in <thead> causes Chromium to repeat them automatically on every printed page, which solves the context-loss problem that makes long tables nearly unreadable when they cross a page boundary.
On Paged.js: in 2020, the polyfill was essentially required for serious paged-media work. Native CSS support has advanced enough since then that standard business documents, invoices, contracts, reports, no longer need it. Reserve Paged.js for scenarios where native CSS genuinely falls short: dynamic running headers derived from content, footnotes, cross-reference page numbers. One caveat before committing to it: Paged.js and Chrome disagree on several aspects of the W3C paged media specification. Test the specific features you need before building on it, not after.
How Deployment Environment Shapes What "Production-Ready" Actually Means
"Production-ready" is not a universal standard. It is a function of where the code runs, and the architecture that works on a long-running server is actively wrong on a serverless function.
In a Docker or long-running server deployment, a Node.js application with Playwright or Puppeteer plus Chromium system dependencies produces an image in the 700 MB to 1 GB range. That slows CI pipelines and increases registry costs, but it gives full control over the browser pool and is the most operationally straightforward path for high-volume synchronous rendering.
Serverless environments create hard constraints. AWS Lambda zip deployments cap at 250 MB. Vercel Edge Functions cap at 50 MB. A Chromium binary alone is around 300 MB. The standard workaround for Lambda and Vercel is @sparticuz/chromium, a compressed binary that is decompressed at runtime. Cold starts run approximately 3 to 5 seconds for the binary unzip. That is acceptable for low-volume asynchronous renders. It is a genuine problem when a user is actively waiting for a response.
Cloudflare Browser Rendering, which reached general availability in 2024, takes a different approach entirely. It drives real Chromium via a Puppeteer-compatible API from a Worker, with no cold start and no binary management. For low-volume, latency-sensitive rendering inside a Cloudflare stack, it is currently the cleanest option available.
Google Cloud Run has emerged as the most practical self-hosted option for teams that want full control without Kubernetes complexity. Full Chromium binary, generous memory configuration, and scale-to-zero behavior without the binary size constraints of Lambda.
For large or complex documents, an asynchronous architecture is worth the added design complexity. The initial request enqueues the job, a separate function handles rendering, output lands in object storage, and the client is notified on completion. This pattern decouples user-facing latency from render time, which matters more as document complexity grows.
chrome-headless-shell is smaller than full Chromium, avoids missing system library issues in minimal Docker images, and ships official ARM64 Linux builds where Puppeteer-bundled Chromium does not. Quarto migrated to it in version 1.9 in April 2026 for precisely these reasons. If ARM64 deployment or minimal image size is a constraint, it belongs on your shortlist before you finalize the deployment design.
Gotenberg as the Reference Architecture for PDF-as-a-Microservice
Gotenberg solves a specific structural problem. Instead of embedding Chromium inside your application container, you push it into a dedicated sidecar that exposes an HTTP API. POST your files and parameters, receive a PDF. Your application image stays lean. Chromium lives in its own service, decoupled from everything else.
The container bundles headless Chromium alongside LibreOffice and several PDF utilities: QPDF, pdfcpu, PDFtk, and ExifTool. This is not a Chromium wrapper with some extras bolted on. It is a document processing service. The project has over 12,000 GitHub stars as of mid-2026, is deployed in production across thousands of organizations, and is Apache 2.0 licensed.
The capabilities beyond raw PDF generation matter for real production workloads: PDF/A compliance at multiple levels (1a, 2b, 3b), AES-256 encryption via QPDF, merge and split operations, and metadata editing. These are features teams would otherwise build, maintain, or procure separately, usually after someone asks for them at an inconvenient time.
Horizontal scaling in Kubernetes is straightforward because the service is stateless. Additional pods spin up during peak loads, end-of-month invoice runs being the canonical example, without any state migration concern. Concurrency defaults to 6 parallel Chromium conversions per instance, and that figure is configurable. Beyond the threshold, you scale with additional containers rather than pushing a single instance past its limits.
One security requirement that is non-negotiable: bind Gotenberg to 127.0.0.1:3000, not 0.0.0.0. There is no built-in authentication. A reverse proxy must handle TLS and access control before Gotenberg touches any network that carries real traffic. This is a deliberate design choice, placing access control outside the service boundary, but teams have absolutely stood up Gotenberg instances on public network interfaces and left them open. The choice to do that is yours; the consequences are also yours.
The honest trade-off: Gotenberg adds a network hop and an additional service to operate. For a simple single-service deployment with modest PDF volume, embedding Chromium in the application carries less operational overhead. For anything with real concurrency demands, mixed document types, or teams that want to keep application images slim, the decoupling pays for itself quickly and stays paid.
Security Risks That Are Specific to Running Headless Browsers in Production
A headless browser is a full browser. It is exploitable like one. Its vulnerabilities do not always surface cleanly in CVE scanners, and the default posture for most teams is to run it without a sandbox. That combination is not hypothetical risk; it is the precondition for the incidents that actually happen.
Server-side request forgery is the persistent concern. If user-controlled content can reference internal network addresses, the browser will fetch them. It does not distinguish between a legitimate external resource and an internal metadata endpoint. It just fetches. Input validation and network egress controls need to be designed in from the start, not retrofitted after the first time someone discovers they can hit your cloud provider's metadata service from inside your PDF renderer.
The sandboxing situation in Docker is a known, accepted compromise. Running Chromium with --no-sandbox is common in containerized deployments because the default Linux sandbox requires kernel capabilities that containers typically do not have. The flag gets copied from documentation without full understanding of what it disables, which is a meaningful layer of process isolation. The mitigation is container-level isolation: seccomp profiles, user namespace restrictions, and network segmentation. The sandbox removal has to be compensated for deliberately, not ignored.
Gotenberg's lack of built-in authentication, discussed in the previous section, is an instance of a broader pattern. PDF generation services get stood up quickly and hardened slowly. The reverse proxy and network boundary should be in place before the first production request. Adding them later, once the service is handling real data, is harder than it sounds and riskier than it feels.
The practical security posture is to treat the headless browser process as a compromised component by default. Restrict its network access. Run it as a non-root user. Isolate it from the rest of the application's IAM permissions. If the browser process is exploited, the blast radius should be contained to the rendering context and nothing beyond it. This is not overcaution. It is the correct mental model for a process that executes arbitrary web content in a production environment, where the content cannot always be fully trusted and the consequences of getting it wrong are not limited to a bad PDF.
