Generating High-Fidelity PDFs with Puppeteer: Layout and Print Control
Puppeteer PDFs lose backgrounds, headers and late-loading content unless you configure the print engine. Here is a working page.pdf() setup and how to verify it.
29 Mar 2026, 13:50 UTC

If your Puppeteer PDF comes out with white boxes where background colors should be, no page numbers, and a chart that is half-rendered, the cause is usually configuration rather than rendering. page.pdf() drives Chromium's print pipeline, and that pipeline has its own defaults: backgrounds are dropped, headers and footers exist only inside margin space, and it captures whatever the DOM looks like at the moment the call is made.
The useful takeaway: treat PDF generation as a print job, not a screenshot. Turn on background printing, reserve margins for templates, wait for the content you care about, and verify the output against the CSS you wrote for @media print.
What page.pdf() actually does
Puppeteer sends a Page.printToPDF command over the Chrome DevTools Protocol (CDP), the same debugging channel DevTools uses. Chromium's headless print engine then lays out the current document for a physical page size and returns a PDF stream to Node.js.
Two consequences follow. First, the PDF is a layout pass, not a pixel copy, so CSS that applies to print media wins over screen styles. Second, the call is asynchronous and resolves to a Buffer (or writes to path if you supply one), so it must be awaited before the browser closes.
PDF generation is a Chromium feature. Running the same script against Firefox through Puppeteer's WebDriver BiDi support is not equivalent; header and footer templates in particular are Chromium-specific.
A worked configuration
Run this from a Node.js project where puppeteer is installed. The process needs write permission on the directory you pass to path, and network access if the page is remote.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/report', {
waitUntil: 'networkidle0'
});
// Wait for the element your data renderer creates, not a fixed delay.
await page.waitForSelector('#chart-ready');
await page.pdf({
path: 'output-report.pdf',
format: 'A4',
printBackground: true,
margin: { top: '80px', bottom: '80px', left: '20px', right: '20px' },
displayHeaderFooter: true,
headerTemplate: `<div style='font-size:10px; margin-left:20px;'>Internal Only</div>`,
footerTemplate: `<div style='font-size:10px; width:100%; text-align:center;'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>`
});
await browser.close();
})();
Why each option matters
printBackground: true— without it, Chromium drops CSS background colors and background images, which is why branded headers and table striping vanish.margin— the template strings render inside the margin area. A zero top margin leaves no space, and the header is clipped rather than shrunk.displayHeaderFooter: true— required in addition to the templates; supplying templates alone may produce nothing.format: 'A4'— sets the paper size.widthandheightcan be used instead when you need a custom page.
Templates render in an isolated document. They do not inherit the page's stylesheets, fonts, or CSS variables, so inline styles are the reliable option. The pageNumber and totalPages class names are special: Chromium substitutes them with the current and final page numbers.
Where it breaks
Content that is not there yet
Lazy-loaded images, client-side charts, and web fonts all resolve after navigation completes. A fixed setTimeout is a guess; waiting on a selector or a network-idle condition is a check. If the page has no reliable marker, add one — for example, set a data attribute from your chart library's render callback and wait for that.
Page breaks in the wrong place
The print engine breaks pages by its own rules. Apply break-inside: avoid to cards, table rows, and figure blocks, and break-before: page where a new section should start. These belong in a @media print block so the screen layout is unaffected.
Memory and scale
Rendering a large DOM with high-resolution images in a headless browser holds the whole layout in memory. For batch jobs, reuse one browser instance and open a new page per document rather than launching a browser per file, and close pages when done. Very large images can be downscaled before printing if the PDF does not need print-resolution assets.
Margins fighting @page
If your CSS declares @page { margin: ... } and you also pass a margin object, the two interact and the result is easy to misread. Pick one source of truth — usually the options object, since that is what the templates depend on.
Checking the result
- Open the PDF and confirm a known background color appears. If it does not,
printBackgroundis missing or false. - Check the first page for the header text and the last page for the page count. A missing header usually means the top margin is too small or
displayHeaderFooteris absent. - Compare the PDF against the screen version. If they look identical, your
@media printrules may not be applying, or the print stylesheet is not linked with a print media attribute. - Search the PDF text for a value that only appears after your data loads. An empty or placeholder value means the wait condition fired too early.
A practical limitation to accept: this workflow is Chromium-only, and the exact set of supported options can differ between Puppeteer and Chrome releases. Pin your Puppeteer version, and re-run the checks above after upgrading rather than assuming the output is unchanged.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.