HomeHow to save a web page as PNG or PDF with Puppeteer?

How to save a web page as PNG or PDF with Puppeteer?

By · Node.js & JavaScript developer
Published July 5, 2026

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.mjs

We'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

  1. 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.
  2. Reuse the browser. Launching Chromium is expensive. If you generate many files, launch one browser and open a fresh page per job instead of relaunching each time.
  3. Always browser.close(). Wrap your work in try/finally so a crash doesn't leave zombie Chromium processes behind.
  4. Wait for the right thing. networkidle2 is a good default, but for pages driven by JavaScript you may want page.waitForSelector() to wait for a specific element before capturing.
  5. Smaller footprint. If you don't want to bundle Chromium, install puppeteer-core and point it at a Chrome you already have with executablePath.

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.

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.