Menu
Sign up

Puppeteer tutorial: first script, scraping, downloading images and Firefox

A practical Puppeteer tutorial, run on Puppeteer 25.7: install, first script, reading and clicking, downloading images, and what works in Firefox.

Published November 7, 2026 · 7 min read

Puppeteer is a Node.js library that drives a real browser from code: it opens pages, clicks and types, reads what is on the page, and takes screenshots and PDFs. This tutorial goes from installation to a working script, then covers two things people search for most: downloading images from a page and running Puppeteer with Firefox. Every script here was run on 2026-11-07 with Puppeteer 25.7.0, Chrome 152.0.7977.42 and Firefox 153.0.4, in the official ghcr.io/puppeteer/puppeteer Docker image, against a small local test shop page; the numbers below come from those runs.

Install

npm i puppeteer

Installing puppeteer also downloads a browser: Chrome for Testing and chrome-headless-shell, into ~/.cache/puppeteer (installation docs). In the official image they took 391 MB and 261 MB on disk. Many package managers now block install scripts by default; then the download is skipped and the first launch fails with "Could not find Chrome". Run npx puppeteer browsers install, or allow Puppeteer's install script.

If you bring your own browser or connect to a remote one, install puppeteer-core instead: the same API, no download.

A first script

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  const response = await page.goto('https://example.com/', { waitUntil: 'networkidle2' });
  console.log(response.status(), await page.title());
  await page.screenshot({ path: 'page.png' });
} finally {
  await browser.close();
}

Save it as first.mjs and run node first.mjs. Three habits from the start:

  • Close the browser in finally. A script that throws before close() leaves a Chrome process running; in a server or a loop they add up.
  • Choose waitUntil deliberately. networkidle2 waits until at most two requests are open for 500 ms. networkidle0 never resolves on a page that keeps a connection open — in our speed tests it timed out.
  • Set the viewport. The default is 800×600, which puts many sites in their tablet layout.

Reading the page

Most scraping is $$eval: select elements and map them to plain data inside the page.

const products = await page.$$eval('.product', (els) =>
  els.map((el) => ({
    name: el.querySelector('.name').textContent.trim(),
    price: el.querySelector('.price').textContent.trim(),
  })),
);

On our test page this returned all five products, the first as {"name":"Item 1","price":"10.00"}. The function runs in the browser, so it cannot use your Node variables; pass values as extra arguments.

Content that arrives later — after a script, a request or a click — needs a wait. await page.waitForSelector('#late') returned as soon as our page added the element half a second after load, which is better than a fixed setTimeout.

Clicking and typing

Locators find an element, wait until it can be used, then act:

await page.locator('input[name=q]').fill('lamp');
await Promise.all([
  page.waitForNavigation(),
  page.locator('form button').click(),
]);
console.log(await page.$eval('h1', (el) => el.textContent));

Start waiting for the navigation before the click, in the same Promise.all; started after it, the navigation may already be over and the wait hangs. Our run landed on /search?q=lamp with "Results for lamp". The page interactions guide covers locators in full.

Screenshots and PDFs

page.screenshot() and page.pdf() are one line each; the details fill their own guides: screenshots (viewport, element, retina), full-page screenshots (lazy images, size limits), PDFs (page size, headers, page breaks) and emoji and fonts.

Downloading images from a page

There are two good ways, and each misses something. Our test page had five product images, one CSS background image and one lazy image far below the fold.

1. Save image responses while the page loads. You get the exact bytes the browser received, with the page's cookies:

import fs from 'node:fs';
import path from 'node:path';

fs.mkdirSync('images', { recursive: true });
const saved = [];
page.on('response', (res) => {
  const type = res.headers()['content-type'] || '';
  if (!type.startsWith('image/')) return;
  saved.push(res.buffer().then((buf) => {
    const name = path.basename(new URL(res.url()).pathname) || 'image';
    fs.writeFileSync(path.join('images', name), buf);
  }).catch(() => {}));
});
await page.goto(url, { waitUntil: 'networkidle0' });
await Promise.all(saved);

In Chrome this caught 6 images: the five products and the CSS background. It missed the lazy image 3,000 px below, which Chrome never requested; in the same test Firefox did load it and saved all 7 — browsers decide differently when a lazy image is close enough to load. Do not rely on either: scroll the page or set lazy images to eager first, as in the full-page guide. Filter by content-type, not request.resourceType() — the latter does not exist in Firefox (below).

2. Collect the image URLs and download them yourself:

