Tutorial10 min read

Web Scraping With Puppeteer

Short answer

Web scraping with Puppeteer means driving a real Chrome from Node.js: you open the page with page.goto(), wait until the content is there (waitUntil: 'networkidle2' or page.waitForSelector()), and read the fields with page.$$eval(). Use it for pages that build their content with JavaScript; for plain HTML, a simple HTTP request is faster and lighter. ProxyField's residential proxies plug into Puppeteer with one launch flag and page.authenticate(), with 412M+ IPs in 195+ countries from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.

residential IPs
412M+
from, traffic never expires
$1.95/GB
Easy Trial
1 GB for $2.95
On this page 12 sections
  1. Is Puppeteer good for web scraping?
  2. Install Puppeteer
  3. Your first Puppeteer scraper, step by step
  4. waitUntil explained: load, domcontentloaded, networkidle0, networkidle2
  5. Extracting data: $eval, $$eval, evaluate and locators
  6. Clicking buttons, filling forms and following pagination
  7. Scraping infinite scroll pages with Puppeteer
  8. Screenshots with Puppeteer
  9. Puppeteer performance for scraping
  10. Using proxies with Puppeteer
  11. Puppeteer and ProxyField: what a scraping job costs in traffic
  12. Web Scraping With Puppeteer FAQ

Is Puppeteer good for web scraping?

Yes, for pages whose data appears only after scripts run: single-page apps, infinite feeds, content behind "Load more" buttons and sites that check for a real browser. Puppeteer is a Node.js library maintained by the Chrome team that controls Chrome (and, in current versions, Firefox) over the DevTools Protocol or WebDriver BiDi (pptr.dev). It is open source under the Apache-2.0 license (npm).

It is the wrong tool when the data is already in the HTML the server sends. A browser downloads every script, style sheet and image, so each page costs more time, memory and proxy traffic than a plain request. Check first: open the page source (Ctrl+U or Cmd+Option+U). If you can see the prices or names there, use fetch with Cheerio (see web scraping with JavaScript and Node.js); if the source is an empty shell, use Puppeteer.

FeaturePlain HTTP request (fetch + Cheerio)Puppeteer
Runs the page's JavaScriptNoYes
Clicks, scrolls, typesNoYes
Memory per pageA few MBTens to hundreds of MB per tab
Traffic per pageThe HTML onlyHTML, scripts, styles, images (unless you block them)
Typical useStatic pages, JSON endpointsJavaScript-rendered pages, user flows

What Puppeteer is and where it came from is covered in what Puppeteer is; this guide is about scraping with it.

Install Puppeteer

Run npm i puppeteer in a Node.js project. The package downloads a matching build of Chrome for Testing the first time, so your scraper and the browser version always fit together. If you want to use a Chrome you already have, install puppeteer-core instead and pass executablePath (or channel: 'chrome') to puppeteer.launch().

Shell
mkdir quotes-scraper && cd quotes-scraper
npm init -y
npm pkg set type=module
npm i puppeteer

Use a current Node.js LTS release; the Puppeteer docs list the supported versions for each release (pptr.dev: System requirements).

Your first Puppeteer scraper, step by step

The script below opens a JavaScript-rendered practice page, waits for the quotes to appear and saves each quote, author and tag list. The practice site quotes.toscrape.com/js/ exists for scraping exercises; its quotes are written into the page by a script, so a plain HTTP request sees none of them.

JavaScript
// scrape.mjs  ->  node scrape.mjs
import puppeteer from 'puppeteer';
import { writeFile } from 'node:fs/promises';

const browser = await puppeteer.launch();            // headless by default
const page = await browser.newPage();

await page.goto('https://quotes.toscrape.com/js/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.quote');                // wait for the data, not for a timer

const quotes = await page.$$eval('.quote', cards => cards.map(card => ({
  text: card.querySelector('.text')?.textContent.trim(),
  author: card.querySelector('.author')?.textContent.trim(),
  tags: [...card.querySelectorAll('.tag')].map(t => t.textContent.trim()),
})));

await writeFile('quotes.json', JSON.stringify(quotes, null, 2));
console.log(`Saved ${quotes.length} quotes`);
await browser.close();

