How to run Puppeteer page.pdf() in production
Updated by Cyril Nicodème
To run Puppeteer's page.pdf() in production, launch one browser per process and reuse it, open a new page for each document and close it in finally, wait for load and then document.fonts.ready, keep a timeout on loading and rendering, and install the fonts your documents use in the image.
In a tutorial, page.pdf() is three lines. In production, it's a browser you now operate. We ran the same 30-row invoice through it hundreds of times to put numbers on what goes wrong.
What does production-safe page.pdf() code look like?
Here's the baseline, written for Puppeteer 25.16.0, which ships with Chrome for Testing 156.
import puppeteer from 'puppeteer';
// One browser per process, launched once and reused for every job.
const browser = await puppeteer.launch();
export async function htmlToPdf(html) {
// A new page (tab) per document.
const page = await browser.newPage();
try {
// 'load' waits for the stylesheets and images in the HTML.
await page.setContent(html, { waitUntil: 'load', timeout: 30_000 });
// Then wait for the web fonts the page asked for.
await page.evaluate(() => document.fonts.ready);
return await page.pdf({
format: 'A4', // the default is Letter
printBackground: true, // the default is false: no background colors or images
margin: { top: '16mm', right: '16mm', bottom: '16mm', left: '16mm' },
timeout: 30_000,
});
} finally {
// Always close the page, even when something above threw.
await page.close();
}
}
Why does Puppeteer's memory keep growing?
Because pages you don't close stay open, and each one keeps its own Chromium process and memory long after the PDF is done.
We converted the same invoice 200 times on one browser, once without page.close() and once with it. Without it, the Chromium process tree grew from 323 MB to 2,715 MB after 100 jobs and 5,046 MB after 200, running 206 processes. With page.close() in finally, it went from 325 MB to 642 MB after 100 jobs and held at 659 MB after 200, with 17 processes. That's about 24 MB for every page you forget.
A process manager won't save you here: the browser is healthy, it just has 200 tabs open. Close pages in finally, and restart the browser now and then if your jobs run for days.
We ran these tests on an Apple M5 Pro with 64 GB of RAM, on macOS, with Node 26 and Puppeteer 25.16.0. Memory is the sum over the browser's process tree. Our test environment blocked Chrome's sandbox, so these runs used --no-sandbox.
Should I launch a new browser for every PDF?
No. You pay for the launch on every job, and a page on a running browser does the same work.
With networkidle0 as the wait, launching a browser, converting and closing it took 1.47 seconds per job (the median of 30 runs). Reusing one browser cut that to 1.06 seconds (100 runs). So the launch alone cost about 0.4 seconds.
The wait cost more than the launch. With load followed by document.fonts.ready, the reused browser took 0.24 seconds per job (100 runs), for the same 121.5 KB PDF.
Then run pages in parallel on that one browser. With 10 pages at a time and the same networkidle0 wait, 100 jobs took 13.9 seconds: about 7 PDFs a second, against about 1 a second one at a time. Each job got slower (median 1.16 seconds) and memory peaked around 678 MB. To find your limit, keep raising the number of parallel pages until memory or per-job time gets worse than you'll accept, then scale out with more processes.
Which waitUntil should I use with page.pdf()?
Use load, then wait for document.fonts.ready. load fires once the page's stylesheets, scripts and images have loaded, except lazy-loaded ones. The fonts wait covers web fonts the page asked for.
The two other common choices each cost you something:
domcontentloadeddoesn't wait for stylesheets to load. In 10 runs out of 10, our invoice came out without its web font, and the PDF was 82.6 KB instead of 121.5 KB.page.pdf()waits for fonts by default (itswaitForFontsoption), and that didn't change the result.networkidle0waits until the page has had no network connections for 500 ms. The fonts arrive, but every job pays for that quiet period. A page that keeps a connection open, an analytics socket for example, may never go quiet, and the job runs until it times out.
Puppeteer's TypeScript types don't allow networkidle0 or networkidle2 with setContent(), though plain JavaScript accepted it in our runs. For a URL, page.goto(url, { waitUntil: 'load' }) followed by the same fonts wait works the same way.
Whichever you pick, keep the timeouts. Navigation and setContent() default to 30 seconds, and so does page.pdf(). Passing 0 turns a timeout off, which is how a single bad page holds a worker forever.
Why are backgrounds, page sizes or headers wrong in the PDF?
Most of the time it's a default.
- Missing colors or background images:
printBackgroundis off by default, so colored table headers and background images disappear. Turn it on. If colors still look washed out, that'spage.pdf()adjusting them for print: add-webkit-print-color-adjust: exactto your CSS. - Screen styles that don't show:
page.pdf()renders with the print media type, so your@media printrules apply and@media screenones don't. - The wrong page size: Letter is the default, and
formatwins overwidthandheight. If your CSS has an@page { size: A4 }rule you want to keep, setpreferCSSPageSize: true. Otherwise Puppeteer scales your content to fit the paper size it was given. - Headers and footers: turn on
displayHeaderFooterand put elements with the classespageNumberandtotalPagesinheaderTemplateorfooterTemplate. The classesdate,titleandurlwork the same way.
What breaks when Puppeteer runs in Docker or serverless?
Your laptop has everything Chrome needs. A slim container image doesn't. Here's the checklist:
- Missing libraries. Chrome won't start without them. Run
ldd chrome | grep noton the browser binary to list the ones you're missing. - No usable sandbox. Chrome crashes with
No usable sandbox!when the container can't give it one. Puppeteer's docs say running without one is "strongly discouraged" and accept--no-sandboxonly when you trust all the content you render. If users can send you HTML, set up the sandbox instead. On Ubuntu 23.10 and later, AppArmor blocks the sandbox for the Chrome that Puppeteer downloads, which gives the same error. - Zombie processes. Start the container with
--init, or a custom entrypoint, so finished Chrome processes get cleaned up instead of piling up. Puppeteer's Docker guide runs it that way. - Fonts. Chinese, Japanese, Arabic, Hebrew or Thai text needs fonts a slim image doesn't have. Puppeteer's troubleshooting page installs
fonts-ipafont-gothic,fonts-wqy-zenhei,fonts-thai-tlwg,fonts-kacstandfonts-freefont-ttffor those, plus the fonts your own templates use. - Alpine. Chrome doesn't support Alpine out of the box. Use a Debian-based image.
- The browser isn't in
node_modules. Puppeteer downloads Chrome into~/.cache/puppeteer, so a multi-stage build that copies onlynode_modulesships without a browser. - Serverless size limits. An AWS Lambda deployment package is capped at 250 MB unzipped, layers included, so check the size of the Chrome build you ship against it before you pick Lambda.
Then pin your versions. Chrome moved to a release every two weeks with Chrome 153 in September 2026, and each Puppeteer release targets one Chrome version. Upgrade on purpose, and compare a few real PDFs before and after.
When is running Chromium yourself not worth it?
When the PDF is a feature of your product and not the product itself. Every section above is something you now maintain, for a feature your users see as one button.
People don't buy PDFs. They buy not having to run Chromium. That's the job we took on at PDFShift, and we've run Chromium conversions since 2018. You send one request with your HTML or a URL to a Chromium we run, and get the PDF back:
const response = await fetch('https://api.pdfshift.io/v3/convert/pdf', {
method: 'POST',
headers: {
'X-API-Key': process.env.PDFSHIFT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ source: html }), // raw HTML or a URL
});
if (!response.ok) {
throw new Error(`PDFShift returned ${response.status}: ${await response.text()}`);
}
const pdf = Buffer.from(await response.arrayBuffer());
The request format is in the conversion API reference, and there are more examples for Node with fetch and the other libraries in our guides. The free plan has 50 credits a month with no credit card, and the paid plans start at $9 a month. Get a free API key and send your own invoice through it.
We hope this guide was helpful. If you have any questions or noticed any issues on the code above,
feel free to drop us a line.