const urls = await page.$$eval('img', (imgs) => imgs.map((img) => img.currentSrc || img.src));
for (const url of urls) {
  const res = await fetch(url);
  fs.writeFileSync(path.join('images', path.basename(new URL(url).pathname)), Buffer.from(await res.arrayBuffer()));
}

This found 6 URLs — the five products and the lazy image — but not the CSS background, which is not an <img>. Node's fetch does not send the page's cookies, so images behind a login fail; the first method does not have that problem. Use currentSrc so a srcset gives the file the browser chose.

For a picture of an element as it looks on the page (cropped, scaled, with CSS applied), take an element screenshot instead: await (await page.$('img.hero')).screenshot({ path: 'hero.png' }).

Respect the site's terms and copyright: being able to download an image does not mean you may reuse it.

Puppeteer with Firefox

Puppeteer drives Firefox over WebDriver BiDi, the cross-browser automation protocol:

npx puppeteer browsers install firefox
const browser = await puppeteer.launch({ browser: 'firefox' });

Firefox took 310 MB in the cache. In the official Puppeteer image it would not start at first ("libX11-xcb.so.1: cannot open shared object file"); after installing libx11-xcb1, libgtk-3-0 and libdbus-glib-1-2 it ran. Puppeteer's own docs list the features that are not supported over WebDriver BiDi; they throw UnsupportedOperation. Our test of common calls:

Call Chrome 152 Firefox 153
goto, $$eval, waitForSelector, locators works works
screenshot (PNG, WebP, full page, element) works works
pdf works works
setRequestInterception (blocked 6 images) works works
response.buffer() works works
setCookie / cookies, setUserAgent works works
request.resourceType() works UnsupportedOperation
emulateMediaFeatures (dark mode) works UnsupportedOperation
page.metrics(), createCDPSession() works UnsupportedOperation

Launch, load and screenshot took about 1.7 s with Firefox against 0.4 s with Chrome on the same page. An UnsupportedOperation thrown inside an event handler (a request listener calling resourceType()) crashes Node with an uncaught exception, so test scripts in Firefox before relying on them. Use Firefox when you need to see the page as Firefox renders it; for everything else Chrome is the faster default.

Headless modes and the user agent

puppeteer.launch() runs Chrome's headless mode. { headless: 'shell' } runs the separate chrome-headless-shell, which the headless modes guide calls more performant but not identical to Chrome; in our run it started about 0.1 s faster. { headless: false } opens a visible window for debugging.

Both headless modes announce themselves: the user agent contained HeadlessChrome/152, and some sites block or change pages for it. page.setUserAgent() changes it if you have a reason to.

Common mistakes

  • Not awaiting. Almost every Puppeteer call returns a promise; a missing await acts on a page that is not ready.
  • Using Node variables inside $$eval or evaluate. The function runs in the page; pass arguments.
  • One browser per request in a server. Launching costs hundreds of milliseconds; reuse one browser and open pages (speed guide).
  • Missing fonts in Docker. Text and emoji turn into boxes (emoji and fonts).

When not to run Puppeteer yourself

If what you need from Puppeteer is a screenshot of a public page or an image made from HTML, running Chrome yourself means servers, fonts, memory and timeouts to look after. Our screenshot API takes screenshots of public URLs in one request — full page, devices, cookie banners hidden — and templates turn HTML and your data into images and PDFs. For scraping, form filling, tests and pages behind your login, Puppeteer on your own machine is the right tool.

FAQ

What is Puppeteer?

A Node.js library from the Chrome team that drives a real browser from code: open pages, click, type, read the page, take screenshots and PDFs. It downloads its own Chrome for Testing when installed and also drives Firefox.

Does Puppeteer work with Firefox?

Yes: install Firefox with npx puppeteer browsers install firefox and launch with { browser: 'firefox' }. It runs over WebDriver BiDi, so CDP-only features throw UnsupportedOperation — in our test request.resourceType(), emulateMediaFeatures(), page.metrics() and createCDPSession(). Screenshots, PDFs, cookies and request interception worked.

How do I download all images from a page with Puppeteer?

Listen to page responses and save the bodies of image responses, or collect the img elements' currentSrc and download each URL. Responses also catch CSS background images; a src list also finds lazy images that never loaded. Use both if you need everything.

Why does my Puppeteer install not download Chrome?

Your package manager blocked Puppeteer's install script, which many now do by default. Run npx puppeteer browsers install, or allow Puppeteer's install script in your package manager's settings.