What each step does:

  1. puppeteer.launch() starts the browser. It runs headless unless you pass headless: false, which is useful while you write selectors.
  2. page.goto() loads the URL and resolves when the event named in waitUntil fires.
  3. page.waitForSelector('.quote') waits until the first quote is in the DOM, up to the default 30-second timeout.
  4. page.$$eval() runs the callback inside the page on every matching element and returns plain data to Node.js.
  5. browser.close() ends the browser process; forgetting it leaves Chrome processes running.

waitUntil explained: load, domcontentloaded, networkidle0, networkidle2

waitUntil tells page.goto() (and page.setContent(), page.reload(), page.waitForNavigation()) when to consider the page loaded. The default is load. The two network-idle values count open network connections (pptr.dev: PuppeteerLifeCycleEvent):

ValueResolves whenUse it for
loadThe load event fires: the HTML and its images, scripts and styles are loadedPages without later data requests
domcontentloadedThe HTML is parsed; images and late scripts may still be loadingThe fastest start, followed by waitForSelector()
networkidle0No network connections for at least 500 msPages that finish all requests; can hang on pages that poll forever
networkidle2No more than 2 network connections for at least 500 msPages that keep a socket or analytics beacon open

networkidle2 is the safer of the two idle options on real sites, because chat widgets, analytics and live updates often keep one or two connections open, and networkidle0 would then wait until the 30-second timeout. Both values are still part of the current API. For scraping, the most reliable wait is usually neither: load with domcontentloaded, then wait for the element that holds your data.

Three more waits you will use:

  • page.waitForSelector(selector, { visible: true }) waits for an element to exist and be visible.
  • page.waitForNetworkIdle({ idleTime: 500 }) waits for the network to go quiet at any point, for example after a click or a scroll (pptr.dev: waitForNetworkIdle).
  • page.waitForResponse(res => res.url().includes('/api/')) waits for a specific data request, and gives you its JSON.

page.waitForTimeout() was removed in Puppeteer 22. If an old tutorial uses it, replace it with one of the waits above, or with await new Promise(r => setTimeout(r, 1000)) when you really need a fixed pause.

Extracting data: $eval, $$eval, evaluate and locators

Puppeteer gives you four ways to read a page; pick by how much data you need.

  • page.$eval(selector, fn) runs fn on the first match: await page.$eval('h1', el => el.textContent).
  • page.$$eval(selector, fn) runs fn on the array of all matches, as in the script above.
  • page.evaluate(fn) runs any code in the page and returns serializable data; use it to read window variables or embedded JSON.
  • page.locator(selector) returns a locator that waits for the element to be ready before it clicks, fills or reads it (pptr.dev: Locators).

Many sites ship their data as JSON inside the page, in a <script type="application/ld+json"> tag or a framework state object. Reading that JSON is more stable than reading the visible layout:

JavaScript
const products = await page.$$eval('script[type="application/ld+json"]',
  tags => tags.map(t => JSON.parse(t.textContent)));

Clicking buttons, filling forms and following pagination

Click with page.click(selector) or a locator; when the click opens a new page, start waiting for the navigation before you click:

JavaScript
await page.locator('button.load-more').click();                 // same page, more items
await Promise.all([page.waitForNavigation(), page.click('li.next > a')]);  // new page
await page.locator('input[name="q"]').fill('running shoes');    // type into a form field

To scrape every page of a list, loop until the "Next" link disappears:

JavaScript
const all = [];
let url = 'https://quotes.toscrape.com/js/';
while (url) {
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page.waitForSelector('.quote');
  all.push(...await page.$$eval('.quote .text', els => els.map(e => e.textContent)));
  url = await page.$eval('li.next > a', a => a.href).catch(() => null);  // null on the last page
}

Reading the next page's URL and calling page.goto() is sturdier than clicking, because you can retry a URL and resume from it after a crash.

Scraping infinite scroll pages with Puppeteer

Scroll to the bottom, wait for the new items to load, and stop when the page height stops growing. The cap on rounds keeps a feed that never ends from running forever.

JavaScript
await page.goto('https://quotes.toscrape.com/scroll', { waitUntil: 'networkidle2' });
let lastHeight = await page.evaluate(() => document.body.scrollHeight);
for (let round = 0; round < 30; round++) {
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  await page.waitForNetworkIdle({ idleTime: 500, timeout: 10_000 }).catch(() => {});
  const height = await page.evaluate(() => document.body.scrollHeight);
  if (height === lastHeight) break;            // nothing new loaded
  lastHeight = height;
}
const texts = await page.$$eval('.quote .text', els => els.map(e => e.textContent));

