Engineering

Why the PDF from headless Chromium differs from Print to PDF in the browser

Same engine, different output: viewport, print media, backgrounds, default margins, headless font rendering. A list of the differences and the option that fixes each.

M Mikel Rougstone · 31 August 2026 · 3 min read
Why the PDF from headless Chromium differs from Print to PDF in the browser

People test with Ctrl+P, ship with headless Chromium, and are surprised the two files differ. They use the same layout engine; they do not use the same defaults. Six differences explain almost every case.

1. Print media emulation

The print dialog applies @media print styles. Headless page.pdf() does too — but only if you emulate print media first. Skip it and you get the screen layout: navigation bars, hover styles, dark-mode backgrounds.

await page.emulateMedia({ media: "print" });   // Playwright
await page.emulateMediaType("print");          // Puppeteer

PDFGeny emulates print on every render, which is why output tends to match the dialog rather than the screen.

2. Background graphics

The dialog has a "Background graphics" checkbox, off by default. The API has printBackground, also off by default in raw Puppeteer.

A white page where you expected a coloured header is always this setting. In PDFGeny it defaults to on ("print_background": true), because documents almost always want it.

3. Page size and margins

The dialog uses your printer's defaults — often Letter in the US, A4 elsewhere, with margins you have never looked at. The API uses whatever you pass, and otherwise Letter with roughly 0.4 inch margins.

Set both explicitly, every time:

{"format": "A4", "margin": {"top": "18mm", "right": "14mm", "bottom": "20mm", "left": "14mm"}}

4. Viewport width

This one causes the most confusion. Responsive CSS reacts to viewport width. In the print dialog the viewport is effectively the paper width. In headless mode it is whatever the browser was launched with — 800×600 by default.

A media query at 768 px can therefore flip between the two, and you get the mobile layout in a PDF that should look like the desktop one. Either set the viewport to match the paper:

await page.setViewportSize({ width: 1240, height: 1754 });   // A4 at 150 dpi

or write print styles that do not depend on breakpoints — which is better practice anyway, since paper has no breakpoints.

5. Fonts and hinting

Headless Linux renders text with different hinting than macOS or Windows, and may have a different font set entirely. Line breaks can move by a character, which cascades into different page breaks on a long document.

Embed web fonts rather than relying on system ones, and leave a few pixels of slack rather than designing to exact widths. The full list of font traps is in web fonts in generated PDFs.

6. Timing

The dialog runs after you have finished looking at the page — everything has loaded, scripts have run, charts have drawn. The API runs the moment the page reports load, which for a JavaScript-heavy page can be before anything is visible.

Wait for the network to settle, for fonts, or for a selector that only exists once content is drawn:

{"url": "https://app.example.com/report", "wait_for": ".chart-ready"}

A quick diagnostic

When a generated PDF looks wrong and the browser looks right, check in this order:

SymptomAlmost always
Colours and backgrounds missingprint_background off
Navigation bars and banners presentprint media not emulated
Mobile layout on a desktop documentviewport width too narrow
Wrong fontembedding or timing, not the renderer
Empty or half-empty pagerendered before JavaScript finished
Different margins than expectedprinter defaults versus explicit options

Making the dialog a fair preview

If you want Ctrl+P to predict the output, match the settings: choose the same paper size, set margins to the same values, tick background graphics, and view at 100%. It will not be pixel-identical — hinting differs — but page breaks and layout will be, because it is the same engine underneath.

That is also why the print dialog remains the fastest way to debug page breaks: the break behaviour is identical, and you get a preview without generating anything.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.