How to save a web page as PNG or PDF with Puppeteer?
Introduction
In this example we'll see how to turn any web page into a PDF or a PNG screenshot with Puppeteer. Puppeteer drives a real headless Chrome browser, so it renders pages exactly like a normal browser would — and unlike the older Nightmare/Electron approach, it runs headless out of the box with no Xvfb virtual display, no DPI magic numbers, and no manual screenshot stitching.
If you've used Nightmare before, this is its modern replacement: Nightmare is built on an unmaintained Electron version, while Puppeteer is maintained by the Chrome team and ships its own compatible Chromium.
Let's start by initializing our project and installing Puppeteer. Installing the package also downloads a matching Chromium build, so there's nothing else to set up.
npm init -y
npm i --save puppeteer
touch index.mjsWe'll use index.mjs as the main file so it's treated as a module, which gives us top-level await and native import/export.
Launching the browser
Bootstrapping is just a few lines: launch the browser, open a page, resize the viewport, and navigate to the URL. We'll load Wikipedia's page for Gallium (the codename for Node.js 16) as our example.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: 'new',
});
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 800 });
await page.goto('https://en.wikipedia.org/wiki/Gallium', {
waitUntil: 'networkidle2',
});The waitUntil: 'networkidle2' option tells Puppeteer to consider navigation finished once there are no more than two network connections for at least 500 ms — a reliable way to wait for the page (and most of its assets) to actually load before we capture it.
Manipulating the page before capture
Just like in a browser console, we can run arbitrary JavaScript inside the page with page.evaluate(). As an example, let's hide the Wikipedia logo and change the title before we render it:
await page.evaluate(() => {
// hide the wiki logo
const sheet = document.styleSheets[0];
sheet.insertRule('#firstHeading:before { content: none }', sheet.cssRules.length);
// change the title
document.getElementById('firstHeading').innerText =
'Testing automated PDF generation with Node.js and Puppeteer';
});Generate PDF
Generating a PDF is a single call. printBackground keeps background colors and images, and Puppeteer paginates the document for us automatically:
await page.pdf({
path: 'generated.pdf',
format: 'A4',
printBackground: true,
});A couple of handy options: pass { width, height } instead of format for a custom page size, and set margin (e.g. { top: '1cm', bottom: '1cm' }) to control the printable area. For PDF output Puppeteer emulates print media by default, so any @media print styles on the page apply.
Generate PNG
This is where Puppeteer really shines compared to the old Nightmare workflow. Capturing the entire page — not just the visible viewport — is a single option, fullPage: true. Puppeteer measures the full document height and scrolls/stitches it internally, so there's no need to take multiple screenshots and glue them together with an image library:
await page.screenshot({
path: 'generated.png',
fullPage: true,
});That's the whole thing. Remember the DPI and "cut by 67 pixels" headaches, and the manual tiling loop the Nightmare version needed to work around Chromium's screenshot height limit? None of that is necessary here — fullPage handles it.
One caveat still applies: Chrome has a maximum texture/canvas size (around 16 384 pixels), so extremely long pages can still be clipped. If you hit that, capture the page in vertical slices by clipping a fixed region and scrolling between shots:
// For very tall pages, capture fixed-height slices
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
const sliceHeight = 4096;
const slices = Math.ceil(fullHeight / sliceHeight);
for (let i = 0; i < slices; i++) {
const top = i * sliceHeight;
const height = Math.min(sliceHeight, fullHeight - top);
await page.screenshot({
path: `generated.${i + 1}.png`,
clip: { x: 0, y: top, width: 1024, height },
});
}Because clip reads straight from the rendered page, the slices line up perfectly and can be concatenated top-to-bottom with any image library (for example sharp). For the vast majority of pages, though, plain fullPage: true is all you need.
The complete script
Here's everything put together in a single index.mjs. Don't forget to close the browser at the end — otherwise the Chromium process keeps running:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: 'new',
// On Linux/CI you often need these; see the notes below.
// args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 800 });
await page.goto('https://en.wikipedia.org/wiki/Gallium', {
waitUntil: 'networkidle2',
});
await page.evaluate(() => {
const sheet = document.styleSheets[0];
sheet.insertRule('#firstHeading:before { content: none }', sheet.cssRules.length);
document.getElementById('firstHeading').innerText =
'Testing automated PDF generation with Node.js and Puppeteer';
});
// PDF
await page.pdf({
path: 'generated.pdf',
format: 'A4',
printBackground: true,
});
// Full-page PNG
await page.screenshot({
path: 'generated.png',
fullPage: true,
});
} finally {
await browser.close();
}Things to keep in mind
- Running on a server or in CI/Docker. The default Chromium sandbox often can't run as root, so launch with
args: ['--no-sandbox', '--disable-setuid-sandbox'](only in trusted environments), and install the required system libraries in your image. - Reuse the browser. Launching Chromium is expensive. If you generate many files, launch one
browserand open a freshpageper job instead of relaunching each time. - Always
browser.close(). Wrap your work intry/finallyso a crash doesn't leave zombie Chromium processes behind. - Wait for the right thing.
networkidle2is a good default, but for pages driven by JavaScript you may wantpage.waitForSelector()to wait for a specific element before capturing. - Smaller footprint. If you don't want to bundle Chromium, install
puppeteer-coreand point it at a Chrome you already have withexecutablePath.
That's it — a cross-platform HTML → PDF/PNG script in a fraction of the code the Nightmare version required, with none of the Xvfb, DPI, or stitching workarounds.