Infinite feeds usually load each batch from a JSON endpoint. Open the browser's DevTools, Network tab, filter by Fetch/XHR and scroll: if you see a request like /api/quotes?page=2, calling that endpoint directly is faster than scrolling, and you can do it without a browser.

Screenshots with Puppeteer

page.screenshot() saves the viewport, the full page or a clipped area as PNG, JPEG or WebP; an element handle can screenshot only itself (pptr.dev: screenshot).

JavaScript
await page.setViewport({ width: 1366, height: 900 });
await page.screenshot({ path: 'page.png', fullPage: true });
const card = await page.$('.quote');
await card.screenshot({ path: 'first-quote.png' });

Screenshots are useful as evidence for price and ad checks, and for debugging a selector that returns nothing on the server but works on your laptop.

Puppeteer performance for scraping

A browser is the most expensive way to fetch a page, so most of the speed comes from doing less in it:

  1. Reuse one browser

    Launching Chrome takes far longer than opening a page. Launch once and open a new page, or a new browser context with browser.createBrowserContext(), per job.

  2. Block what you do not read

    Images, fonts and media are most of a page's weight, and every byte also counts against your proxy traffic:

JavaScript
await page.setRequestInterception(true);
page.on('request', req => {
  if (['image', 'font', 'media'].includes(req.resourceType())) req.abort();
  else req.continue();
});
  1. Wait for the selector, not for idle

    Domcontentloaded plus waitForSelector() returns as soon as the data is there; networkidle0 waits for every tracker.

  2. Limit concurrency

    Run a fixed number of pages at once: start low, watch memory and CPU, raise the number while both stay comfortable, and close each page when it is done.

  3. Skip the browser where you can

    If a data request appears in the Network tab, call it with fetch.

For many URLs, puppeteer-cluster is an open-source library outside the Puppeteer project that runs a pool of pages or browser contexts with a queue, retries and a concurrency limit; its latest release (0.25.0, November 2025) lists Puppeteer 24 or later as its peer dependency, so check it against your Puppeteer version before you rely on it.

JavaScript
import { Cluster } from 'puppeteer-cluster';
const cluster = await Cluster.launch({ concurrency: Cluster.CONCURRENCY_CONTEXT, maxConcurrency: 4 });
await cluster.task(async ({ page, data: url }) => {
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  console.log(url, await page.title());
});
['https://quotes.toscrape.com/js/page/1/', 'https://quotes.toscrape.com/js/page/2/'].forEach(u => cluster.queue(u));
await cluster.idle();
await cluster.close();

Using proxies with Puppeteer

Puppeteer takes a proxy as a Chrome launch flag, and the username and password through page.authenticate(); Chrome does not read credentials from the proxy URL. The ProxyField gateway can give you a new residential IP on every request, or keep one IP for a sticky session of 30 s to 36 h.

JavaScript
const browser = await puppeteer.launch({ args: ['--proxy-server=http://gate.proxyfield.io:7000'] });
const page = await browser.newPage();
await page.authenticate({ username: '<login>-country-us-city-new_york', password: '<password>' });
await page.goto('https://api.ipify.org?format=json');
console.log(await page.$eval('body', b => b.textContent));   // the exit IP

<login>-country-us-city-new_york asks for an IP in New York, United States. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. Per-context proxies, SOCKS5, rotation and the common proxy errors are covered in the Puppeteer proxy setup guide.

When a site still blocks you, more IPs rarely fix it alone: slow down, send the headers a real Chrome sends, keep cookies within a session, and see how anti-bot systems detect scrapers. Collect only public data, and check the site's terms; see is web scraping legal.

Puppeteer and ProxyField: what a scraping job costs in traffic

Residential proxies are billed per GB, so a browser scraper's cost depends on page weight. Measure it: open DevTools, Network tab, reload, and read the "transferred" total at the bottom; then block images, fonts and media and measure again. Multiply the per-page figure by your page count to size the purchase. ProxyField residential proxies cost $2.95/GB for 1 GB down to $1.95/GB from 1,000 GB, with no subscription; the traffic never expires, and all purchases are final. See residential and mobile proxy pricing or the residential proxies page.

