Playwright vs Puppeteer for web scraping: Playwright is the better default, because it works in four languages, waits for elements before every action, and ships a trace viewer and a code generator that make a broken scraper quick to fix. Puppeteer is a lean JavaScript library for Chrome and Firefox, and it stays a good choice when your scraper is already Node.js and Chrome-only.
Below is how they compare on what matters for scraping, using facts from playwright.dev and pptr.dev as of 23 October 2026, then the same scraper written in both and run against the same JavaScript-rendered page. At the end: how to run either one on a hosted browser built for scraping.
Playwright vs Puppeteer at a glance
| Playwright | Puppeteer | |
|---|---|---|
| Latest version on npm (23 Oct 2026) | 1.64.1, released 14 October 2026 | 25.15.0, released 20 October 2026 |
| Languages | TypeScript/JavaScript, Python, .NET, Java | JavaScript/TypeScript |
| Browsers | Its own Chromium, Firefox and WebKit builds, plus installed Chrome and Edge | Chrome for Testing and stable Firefox |
| Auto-waiting | Before every action | In the Locator API; page.$() and $$eval() don't wait |
| Selectors | Locators, including getByRole, getByLabel, getByText | CSS, plus ::-p-xpath(), ::-p-text(), ::-p-aria() |
| Network interception | page.route() / context.route(): abort, modify, mock, or rewrite responses | setRequestInterception(true): every request waits for your handler |
| Proxy per session | Per browser context | Per browser context (proxyServer) |
| Debugging | Trace Viewer (DOM snapshots, network, console per action) and codegen | Chrome performance traces; Chrome DevTools Recorder exports Puppeteer scripts |
| Stealth plugin | Via playwright-extra | Via puppeteer-extra (the plugin's original home) |
navigator.webdriver in headless mode | true (our test) | true (our test) |
| npm downloads, 12–18 Oct 2026 | 69.8 million | 6.7 million |
Languages and browsers
Playwright is "available for TypeScript, Python, .NET, and Java" and drives Chromium, Firefox and WebKit with one API (playwright.dev). npx playwright install chromium downloads the browser build that matches your Playwright version, and the channel option lets it drive the Google Chrome or Microsoft Edge installed on your machine (browsers docs).
Puppeteer describes itself as "a JavaScript library which provides a high-level API to control Chrome or Firefox over the DevTools Protocol or WebDriver BiDi" (pptr.dev). Since version 20 it works with Chrome for Testing, and since version 23 with stable Firefox (supported browsers). npm i puppeteer downloads a compatible Chrome during installation. The docs warn that package managers which block install scripts skip that download; npx puppeteer browsers install fetches it afterwards. In our test, installing Puppeteer 25.15.0 with npm i puppeteer downloaded Chrome for Testing 155.0.8059.84 on the first try.
For scraping, the browser list rarely matters, because nearly every scraper runs Chromium. The language list matters more: if your team writes Python, Java or C#, the choice is made. Puppeteer has no official version for them.
The same scraper in both
The task: scrape the first three pages of quotes.toscrape.com/js, a sandbox site whose quotes are written into the page by JavaScript, so a plain HTTP request sees none of them. Both scripts block images, fonts and stylesheets, and paginate by clicking the Next link. Install both libraries with npm i playwright puppeteer, run npx playwright install chromium, and save each script as an .mjs file.
Playwright:
import { chromium } from 'playwright';
const BLOCKED = new Set(['image', 'font', 'stylesheet']);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// Skip images, fonts and stylesheets: the quotes are in the HTML.
await page.route('**/*', (route) =>
BLOCKED.has(route.request().resourceType()) ? route.abort() : route.continue(),
);
await page.goto('https://quotes.toscrape.com/js/');
const quotes = [];
for (let pageNo = 1; pageNo <= 3; pageNo++) {
const cards = page.locator('div.quote');
await cards.first().waitFor(); // the quotes are written by JavaScript
for (const card of await cards.all()) {
quotes.push({
text: await card.locator('span.text').innerText(),
author: await card.locator('small.author').innerText(),
tags: await card.locator('a.tag').allInnerTexts(),
});
}
const next = page.locator('li.next > a');
if ((await next.count()) === 0) break;
await next.click(); // waits until the link is visible, stable and enabled
await page.waitForURL(`**/js/page/${pageNo + 1}/`);
}
console.log(quotes.length, quotes[0]);
await browser.close();Puppeteer:
import puppeteer from 'puppeteer';
const BLOCKED = new Set(['image', 'font', 'stylesheet']);
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
// Skip images, fonts and stylesheets: the quotes are in the HTML.
await page.setRequestInterception(true);
page.on('request', (request) => {
if (request.isInterceptResolutionHandled()) return;
if (BLOCKED.has(request.resourceType())) request.abort();
else request.continue();
});
await page.goto('https://quotes.toscrape.com/js/');
const quotes = [];
for (let pageNo = 1; pageNo <= 3; pageNo++) {
await page.waitForSelector('div.quote'); // the quotes are written by JavaScript
const rows = await page.$$eval('div.quote', (cards) =>
cards.map((card) => ({
text: card.querySelector('span.text').innerText,
author: card.querySelector('small.author').innerText,
tags: [...card.querySelectorAll('a.tag')].map((tag) => tag.innerText),
})),
);
quotes.push(...rows);
const next = await page.$('li.next > a');
if (!next) break;
await Promise.all([page.waitForNavigation(), next.click()]);
}
console.log(quotes.length, quotes[0]);
await browser.close();We ran both on 23 October 2026 on macOS (Apple silicon) with Node.js 23.9.0, Playwright 1.64.1 (Chromium 156.0.8078.4) and Puppeteer 25.15.0 (Chrome for Testing 155.0.8059.84). Both collected 30 different quotes and printed the same first record:
30 {
text: '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”',
author: 'Albert Einstein',
tags: [ 'change', 'deep-thoughts', 'thinking', 'world' ]
}The scripts are almost the same length. Three differences show up anyway:
- Pagination. In Puppeteer you start waiting for the navigation before you click, with
Promise.all. In Playwright you click, then wait for the URL you expect. - Extraction. Puppeteer's
$$eval()runs one function inside the page and returns plain objects. Playwright reads each field through a locator; itsevaluateAll()does the same thing as$$eval()if you prefer that style. - Request blocking. Playwright's route handler only runs for the URLs you match. Puppeteer's interception holds every request until a handler decides, which is why the docs recommend the
isInterceptResolutionHandled()check.
Auto-waiting and locators
Most flaky scrapers fail on timing: the script looks for an element a moment before the page has drawn it. Playwright runs actionability checks before every action. A click waits until the element is visible, stable, enabled and receiving events, and fails with a TimeoutError only if that doesn't happen in time (auto-waiting docs). Its locators can also find elements the way a person sees them, with getByRole, getByLabel, getByPlaceholder and getByTestId.
Puppeteer added the same idea with its Locator API, which its docs call "the recommended way to select an element and interact with it". Before a click, a locator makes sure the element is in the viewport, visible, enabled and has "a stable bounding box over two consecutive animation frames" (page interactions). The older calls are still there and are what the scraper above uses: page.$(), page.$$() and $$eval() query "without waiting", and waitForSelector "does not automatically retry the action if it fails". Puppeteer's selectors are CSS by default, extended with ::-p-xpath(), ::-p-text() and ::-p-aria() and the >>> combinator for shadow DOM.
So both have auto-waiting today. In Playwright it is the whole API; in Puppeteer it is one API next to older ones you need to avoid mixing up.
Network interception
Blocking heavy resources saves bandwidth on every page, which adds up when you pay per GB of proxy traffic. Capturing the JSON a page fetches is often cleaner than parsing its HTML.
Playwright's page.route() and context.route() can abort a request, continue it with changed headers or method, answer it with your own data, or fetch the real response and modify it (network docs). page.waitForResponse() grabs an API response triggered by a click. Proxies can be set for the whole browser or per context.
In Puppeteer, page.setRequestInterception(true) turns interception on, and from then on "every request will stall unless it's continued, responded or aborted" (request interception). If two handlers resolve the same request, Puppeteer throws Request is already handled!, so the docs describe a Cooperative Intercept Mode with priorities for code that registers several handlers. A proxy per context works through browser.createBrowserContext({ proxyServer }), with credentials set via page.authenticate (context options).
Both can do what a scraper needs. Playwright's version is simpler to combine with other code, because routes only touch the URLs they match.
Debugging: Trace Viewer and codegen
This is where Playwright pulls clearly ahead. When a scraper fails on page 47 at 3 a.m., you want to see what the page looked like. Playwright's Trace Viewer shows every action with DOM snapshots before and after it, the network requests, the console logs and a screenshot film strip (Trace Viewer docs). In a plain script, wrap the work in a trace:
const context = await browser.newContext();
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
// ... scrape ...
await context.tracing.stop({ path: 'trace.zip' });Open it with npx playwright show-trace trace.zip. In our test, a two-step run produced a 145 KB trace file. To write a first draft of a scraper, npx playwright codegen https://quotes.toscrape.com/js/ records your clicks as code and picks locators for the elements you point at (codegen docs).
Puppeteer's page.tracing.start() writes a performance trace "which can be opened in Chrome DevTools or timeline viewer" (Tracing API). That's a performance profile, not a step-by-step replay. For recording, Chrome's DevTools Recorder panel can export a recorded flow as a Puppeteer script (Recorder reference).
Stealth and detection
Neither library hides that it is automated. In our test, navigator.webdriver was true in headless Playwright Chromium and in headless Puppeteer Chrome for Testing, a flag any script on the page can read.
The usual fix is the same plugin for both: puppeteer-extra-plugin-stealth 2.11.2, used through puppeteer-extra 3.3.6 in Puppeteer or playwright-extra 4.3.6 in Playwright. The plugin started in the Puppeteer world, and none of the three packages has had a release since March 2023. We tested the setup in both libraries, with results, in our Playwright stealth guide. The short version: stealth patches the browser's JavaScript surface, not your IP address, your TLS handshake or how your scraper behaves, so it doesn't decide whether you get past serious bot protection.
Is Playwright or Puppeteer faster?
We timed the two scripts above, alternating them five times each on the same machine and network, measuring the whole script from launch to close. Playwright took 2.00 to 2.42 seconds (median 2.04), Puppeteer 2.18 to 2.47 seconds (median 2.30). Most of that time is waiting on the network, and the gap is about the size of the run-to-run noise, so we don't call either one faster. For scraping, speed comes from blocking resources you don't need, running pages in parallel and skipping the browser when a page doesn't need one, not from the library.
Puppeteer vs Playwright: which should you pick for scraping?
- Starting a new scraper: Playwright. Auto-waiting everywhere, simple request routing, and the Trace Viewer for when something breaks.
- Your team writes Python, Java or C#: Playwright, since Puppeteer is JavaScript-only.
- You have a working Puppeteer scraper: keep it, and use the Locator API for new code. Rewrite only when debugging starts costing you time.
- You want the smallest Chrome-only dependency in Node.js: Puppeteer is a fine fit.
- The site blocks headless browsers: neither library alone. You need real fingerprints and good IPs, covered next.
If you're also weighing Selenium, our Playwright vs Selenium comparison runs the same quotes scraper in Python with both.
Run Playwright or Puppeteer on the Scrape.ly browser
Picking the library is the easy part. Keeping a browser scraper alive means proxies, fingerprints, challenge pages, retries and scheduling. On Scrape.ly you push your scraper as an Actor and let the platform handle that, whichever library you chose.
The Scrape.ly browser is a hosted Chromium with anti-bot fingerprinting, Cloudflare Turnstile solving and its own residential proxy. It's off by default. You turn it on per Actor under Settings → Scrapely Browser and rebuild. From then on, every chromium.launch() in Playwright and every puppeteer.launch() connects to it over CDP without code changes, and so do Crawlee's PlaywrightCrawler and PuppeteerCrawler. Firefox and WebKit launches are redirected to it too.
The browser doesn't take a proxy URL. You pick where its traffic exits, and which operating system the fingerprint shows, with a browser configuration:
import { Actor, PlaywrightCrawler } from 'scrapely';
await Actor.main(async () => {
const browserConfiguration = await Actor.createBrowserConfiguration({
countryCode: 'US',
state: 'California',
os: 'windows',
});
const crawler = new PlaywrightCrawler({
browserConfiguration,
async requestHandler({ page }) {
await Actor.pushData({ title: await page.title() });
},
});
await crawler.run(['https://example.com']);
});Time zone and language follow the exit country unless you set them, so the fingerprint matches the IP, which is exactly what stealth plugins can't do on their own. You can choose Windows, macOS or Android, and a city or state. Browser crawlers such as PuppeteerCrawler take the same browserConfiguration option. It costs $0.45 per browser hour at list price, billed in 30-second blocks plus the browser's proxy traffic, and it counts toward the run's maximum charge, so a run can't spend more than the cap you set.
To start from working code, use the Crawlee + Playwright template or the Crawlee + Puppeteer template: scrapely create my-actor --template crawlee-puppeteer-chrome, put your parsing in the router, and push. And check the Scrape.ly Store before you write anything: it has 568 ready-made Actors, and many sites don't need a browser at all. The Web Scraper collected four book pages from books.toscrape.com in a 12-second run for $0.0029, with plain HTTP requests and one short extraction function.
If you'd rather have an AI agent drive a browser for you, see our guide to Playwright MCP.
FAQ
Is Playwright better than Puppeteer for web scraping?
For new scrapers, yes. It auto-waits before every action, routes requests with a simple API, gives you a step-by-step Trace Viewer, and works in Python, Java and .NET as well as JavaScript. Puppeteer is a solid choice if you already have a Node.js scraper that only needs Chrome.
Is Puppeteer faster than Playwright?
Not in a way you'd notice. In our test of the same three-page scraper, five runs each, the medians were 2.04 seconds for Playwright and 2.30 seconds for Puppeteer, a gap about the size of the network noise. Blocking images and stylesheets and running pages in parallel matter far more.
Can I use Puppeteer's stealth plugin with Playwright?
Yes. playwright-extra accepts puppeteer-extra-plugin-stealth as a plugin, and our Playwright stealth guide has tested setup code. The plugin's last release was in March 2023, and it doesn't change your IP address or TLS fingerprint.
Does Puppeteer work with Python?
Not officially. Puppeteer is a JavaScript library. If you scrape in Python, use Playwright for Python, which has both a sync and an async API.
Should I migrate from Puppeteer to Playwright?
Only if it pays off. The APIs are close (page.goto, page.click, page.evaluate work much the same), so a port is usually quick, but a working scraper doesn't need rewriting. Good reasons to switch are flaky waits, the need for Python, or wanting the Trace Viewer for debugging.
Which is harder for websites to detect?
Neither, out of the box. Both reported navigator.webdriver as true in headless mode in our test. Getting through bot protection depends on fingerprints, IP reputation and challenge solving, which is what the Scrape.ly browser handles for both libraries.




