Puppeteer is the headless browser to pick when you already have a Node.js codebase running it. For a brand-new scraper, Playwright is 4x faster on cold-start (I measured) and drives Chromium, Firefox, and WebKit. The Puppeteer case is “we already run it, the team knows the API, and puppeteer-extra-plugin-stealth is wired up.” That’s the room this article writes for.
The minimal scraper is six lines.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://quotes.toscrape.com/js/');
const quotes = await page.$$eval('.quote .text', els => els.map(el => el.textContent));
console.log(quotes.slice(0, 3));
await browser.close();This guide walks through that scraper and the patterns that scale it. It covers selecting and waiting on dynamic content, the three headless modes in 2026 and why the new mode is slower than the legacy one, what puppeteer-extra-plugin-stealth patches on default Chromium, when resource blocking pays off, and the concurrency point where adding workers stops helping (5 for the workload I tested).
Common Takeaways:
puppeteer.launch() → newPage → goto → $$eval → closeis what every Puppeteer scraper does. Every pattern in this article builds on those five calls.- Default headless Puppeteer leaks
HeadlessChromein the UA in 2026, including the new headless mode. Applypuppeteer-extra-plugin-stealthfor any anti-bot target. puppeteer-core+ system Chrome is what you want in Docker. The defaultpuppeteerpackage downloads a 416 MB Chromium you don’t need on a server.- Concurrency saturates at 5 parallel pages on the tested workload. Past that, you trade RAM (1.8 GB at conc 20) for diminishing throughput.
- Playwright is 4x faster cold-start than Puppeteer in my measurement. Stay on Puppeteer for existing codebases and the older puppeteer-extra ecosystem. Start new projects on Playwright.
You’ll need Node 22+, basic async/await, and Chrome DevTools.
When to use Puppeteer in 2026
Puppeteer makes sense when you already run it in production, target Chromium only, or depend on the puppeteer-extra plugin ecosystem. Outside those three cases, Playwright is 4x faster on cold-start and uses 2.3x less RAM.
| Puppeteer | Playwright | |
|---|---|---|
| Cold-start (ms, median of 5 runs) | 1004 | 251 |
| Chromium RSS at idle (MB) | 379 | 167 |
| npm downloads / week | 11M | 62M |
| Browsers driven | Chromium only | Chromium, Firefox, WebKit |
| Maintainer | Google Chrome DevTools | Microsoft |
| Latest release | 2026-05-26 | 2026-06-15 |
*-extra-plugin-stealth origin | First-class (2018) | Through playwright-extra bridge (same plugin) |
Both run Chromium 149 in 2026.
You already run Puppeteer in production
The API surface between the two is close enough that translating a scraper is mechanical. Migration takes roughly a week of test rewrites for a 20-30 file codebase. That work saves sub-second-per-launch on cold-start, which only matters on workloads that spin up a fresh browser per task (Lambda, Cloud Run, anything that scales to zero). On a long-running scraper that holds the browser open, the 750 ms gap is paid once at process start, not per page.
Chromium-only fits your targets
Puppeteer drives Chromium and nothing else. Playwright’s multi-browser support matters when a target detects Chromium-specific JS APIs or when a site has rendering quirks that only reproduce in Safari. If neither applies, Chromium-only is fine.
You depend on the puppeteer-extra plugin ecosystem
puppeteer-extra-plugin-stealth is the original stealth plugin, written for Puppeteer in 2018. It works against Playwright through the playwright-extra bridge with the same plugin code and identical results. But if your team wired it up against Puppeteer years ago, switching brings no new patches, just refactoring.
Installing Puppeteer
There are two Puppeteer packages on npm. puppeteer downloads a matching Chromium binary into a cache directory on first install, so a single npm install puppeteer gives you a working scraper without any system setup. puppeteer-core is just the driver. The Chrome binary it controls has to be installed separately on your system.
For local development, install puppeteer. For Docker images, CI runners, or anywhere image size matters, puppeteer-core and a system Chrome are roughly 4x smaller on disk and give you control over the Chrome version.
npm install puppeteer
# or
npm install puppeteer-coreBoth packages contain the same 28 MB of JavaScript, and the difference is whether install also downloads Chromium.
| Setup | node_modules | Chrome download | Total |
|---|---|---|---|
puppeteer | 28 MB | ~416 MB (Chrome 149 for win64, ~390 MB for mac-arm64) | ~444 MB |
puppeteer-core | 28 MB | n/a (use system Chrome) | 28 MB |
The downloaded Chrome lands in ~/.cache/puppeteer/chrome/<platform>-<version>/, and each Puppeteer release pins a matching Chromium version, so the cache slowly accumulates one Chrome per major Puppeteer version installed over time. On my machine three Chrome versions are stacked at ~1.2 GB total.
For puppeteer-core, set executablePath to the system Chrome:
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.launch({
// macOS
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
// Linux: '/usr/bin/google-chrome-stable'
});In Docker, install Chromium with apt (apt-get install chromium) and use that path. That’s roughly 120 MB of image overhead instead of ~440 MB if you install the puppeteer package, and you control the Chrome version explicitly.
Your first Puppeteer scraper
The pattern from the intro extracts more than just text. Here’s the same scrape with author and tags pulled per quote, against the same JS-rendered target.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://quotes.toscrape.com/js/');
const quotes = await page.$$eval('.quote', els => els.map(el => ({
text: el.querySelector('.text')?.textContent,
author: el.querySelector('.author')?.textContent,
tags: [...el.querySelectorAll('.tags .tag')].map(t => t.textContent),
})));
console.log(quotes[0]);
await browser.close();Five things happen in that script.
puppeteer.launch()spawns a Chromium process. The function returns aBrowserinstance.browser.newPage()opens a tab in that Chromium instance. Multiple pages can run in parallel against the same browser, which is how Puppeteer concurrency works.page.goto(url)navigates to the URL and waits for theloadevent. For JS-rendered pages where data shows up afterload, you’ll need extra wait logic.page.$$eval(selector, callback)finds all matching elements, serializes them as an array, and passes them tocallbackinside the browser’s JavaScript context. The callback returns whatever you build from the DOM. The return value crosses the CDP wire back to Node.js as plain JSON.browser.close()kills the Chromium process. Skip this and you have a zombie Chromium using 400 MB of RAM until your Node.js process exits.
Scraping patterns
The example above is enough for a static page. Pages that fetch data after load, fan out across pagination, or expect clicks need patterns past $$eval.
Extracting data with selectors
Puppeteer exposes four selector methods, differing on how many elements they match and whether they return a value or a live DOM handle.
| Method | Returns | Use when |
|---|---|---|
page.$(selector) | First matching ElementHandle, or null | You need to interact with one element (click, type) |
page.$$(selector) | Array of ElementHandle | You need to interact with several elements |
page.$eval(selector, fn) | Whatever fn returns | You need a value from the first match |
page.$$eval(selector, fn) | Whatever fn returns | You need values from all matches |
Most scraping is $$eval because you usually want all matching rows parsed into objects and returned to Node.js. The example above uses it.
$eval is the same idea for a single value. Grab the page title from <h1> with one call.
const title = await page.$eval('h1', el => el.textContent);$ and $$ return ElementHandle objects you can click(), type(), or pass to other methods that need a live DOM reference. The clicks-and-forms patterns below use them.
Waiting for dynamic content
For JS-rendered pages, page.goto() returns when the load event fires, but the data you want often shows up after load. Three wait patterns cover most cases.
waitForSelector waits until the matching element appears in the DOM.
await page.goto('https://quotes.toscrape.com/js/');
await page.waitForSelector('.quote');
const quotes = await page.$$eval('.quote', els => els.map(el => el.textContent));waitForFunction runs a callback in the page context on every animation frame until it returns truthy. Useful when “ready” means more than “the element exists”. For example, wait until at least 10 quotes have rendered.
await page.waitForFunction(
() => document.querySelectorAll('.quote').length >= 10,
{ timeout: 5000 }
);waitForNavigation waits for the next navigation event (link click, form submit, JS redirect). Use Promise.all to start the wait before the action so you don’t miss the navigation event.
await Promise.all([
page.waitForNavigation(),
page.click('a.next-page'),
]);Clicks, forms, screenshots
Three common interactions, each running against a selector or an ElementHandle.
// Click
await page.click('button.load-more');
// Fill a form field
await page.type('input[name="email"]', 'test@example.com');
// Screenshot the whole page
await page.screenshot({ path: 'page.png', fullPage: true });For complex interactions like drag, focus management, or keyboard shortcuts, get an ElementHandle from $ and call its methods directly.
const search = await page.$('input[type="search"]');
await search.focus();
await search.type('puppeteer scraping');
await page.keyboard.press('Enter');
await page.waitForNavigation();Crawling multiple pages
The simplest pagination is a loop that follows a “Next” link until it disappears.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
const allBooks = [];
let url = 'https://books.toscrape.com/catalogue/page-1.html';
while (url) {
await page.goto(url);
const books = await page.$$eval('article.product_pod', els => els.map(el => ({
title: el.querySelector('h3 a').getAttribute('title'),
price: el.querySelector('.price_color').textContent,
})));
allBooks.push(...books);
const next = await page.$('.next a');
url = next ? await page.evaluate(el => el.href, next) : null;
}
console.log(`${allBooks.length} books from books.toscrape.com`);
await browser.close();The loop opens one Chromium and reuses the same page across all 50 listing pages. That’s about 10x cheaper than spawning a new browser per request. For crawls past a few hundred URLs, switch to Crawlee. It handles request queues, retries, deduplication, and per-context fingerprints without manual loops.
Headless modes in 2026
Puppeteer’s headless option in 2026 accepts three values that launch Chromium differently, with different trade-offs on speed, RAM, and what the browser leaks about itself. Chromium 119 dropped the old --headless flag in favor of a new headless mode that runs the full browser stack, so Puppeteer kept the old behavior as 'shell' and uses the new mode by default.
| Value | What it is |
|---|---|
headless: true | New headless mode, 2026 default |
headless: 'shell' | Legacy shell mode, deprecated but still functional |
headless: false | Headful, full browser window painted |
I benchmarked all three on the same target with 5 cold-launches per mode in a fresh Node process. The results contradict two common claims about headless Puppeteer.
Cold-start and RAM
| Mode | Cold-start (ms, median) | Peak RSS (MB) |
|---|---|---|
'shell' (legacy) | 406 | 206 |
true (new headless) | 1072 | 416 |
false (headful) | 858 | 424 |
Legacy shell is 2.6x faster on cold-start and uses half the RAM of either new headless or headful. The “new headless” mode (Chromium 119+, default in Puppeteer since version 23) runs the same binary as headful with no window painted, so headful and new headless land within 4% of each other on every metric. Multiple GitHub issues (#10071, #3938, #12982) report new headless slower than legacy shell, and my benchmark confirms it.
Fingerprint differences
Cold-start is one axis. Anti-bot detection is the other, and the three modes differ in what each one leaks about itself.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
const fp = await page.evaluate(() => ({
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
userAgent: navigator.userAgent,
}));
console.log(fp);
await browser.close();Running that script with each headless mode produces this matrix.
| Surface | 'shell' | true (new) | false (headful) |
|---|---|---|---|
navigator.webdriver | true | true | true |
navigator.plugins.length | 0 | 5 | 5 |
navigator.userAgent (substring check) | contains HeadlessChrome | contains HeadlessChrome | contains Chrome |
Two findings worth flagging. First, legacy shell mode exposes navigator.plugins.length: 0. That’s a strong “I’m headless” signal any anti-bot scanner picks up. New headless and headful both expose the realistic 5 plugins Chromium reports in 2026.
Second, the User-Agent. Common dev belief is that new headless mode has a clean Chrome UA, but both 'shell' and true modes still include HeadlessChrome in the UA in 2026. Only false (headful) returns a clean Chrome UA by default. The stealth plugin rewrites the UA on top of any headless mode.
Which mode to use
For CI and unit tests where speed matters and detection doesn’t, use 'shell'. It’s 2.6x faster cold-start and half the RAM. The deprecation warning means future Puppeteer versions will drop it, but as of 25.x it still works.
For production scraping with anti-bot pressure, use true with the stealth plugin. Stealth rewrites the UA, patches navigator.webdriver, and fixes the other tells the next section covers.
For debugging, use false. The 4% performance cost vs new headless doesn’t matter when you’re watching it interact.
Anti-bot and the stealth plugin
Default Puppeteer announces itself as automation on the first page load. The user-agent contains HeadlessChrome, navigator.webdriver returns true, and other surfaces expose values a real Chrome doesn’t produce. Anti-bot vendors read all of this before your code has a chance to interact with the DOM.
puppeteer-extra-plugin-stealth patches those leaks. Six sub-plugins override the surfaces, applied through the puppeteer-extra wrapper that lets you use() plugins on a Puppeteer instance.
Running the inspection script from earlier against default Puppeteer and the same launch with stealth applied produces this matrix.
| Surface | Default Puppeteer | With stealth plugin |
|---|---|---|
navigator.webdriver | true | false |
navigator.plugins.length | 5 | 5 (no change needed) |
navigator.languages | ["en-US"] | ["en-US", "en"] |
navigator.hardwareConcurrency | 8 (real) | 4 (faked to common value) |
navigator.userAgent | … HeadlessChrome/149.0.0.0 … | … Chrome/149.0.0.0 … |
The four changes that matter for anti-bot detection are webdriver, languages, hardwareConcurrency, and the UA string. navigator.plugins.length stays at 5 because new headless mode exposes the realistic 5-plugin list in 2026.
Install both packages and apply the plugin like this.
npm install puppeteer puppeteer-extra puppeteer-extra-plugin-stealthimport { addExtra } from 'puppeteer-extra';
import puppeteer from 'puppeteer';
import Stealth from 'puppeteer-extra-plugin-stealth';
const pextra = addExtra(puppeteer);
pextra.use(Stealth());
const browser = await pextra.launch();
const page = await browser.newPage();
await page.goto('https://quotes.toscrape.com/js/');
// ... rest of scraper code ...
await browser.close();Maintenance status
The plugin’s last release came in March 2023, with the last commit in July 2024.
I verified the inspection results above against the current Chromium 149, and the patches still hold. Chrome releases a new major version every 4-6 weeks, and any of them could change a detectable surface the plugin doesn’t follow.
If you’re betting an anti-bot strategy on this plugin in 2026, plan a fallback. Custom addInitScript calls override the same surfaces in your own code, and a managed scraping API handles fingerprint upstream.
What stealth doesn’t fix
Stealth fixes the browser fingerprint and nothing else. Your IP and your behavior pass through untouched, so if a target rate-limits by IP or watches for inhuman mouse and keyboard cadence, a clean fingerprint won’t help.
Anti-bot vendors in 2026 layer three checks (fingerprint, IP, behavior), and fixing one without the others gets you a slightly more polite rejection.
Proxies
IP is the second anti-bot layer after fingerprint. You’ll need rotation for any target that rate-limits by source address. Pass a proxy with the --proxy-server Chrome flag at launch time, and call page.authenticate() for basic auth credentials.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
args: ['--proxy-server=http://your-proxy-host:8080'],
});
const page = await browser.newPage();
await page.authenticate({ username: 'user', password: 'pass' });
await page.goto('https://quotes.toscrape.com/js/');That covers the static-proxy case. Production scrapers usually need per-request rotation, sticky sessions across multi-step flows, and provider-specific auth headers, all covered in the puppeteer-proxy guide.
Behavior emulation
Behavior is the third anti-bot layer. Vendors detect mouse paths that teleport rather than drift, keystrokes that arrive in microseconds rather than the 50-150 ms a human typist needs, scroll positions that snap to elements without intermediate scrolls, and timing between actions that looks scripted.
Puppeteer’s interaction API has options for each of these.
page.mouse.move(x, y, { steps: N }) moves the cursor through N intermediate points instead of jumping directly. A real user moves the mouse through dozens of frames, and a script using steps: 10 looks more natural than the default steps: 1.
page.type(selector, text, { delay: 50 }) waits 50 ms between each character. The default is 0 (instant paste). 50-100 ms matches normal human typing speed.
For random sleeps between actions, wrap setTimeout in a Promise. Real humans don’t fire actions on a precise interval.
async function rand(min, max) {
return new Promise(r => setTimeout(r, Math.random() * (max - min) + min));
}
await page.click('a.product-link');
await rand(800, 2200);
await page.click('button.add-to-cart');
await rand(1500, 3000);That’s the basic toolkit. It covers the common behavior patterns vendors run, including mouse cadence, typing speed, scroll, and timing. ghost-cursor automates more realistic Bezier-curve mouse paths if your target detects straight-line movement, and the simpler steps, delay, and setTimeout patterns work for most cases.
Behavior detection rarely fires without fingerprint detection firing first. Fix navigator.webdriver and the UA before reaching for ghost-cursor or random delays, because fingerprint leaks get caught sooner.
Speed and scaling
Two techniques take most scrapers from “works on one page” to “handles thousands per hour”. Resource blocking cuts bandwidth and time per page, and concurrency runs multiple pages in the same browser process.
Resource blocking
Puppeteer’s setRequestInterception API lets you drop resource requests before they hit the network. On a typical scrape, most requests are images, CSS, and fonts that the scraper doesn’t need to parse.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', req => {
if (['image', 'stylesheet', 'font', 'media'].includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
await page.goto('https://books.toscrape.com/');
const books = await page.$$eval('article.product_pod h3 a', els =>
els.map(el => el.getAttribute('title'))
);
console.log(`${books.length} books, no images loaded`);
await browser.close();I measured this on three targets. The impact varies with how much of the page is images or CSS.
| Target | Without blocking (ms) | With blocking (ms) | Speedup | Requests saved |
|---|---|---|---|---|
| quotes.toscrape.com/js/ (JS-rendered) | 3298 | 1991 | 1.66x | 2 of 5 |
| books.toscrape.com/ (32 image requests) | 2456 | 2342 | 1.05x | 32 of 39 |
| Minimal landing page | 1720 | 1728 | 1.00x | 0 of 2 |
Time savings come from JS-heavy pages where blocked resources were on the critical path. quotes.toscrape.com/js/ got 1.66x faster because Puppeteer’s networkidle0 wait completed sooner with fewer requests to wait on.
Bandwidth savings scale with image-heavy pages, not with time. Blocking 32 image requests on books.toscrape.com/ cut requests by 82% but only saved 5% of load time. Over 1000 pages at ~7 KB per image, that’s roughly 220 MB of bandwidth saved. On residential proxies billed by GB, saved bandwidth converts directly to dollars.
Concurrency curve
Multiple pages run in parallel against the same Puppeteer browser. Each new page opens a Chromium tab that shares process memory with the others. Concurrency past 1 speeds things up. Past about 5, adding more parallel pages costs RAM but returns almost no throughput.
I measured 500 pages of books.toscrape.com at four concurrency levels. Each level ran against one shared browser instance with a fresh page per URL.
import puppeteer from 'puppeteer';
import pLimit from 'p-limit';
const browser = await puppeteer.launch();
const limit = pLimit(5); // adjust this
await Promise.all(urls.map(url => limit(async () => {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded' });
const data = await page.$eval('h1', el => el.textContent);
await page.close();
return data;
})));
await browser.close();p-limit caps parallel operations at N. Each task opens a new page, does work, closes the page. The shared browser stays open across all tasks, so the ~1000 ms cold-start cost happens once.
| Concurrency | Total (s) | Pages/sec | Peak Chrome RSS (MB) | Speedup vs sequential |
|---|---|---|---|---|
| 1 | 273.2 | 1.83 | 620 | 1.00x |
| 5 | 127.3 | 3.93 | 798 | 2.15x |
| 10 | 114.8 | 4.36 | 1114 | 2.38x |
| 20 | 103.6 | 4.83 | 1813 | 2.64x |
Going from sequential to concurrency 5 more than doubled throughput. Past 5, the returns get marginal. Concurrency 20 gets you 22% more throughput than conc 5, but at 2.3x the peak RAM.
Concurrency 5 hit 92% of the throughput at conc 20 while using 44% of the RAM, with the same 100% success rate.
Higher concurrency starts helping again on slow targets where each request takes seconds. If you have 16+ GB of RAM headroom and the target isn’t rate-limiting, push past 5 and measure again.
When to move to Crawlee
Manual patterns like the one above are enough for scrapes under a few hundred URLs. Past that, Crawlee’s PuppeteerCrawler handles the queue, retries, per-context fingerprints, and auto-scaling concurrency for you.
import { PuppeteerCrawler } from 'crawlee';
const crawler = new PuppeteerCrawler({
maxRequestsPerCrawl: 500,
async requestHandler({ page, request, pushData }) {
const data = await page.$eval('h1', el => el.textContent);
await pushData({ url: request.url, data });
},
});
await crawler.run(['https://books.toscrape.com']);For crawls under ~100 pages, the manual pattern above is enough. Past that, install Crawlee.
A complete scraper
The examples so far each covered one improvement. Production scrapers combine them into one pattern that uses stealth for fingerprint, proxy for IP, random delays for behavior, resource blocking for speed, and concurrency at 5 for scale.
import { addExtra } from 'puppeteer-extra';
import puppeteer from 'puppeteer';
import Stealth from 'puppeteer-extra-plugin-stealth';
import pLimit from 'p-limit';
const pextra = addExtra(puppeteer);
pextra.use(Stealth());
async function rand(min, max) {
return new Promise(r => setTimeout(r, Math.random() * (max - min) + min));
}
const browser = await pextra.launch({
headless: true,
args: ['--proxy-server=http://your-proxy-host:8080'],
});
const limit = pLimit(5);
const urls = [/* your target URLs */];
const results = await Promise.all(urls.map(url => limit(async () => {
const page = await browser.newPage();
await page.authenticate({ username: 'user', password: 'pass' });
await page.setRequestInterception(true);
page.on('request', req => {
if (['image', 'stylesheet', 'font', 'media'].includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
await page.goto(url, { waitUntil: 'domcontentloaded' });
await rand(800, 2200);
const data = await page.$$eval('.product', els => els.map(el => ({
title: el.querySelector('.title')?.textContent,
price: el.querySelector('.price')?.textContent,
})));
await page.close();
return { url, data };
})));
await browser.close();
console.log(`Scraped ${results.length} URLs`);Six things happen in that script.
puppeteer-extrawraps Puppeteer and applies the stealth plugin. All browsers launched frompextrainherit the fingerprint patches.--proxy-serverat launch routes requests through your proxy.page.authenticatesupplies basic-auth on the first request that needs it.p-limit(5)caps parallel page work at 5, the saturation point from the earlier benchmark.setRequestInterceptiondrops images, CSS, fonts, and media before they hit the network.rand(800, 2200)inserts a human-range wait between actions.- The shared
browserstays open across all URLs so the ~1000 ms cold-start happens once.
For a real target, swap the placeholders (proxy URL, credentials, target URL list, extraction selectors) and the pattern runs as-is. This is the shape most production scrapers converge to in 2026.
Common errors and pitfalls
Five errors show up more than others across Puppeteer scrapers. Each has a specific cause and fix.
Error: Failed to launch the browser process
Puppeteer needs a set of system libraries to launch Chromium. On Debian/Ubuntu that’s roughly 15 packages (libnss3, libxkbcommon-x11-0, libgbm1, libatk-bridge2.0-0, and others). Fresh Node Docker images don’t include them by default.
Install the system deps with apt-get install in your Dockerfile, or start from Puppeteer’s official base image which has them preinstalled.
TimeoutError: Navigation timeout of 30000 ms exceeded
page.goto() waits for the load event by default with a 30-second timeout. Slow sites or infinite JS pages hit that ceiling.
Use a lighter wait condition and bump the timeout.
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });domcontentloaded fires after the initial HTML parse, without waiting for images or slow XHR calls.
Protocol error (Page.navigate): Target closed
The browser was closed before an operation completed. Two common patterns cause this. Either browser.close() fires inside a task that another task hasn’t finished, or you’re reusing a page after page.close().
Keep the browser alive until all page operations settle. The concurrent pattern earlier put Promise.all before browser.close() for that reason.
Zombie Chrome processes in Docker
Chrome spawns multiple child processes. When Node exits without cleanup, some of those child processes stay running as zombies until the container is killed. In long-running containers, they pile up and consume memory.
Run Docker with --init to reap zombie processes automatically.
docker run --init your-scraper-imageAnd always call browser.close() in a finally block so it runs even when the scraper crashes.
page.evaluate returns undefined for values that should exist
page.evaluate needs a JSON-serializable return value. Functions, DOM element references, and circular structures come back as undefined in Node.
Return primitive values or plain objects. For DOM element references, use $eval or $$eval (which do the DOM extraction inside the browser) instead of returning the element itself.
FAQ
Is Puppeteer good for web scraping?
Yes, for JavaScript-rendered pages where you already run Puppeteer or need puppeteer-extra-plugin-stealth. For new projects starting today, Playwright is 4x faster on cold-start (251 ms vs 1004 ms) and uses 2.3x less RAM (167 MB vs 379 MB). Puppeteer still wins on existing codebases and the older stealth plugin ecosystem.
Is Puppeteer or Playwright better for web scraping?
Playwright, for new projects in 2026. It cold-starts 4x faster, uses 2.3x less RAM, drives Chromium, Firefox, and WebKit, and has 5.6x the npm install base. Puppeteer still makes sense on existing codebases or when you depend on puppeteer-extra plugins that were written for it first.
Which is better, Puppeteer or Selenium?
For JavaScript-only scraping, Puppeteer beats Selenium on API ergonomics, cold-start time, and community size. Selenium’s JavaScript bindings work fine, but the tool was designed for cross-language test automation (Java, Python, Ruby, C#). For a Node.js-only stack, Puppeteer’s tighter API handles scraping with less code than Selenium’s WebDriver bindings. Playwright beats both on cold-start and browser support, but if you’re weighing just Puppeteer vs Selenium, Puppeteer wins for pure JavaScript work.
Conclusion
Puppeteer works and will keep working for the next few years. If you’re already on it, keep going and layer stealth, proxies, and behavior delays. If you’re starting fresh, look at Playwright first. The benchmarks in this article all favor it.
At scale, the failures come from the stack around Puppeteer. Proxies return 503s at odd hours, containers hit memory limits after long runs, Chrome updates change fingerprint surfaces the plugin doesn’t follow. The patterns above make those problems catchable, and the specific fixes depend on what breaks.


