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 beforeclose()leaves a Chrome process running; in a server or a loop they add up. - Choose
waitUntildeliberately.networkidle2waits until at most two requests are open for 500 ms.networkidle0never 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
awaitacts on a page that is not ready. - Using Node variables inside
$$evalorevaluate. 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.