Puppeteer HTML to PDF: page.pdf() Options Tested
Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.
To turn HTML into a PDF with Puppeteer, load the HTML into a page and call page.pdf():
import puppeteer from 'puppeteer'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setContent('<h1>Invoice INV-0042</h1><p>Total: $75.00</p>', { waitUntil: 'load' }); await page.pdf({ path: 'invoice.pdf', format: 'A4', printBackground: true }); } finally { await browser.close(); }
That file opens, but a real document needs more: the right paper size, margins, page numbers, rows that do not split in half, and backgrounds that survive. This post goes through the page.pdf() options one by one on the same 60-line invoice. For each option we show what it changed in the output, with the file size and page count we measured. To compare Puppeteer with Playwright and PDFKit, read HTML to PDF in Node.js.
For screenshots instead of PDFs, start with the Puppeteer screenshot guide. This post is the PDF part of that series.
Tested on 2026-10-18 with Node.js 22.18.0,
[email protected]and the Chrome it downloads (148.0.7778.97), on macOS (Apple Silicon). Page counts and sizes come frompdfinfo(poppler). Timings are the median of fivepage.pdf()calls on an already-loaded page.
Setup
npm init -y npm install [email protected]
Use ESM ("type": "module" in package.json, or .mjs files) so you can use top-level await. Puppeteer downloads a matching Chrome into ~/.cache/puppeteer on install. In a Docker image, run the install as the same user that runs the app, or set PUPPETEER_CACHE_DIR so the browser is found at run time.
The test document
Every number on this page comes from one HTML file: an A4 invoice with a flex header, a grid "billed to" block, a CSS variable for the brand color, a rounded "Paid" badge, a 60-row table with a <thead>, a "Terms" section that must start on a new page, and a line that is visible only on screen:
:root { --accent: #0f766e; } @page { size: A4; margin: 18mm 14mm 22mm; } thead { display: table-header-group; } tr { break-inside: avoid; page-break-inside: avoid; } .terms { break-before: page; page-break-before: always; } @media print { .screen-only { display: none; } }
A Python script writes the full file. It is in the test harness listed at the end, so you can print the same document.
What each option changed
The script below prints the invoice once per option set and reports the median time and size.
// variants.mjs: node variants.mjs invoice.html out/ import { readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; const [input, outDir] = process.argv.slice(2); const html = await readFile(input, 'utf8'); const footerTemplate = ` <div style="font-size:8px;width:100%;padding:0 14mm;display:flex;justify-content:space-between;color:#64748b"> <span class="title"></span> <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span> </div>`; const variants = { 'default': {}, 'a4-background': { format: 'A4', printBackground: true }, 'css-page-size': { printBackground: true, preferCSSPageSize: true }, 'header-footer': { format: 'A4', printBackground: true, displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate, margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' }, }, 'landscape': { format: 'A4', landscape: true, printBackground: true }, 'scale-0.8': { format: 'A4', printBackground: true, scale: 0.8 }, 'tagged-outline': { format: 'A4', printBackground: true, tagged: true, outline: true }, }; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'load' }); for (const [name, options] of Object.entries(variants)) { const times = []; let pdf; for (let i = 0; i < 5; i++) { const start = performance.now(); pdf = await page.pdf(options); times.push(performance.now() - start); } times.sort((a, b) => a - b); await writeFile(`${outDir}/${name}.pdf`, pdf); console.log(`${name}\t${times[2].toFixed(0)} ms\t${pdf.length} bytes`); } } finally { await browser.close(); }
| Option set | Paper in the PDF | Pages | Bytes | Median time |
|---|---|---|---|---|
{} (all defaults) |
Letter, 612 x 792 pt | 3 | 118,457 | 102 ms |
format: 'A4', printBackground: true |
A4 | 3 | 118,514 | 110 ms |
preferCSSPageSize: true (no format) |
A4, from @page |
3 | 118,514 | 109 ms |
| Header and footer templates + margins | A4 | 3 | 128,780 | 110 ms |
landscape: true with @page { size: A4 } in CSS |
A4 portrait | 3 | 118,514 | 108 ms |
scale: 0.8 |
A4 | 3 | 118,569 | 105 ms |
tagged: true, outline: true |
A4 | 3 | 118,917 | 102 ms |
What the table tells you:
- The default paper is Letter, even though the CSS asks for A4. Without
formatorpreferCSSPageSize, Puppeteer sends Letter to Chrome. If you print for customers outside North America, always set one of the two. @page { size }beatslandscape. Withsize: A4in the stylesheet,landscape: trueprinted portrait pages. After we removedsizefrom the@pagerule, the same call printed landscape A4 (841.92 x 595.92 pt) and the table needed 4 pages instead of 3. Pick one place to set orientation. Our rule: in CSS when the template owns the layout (size: A4 landscape), inpage.pdf()when the code does.- The print itself is fast. About 100 ms per call on a warm page. The expensive parts are launching Chrome and loading the page, which is why browser reuse (below) matters more than any PDF option.
- The footer template added about 10 KB, because it embeds the template's font once more.
Paper size and margins
format takes the names Chrome knows: Letter, Legal, Tabloid, Ledger, A0 to A6. For anything else, pass width and height as CSS lengths:
await page.pdf({ width: '100mm', height: '150mm', printBackground: true }); // a shipping label
Margins come from two places, and they add up in a confusing way:
margininpage.pdf(), as an object withtop,right,bottom,left.marginin the CSS@pagerule.
When page.pdf() gets no margin, Chrome uses the @page margin. When you need header and footer templates, set the margins in page.pdf(), because the templates render inside the top and bottom margin area and you want that space under your code's control.
Backgrounds: printBackground
Browsers drop backgrounds when they print, to save ink. page.pdf() does the same unless you pass printBackground: true. In our run without it, the table header lost its gray fill and the "Paid" badge lost its teal pill, so only light gray text was left. For a single element you can also opt in from CSS:
.badge { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
Header and footer templates
displayHeaderFooter: true turns on two small extra documents, one at the top and one at the bottom of each page. Chrome fills elements that have these classes:
| Class | Filled with |
|---|---|
pageNumber |
Current page number |
totalPages |
Total page count |
title |
The document <title> |
url |
The document URL |
date |
The print date |
Rules that save an hour of debugging:
- The templates do not see your stylesheet. Put the font size, color and padding in inline styles. Without a font size the text is very small.
- Pass both templates. If you only want a footer, pass
headerTemplate: '<span></span>'. Otherwise Chrome prints its default header with the date and title. - Give them room. The templates draw inside the page margins. Set
margin.topandmargin.bottomlarge enough for the text. - Scripts in templates do not run, and remote images are not reliable there. Inline a logo as a
data:URI and use system fonts. width:100%and your own padding line the template up with the body. In our footer,padding: 0 14mmmatches the 14 mm side margins.
In the header-footer output, page 2 ended with "Invoice INV-2026-0042" on the left and "Page 2 of 3" on the right, and the table header row repeated at the top.
Page breaks that behave
Three CSS rules fixed every break in our invoice:
thead { display: table-header-group; } /* repeat the header row on each page */ tr { break-inside: avoid; page-break-inside: avoid; } /* never cut a row in half */ .terms { break-before: page; page-break-before: always; } /* start on a new page */
With A4 and print media, the invoice printed 32 rows on page 1, 28 rows on page 2, and the terms alone on page 3, with the header row repeated on page 2. Two more points:
break-inside: avoidon a large block (a whole card or a whole table) can push it to the next page and leave a big gap. Apply it to the smallest unit you do not want to split: a row, a figure, a signature block.- Flex and grid containers break less predictably than block layout. If a long flex column will not break, change it to
display: blockinside@media print.
To print only some pages, use pageRanges: '1' or '1-3, 5'. Our run with pageRanges: '1' returned a one-page PDF.
Print CSS vs screen CSS
page.pdf() prints with the print media type. Our .screen-only line was hidden in every PDF above. If you want the PDF to look like the screen, switch the media type first:
await page.emulateMediaType('screen'); const pdf = await page.pdf({ format: 'A4', printBackground: true });
With screen, the red "visible on screen only" line came back on page 1. Most app pages look better in print with a short print stylesheet than with screen CSS: hide the navigation, the cookie banner and the chat widget, and set color and background for the body explicitly. Our glossary entry on CSS paged media lists which @page features Chrome supports.
Accessible and navigable PDFs
tagged: true writes a structure tree (headings, paragraphs, tables) so screen readers can read the PDF. outline: true builds a bookmark panel from your <h1> to <h6> headings. In our run, pdfinfo reported the default output as already tagged, so the extra 460 bytes in the last row of the table come from the outline. Set tagged explicitly anyway, so a library upgrade cannot change it.
Waiting before you print
page.pdf() prints what is in the page at that moment. For setContent, wait for load and then for fonts:
await page.setContent(html, { waitUntil: 'load' }); await page.evaluate(() => document.fonts.ready);
For a URL that fetches data after load, wait for an element that only appears when the data is in:
await page.goto(url, { waitUntil: 'networkidle2' }); await page.waitForSelector('[data-report-ready]', { timeout: 15_000 });
page.waitForTimeout was removed in Puppeteer 22. Wait for a condition instead of a fixed time. For converting live third-party pages, our URL to PDF tutorial covers lazy images and cookie banners in detail.
In production
The PDF call is about 100 ms. Launching Chrome is not. Our timeouts and memory leaks post covers the full list; for PDFs these four matter most:
- Launch once, open a page per job, close the page in
finally. A new browser per PDF repeats the most expensive step every time. - Cap concurrency. Each page holds the whole document in memory while it prints. Run a fixed number of pages at a time with a queue.
- Set timeouts on
goto,setContentandwaitForSelector.page.pdf()also acceptstimeout. - Install fonts in the image. A Linux container with no fonts falls back to whatever it has, and the page count changes. On Debian or Ubuntu:
RUN apt-get update && apt-get install -y --no-install-recommends \ fonts-liberation fonts-noto-core fonts-noto-color-emoji fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/*
Better still, load your own web fonts in the template so every machine uses the same files.
To stream the PDF from a web server instead of writing a file, page.pdf() returns a Uint8Array when you leave out path, and page.createPDFStream() returns a stream for very long documents.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| PDF is Letter, you wanted A4 | No format and no preferCSSPageSize |
Pass format: 'A4' or preferCSSPageSize: true |
landscape: true has no effect |
@page { size: A4 } in the CSS |
Remove size, or write size: A4 landscape |
| Colors and table shading missing | Backgrounds are off by default | printBackground: true |
| Chrome's date and title appear at the top | Only footerTemplate was set |
Also pass headerTemplate: '<span></span>' |
| Footer text is tiny or unstyled | Templates ignore page CSS | Inline font-size and colors in the template |
| Footer overlaps the content | No bottom margin | Set margin.bottom in page.pdf() |
| Rows cut in half at page ends | Default fragmentation | tr { break-inside: avoid } |
| Header row only on page 1 | No <thead> |
Wrap the header row in <thead> |
| Fallback font in the PDF | Printed before web fonts loaded | await page.evaluate(() => document.fonts.ready) |
| Navigation and banners printed | Screen styles leak into print | Add @media print { nav, .banner { display: none } } |
| Different page count in Docker | Different fonts in the container | Install fonts or ship web fonts |
TimeoutError on large documents |
Long layout or slow assets | Raise timeout on goto and page.pdf(), or split the document |
The same PDF with one API call
If you would rather not run Chrome, our HTML to PDF API takes the same HTML and the same kind of options. This Node SDK call matches the header-footer variant above. It follows the PDF options docs and the Node SDK reference; it was not run for this page.
import { readFile, writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const html = await readFile('invoice.html', 'utf8'); const pdf = await client.take( TakeOptions.html(html) .format('pdf') .pdfPaperSize('a4') .pdfPrintBackground() .pdfMargin('18mm') .pdfFooter('<div style="font-size:8px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>') ); await writeFile('invoice.pdf', pdf);
The mapping from page.pdf() is direct: format becomes paper, printBackground becomes print_background, preferCSSPageSize becomes prefer_css_page_size, pageRanges becomes page_ranges, and headerTemplate/footerTemplate become header/footer. The same template classes (pageNumber, totalPages, date, title, url) work. Margins are CSS strings ("18mm"), either one margin or margin_top and the other sides. The API also has fit_one_page, which scales the content to a single page.
The trade-off is plain. Puppeteer is free and fully under your control, and you own Chrome, fonts, memory and scaling. The API is one HTTP request, and you pay per render. You can create a free account and send the invoice above to compare the output with your local PDF.
Test harness and what we did not run
The files used on this page (gen.py for the invoice, variants.mjs, landscape.mjs and edge.mjs) ran on 2026-10-18 with the versions in the box at the top. Each run used the invoice fixture through setContent, not a live site.
Not run for this page: the RenderScreenshot SDK call above, the Dockerfile font lines, and page.createPDFStream(). We also produced no rendered images for this post. The page counts, sizes and visual results above come from pdfinfo and from our own look at the PDF pages.
Related guides
- Taking screenshots with Puppeteer, the hub for this series
- wkhtmltopdf vs Puppeteer, if you are moving off wkhtmltopdf
- URL to PDF in Node and Python, for live pages with banners and lazy images
- PDF options reference
Related in this topic
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Puppeteer on AWS Lambda with @sparticuz/chromium Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.
- Puppeteer in Docker: A Tested Dockerfile with Fonts A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer Screenshot Timeouts and Memory Leaks: A Debugging Guide A comprehensive debugging guide for Puppeteer screenshot issues — covering navigation timeouts, memory leaks, zombie Chrome processes, and production error handling patterns.