Web Scraping With Puppeteer FAQ

Is Puppeteer good for web scraping?

ProxyField's residential proxies (412M+ residential IPs, from $1.95/GB) run under Puppeteer with one launch flag and page.authenticate(), and Puppeteer is good for web scraping pages that build their content with JavaScript. For pages whose data is already in the HTML, a plain HTTP request with Cheerio is faster and uses far less traffic.

How do I scrape a website with Puppeteer?

Launch the browser, open the URL with page.goto(), wait for the element that holds your data with page.waitForSelector(), and read it with page.$$eval(). Save the result as JSON or CSV and close the browser. The full script is in the step-by-step section above.

What does networkidle2 mean in Puppeteer?

networkidle2 makes page.goto() resolve once there have been no more than 2 network connections for at least 500 ms. It suits pages that keep an analytics beacon or a live socket open, where networkidle0 would wait until the timeout.

When should you use networkidle2 instead of networkidle0?

Use networkidle2 on real-world sites, which often keep one or two connections open, and networkidle0 only on pages that finish every request, such as your own test pages. For scraping, domcontentloaded followed by waitForSelector() is usually faster than either.

Was networkidle0 removed from Puppeteer?

No. networkidle0 and networkidle2 are still valid waitUntil values for page.goto() and page.setContent() in the current API. What Puppeteer 22 removed is page.waitForTimeout(); use waitForSelector(), waitForNetworkIdle() or a plain setTimeout promise instead.

How do I wait for a page to load in Puppeteer?

Pass waitUntil to page.goto() (load, domcontentloaded, networkidle0 or networkidle2), then wait for the element you need with page.waitForSelector(). After a click or a scroll, page.waitForNetworkIdle() waits until the new requests have finished.

How do I click buttons in Puppeteer?

Use page.click('selector') or page.locator('selector').click(), which waits until the button is visible and enabled. If the click opens a new page, wrap it as await Promise.all([page.waitForNavigation(), page.click('selector')]) so the wait starts before the click.

How do I scrape infinite scroll pages with Puppeteer?

Scroll to the bottom with window.scrollTo(0, document.body.scrollHeight), wait for the network to go idle, and repeat until the page height stops growing, with a cap on the number of rounds. If the Network tab shows a JSON request per batch, call that endpoint directly instead.

How do I take a screenshot with Puppeteer?

Call await page.screenshot({ path: 'page.png', fullPage: true }) for the whole page, or elementHandle.screenshot() for one element. Set the viewport with page.setViewport() first if you need a fixed size.

How do you scrape multiple pages using Puppeteer?

Loop over the URLs, or read the "Next" link on each page and goto() it until there is none. For many URLs, run a fixed number of pages in parallel from one browser, or use a queue such as puppeteer-cluster, and close each page when it is done.

Show 5 more questionsShow fewer questions

How do I optimize Puppeteer performance for scraping?

Launch the browser once, block images, fonts and media with request interception, wait for selectors instead of network idle, and cap concurrency. Where the page loads its data from a JSON endpoint, call that endpoint with fetch and skip the browser.

What is the function of puppeteer-cluster?

puppeteer-cluster runs a pool of Puppeteer pages, browser contexts or browsers with a job queue, a concurrency limit and retries. It is maintained outside the Puppeteer project, so check that its latest release supports your Puppeteer version.

Can I scrape any website with Puppeteer?

Technically Puppeteer can load almost any page a browser can, but some sites rate-limit or block automated traffic, and some data sits behind logins you are not entitled to use. Collect public data, pace your requests, and check the site's terms first.

What does the puppeteer npm package include?

npm i puppeteer installs the library and downloads a matching build of Chrome for Testing, so the browser version always fits the API. puppeteer-core is the same library without the browser download, for when you bring your own Chrome.

How do I use Puppeteer with my own Chrome instead of the downloaded one?

Install puppeteer-core, which downloads no browser, and pass executablePath: '/path/to/chrome' or channel: 'chrome' to puppeteer.launch(). Keep that Chrome close to the version your Puppeteer release expects, or some calls may fail.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.