Back to blog

Puppeteer vs. Playwright for Web Scraping in 2026

Share article:

Puppeteer and Playwright are Node-based browser automation libraries that drive a real Chromium, Firefox, or WebKit browser through code. Choosing between them is a real architectural decision for any production scraping project. The two libraries share a common origin, with Playwright being built at Microsoft by engineers who previously worked on Puppeteer at Google. Yet they differ in browser coverage, language bindings, and scraping ergonomics. In this guide, we’ll cover their performance, stealth, and proxy integration.

A neon purple icon of a film slate inside a browser window, framing the Puppeteer vs Playwright showdown as if browser automation were an epic Hollywood drama.

TL;DR

  • Use Puppeteer for Node.js pipelines on Chrome or Firefox. You get direct CDP access and a mature ecosystem.
  • Use Playwright for Python, Java, or C# teams, for WebKit coverage, or for high-parallelism scraping. You get auto-wait, lower memory per session, and per-context proxies that carry their own credentials. 
  • Neither tool is enough alone against enterprise anti-bot stacks – plan for Decodo's residential proxies and a managed scraping API for those targets.
  • We tested both libraries on a 50-page run, and Playwright won in terms of speed and resources, completing the unblocked benchmark in 18.41s, though applying asset blocking via its route() API significantly slowed execution to 56.05s.

Overview and key features of Puppeteer

Google's Chrome DevTools team released Puppeteer in 2017 as a Node.js library for driving Chromium and Chrome. It talks to the browser directly over the Chrome DevTools Protocol (CDP) – the same channel Chrome's own DevTools UI uses. Running npm install puppeteer downloads a matching Chrome for Testing build, so you get a working headless browser with no separate driver installation.

What Puppeteer handles natively

Out of the box, Puppeteer handles the common scraping needs without extra dependencies:

  • Page navigation and DOM interaction through page.goto(), selectors, keyboard, and mouse input
  • Screenshot and PDF generation at full-page or element scope
  • Network request interception via page.setRequestInterception(), useful for blocking images or mocking responses during scraping
  • In-page JavaScript execution through page.evaluate() for DOM extraction and scripted interactions
  • Chrome DevTools features include performance timelines, JavaScript coverage, and CPU/memory profiling

Why direct CDP access matters

Puppeteer calls CDP without an extra abstraction layer when driving Chrome, and you get direct access to Chromium's DevTools surface. For a scraping pipeline that also tracks page weight, memory footprint, or paint timings, direct CDP access is the shorter path. Playwright's higher-level API hides most of these knobs, and accessing them requires a CDPSession.

Where Puppeteer is narrower

Puppeteer v23 added production-ready Firefox support through WebDriver BiDi, the W3C bidirectional WebDriver protocol that adds event streaming to the classic request/response WebDriver. That narrows the Firefox gap against Playwright. WebKit remains unsupported. Teams that need to scrape Safari-specific rendering quirks, or that want to rotate across Chromium, Firefox, and WebKit to vary the detection surface, still hit this limit. By default, installing Puppeteer downloads Chrome for Testing and chrome-headless-shell, while Firefox is opt-in.

The ecosystem worth knowing

3 packages worth knowing for anyone building a Puppeteer scraper:

  • puppeteer-extra – a wrapper around Puppeteer that adds a plugin system
  • puppeteer-extra-plugin-stealth – patches common fingerprint vectors (navigator.webdriver, WebGL vendor strings, plugin arrays, chrome runtime quirks) to cut the obvious automation markers
  • puppeteer-cluster – manages pools of concurrent Puppeteer instances for parallel scraping

Overview and key features of Playwright

Microsoft released Playwright in January 2020. The team included engineers who built Puppeteer at Google. Their design goal was broader: one unified API across Chromium, Firefox, and WebKit, with official client libraries for JavaScript, TypeScript, Python, C#, and Java. The shared lineage explains why the APIs feel familiar – most method names and core concepts transfer directly from Puppeteer.

What Playwright adds

Beyond the shared scraping API, Playwright adds capabilities aimed at multi-target and multi-language pipelines:

  • Cross-browser support. It does this through a single API surface for Chromium, Firefox, and WebKit
  • Browser contexts. Isolated sessions with separate cookies, storage, and network state inside one browser instance. The context API is more polished than Puppeteer's incognito-context equivalent and its proxy option carries the credentials alongside the server address, while Puppeteer's context-level proxy takes a host only, with credentials set separately per page.
  • Auto-wait. Before interacting with an element, Playwright waits for it to be visible, stable, able to receive events, and enabled. This removes most manual waitForSelector() calls and reduces flaky scrapes on slow or dynamic pages.
  • Route-based network interception. page.route() handles request mocking or blocking cleanly at the context level, letting you drop images, stylesheets, or ad scripts without a browser-wide flag. It's more flexible than Puppeteer's interception, but it isn't free.
  • Built-in tracing. The test runner ships with trace recording (screenshots, network logs, and DOM snapshots per action). The viewer is useful for debugging broken scrapes even outside a testing context.

For a practical walkthrough, Playwright Web Scraping: A Practical Tutorial covers the end-to-end pipeline.

Playwright MCP vs. Puppeteer MCP for AI agents

MCP, or Model Context Protocol, lets an AI agent call a browser through a standard set of tools instead of custom code for every task. Microsoft maintains an official Playwright MCP server, but there is no maintained official Puppeteer equivalent as of September 2026. The reference @modelcontextprotocol/server-puppeteer package is deprecated on npm, which flags it as no longer supported, and its repository sits archived in modelcontextprotocol/servers-archived. 

The closest first-party option on the Puppeteer side is Google's chrome-devtools-mcp, which uses Puppeteer under the hood but is built for debugging and performance work, and not agent-driven scraping.

What an MCP server exposes to an agent

An MCP server built for browser automation typically provides tools for navigation, clicking, form filling, taking snapshots, and reading console or network output. Playwright MCP reads the page's accessibility tree instead of relying on screenshots. That's why agents drive it more reliably through multi-step scraping tasks. chrome-devtools-mcp provides a different toolset, particularly useful for network inspection, core web vitals, performance traces, and memory snapshots, but weaker as a scraping driver, since it wasn't primarily designed for that job.

Proxy routing for an agent session

An MCP server doesn't take the library's launch options directly. Instead, it provides setup options so you can configure the proxy. Playwright MCP accepts --proxy-server and --proxy-bypass, either in the args list of your client config or through the PLAYWRIGHT_MCP_PROXY_SERVER and PLAYWRIGHT_MCP_PROXY_BYPASS environment variables.

This is where sticky sessions come into play. An agent scraping a single target can take dozens of turns to complete one task, and losing the exit IP partway through can trigger a fresh bot check mid-session. Holding 1 residential session for the length of the run avoids that.

Authentication has two routes here. The --proxy-server flag takes a server string only, so it can't carry a username or password on its own. For a Decodo sticky session tied to a proxy username, pass the credentials through a config file instead, with --config path/to/config.json:

{
  "browser": {
    "launchOptions": {
      "proxy": {
        "server": "https://gate.decodo.com:10000",
        "username": "user-YOUR_USERNAME-session-abc123-sessionduration-90",
        "password": "YOUR_PASSWORD"
      }
    }
  }
}

If you'd rather keep credentials out of the config entirely, whitelist the machine running the MCP server in your Decodo dashboard and move the session parameters into the hostname:

["@playwright/mcp@latest", "--proxy-server=https://session-abc123-sessionduration-90.gate.decodo.com:10000"]

It holds 1 exit IP for 90 minutes with no credentials in the config. sessionduration accepts any value from 1 to 1,440 minutes, though a longer session carries more chance of the IP rotating early if the residential device drops offline.

If you prioritize IP consistency over uptime, add session_iplock-<name> instead of session-<name> (username:password auth only). It won't silently substitute a new IP if the original drops. It’ll return a 502 so you can catch and retry instead.

Playwright 1.59 added 2 features for agent workflows, both of which are present in the current release. browser.bind() shares 1 running browser across playwright-cli, @playwright/mcp, and other clients, so a test script, an MCP server, and the CLI can all use the same session instead of each launching its own. page.screencast records an annotated video of what the agent did, useful for auditing an agent-driven scrape after the fact.

For a full setup walkthrough, our Playwright MCP guide covers the Execute Automation variant for agent-driven scraping.

What Puppeteer and Playwright share

Most scraping code looks the same in both libraries. Puppeteer and Playwright share a lineage, so most of what you write in one transfers to the other with little rework, and migration cost stays low.

Capability

How it works in both

API shape

Both launch a browser, open a page, navigate, interact, and extract, with near-identical method names like page.goto, page.click, and page.evaluate

Node.js and TypeScript support

Both ship TypeScript definitions and run the same linting, debugging, and module resolution as any Node.js project

Headless and headful modes

Both default to headless for production and support a visible browser window with DevTools open for debugging

Dynamic content and SPA rendering

Both render JavaScript and wait for DOM changes, so single-page apps and infinite-scroll pages work in either tool. See our guide on scraping dynamic content for the general approach

Core primitives

Both handle screenshots, PDF generation, form submission, cookies, local storage, and file downloads, with only cosmetic API differences

OS and container support

Both run on Windows, Linux, and macOS, and both work inside Docker with the usual Chromium flags like --no-sandbox

Both tools accept a proxy server at launch as a host and port, handle authenticated proxies, and both can scope a proxy to a single browser context. The syntax differs, and the proxy configuration section below covers the setup for each.

The puppeteer-extra-plugin-stealth package also works with both tools, via playwright-extra on the Playwright side. When configured identically, their stealth capabilities remain broadly equivalent on Chromium.

Where Puppeteer and Playwright diverge

The shared foundation makes the differences easier to isolate. Each dimension has a practical scraping consequence.

Dimension

Puppeteer

Playwright

Developer

Google Chrome DevTools team

Microsoft

Initial release

2017

January 2020

Language support

JavaScript, TypeScript

JavaScript, TypeScript, Python, C#, Java

Browser support

Chrome + Firefox (via WebDriver BiDi, stable since v23)

Chromium, Firefox, WebKit

Auto-waiting

Via Locator API (recommended); manual with $()/ElementHandle

Built-in for actionable states

Parallel execution

Manual (puppeteer-cluster, multiple processes)

Native browser contexts, built-in test runner

Protocol depth

Direct CDP, full DevTools access

Higher-level API; CDP available through CDPSession

Default install footprint

Chrome for Testing + chrome-headless-shell (Firefox opt-in)

Chromium, Firefox, and WebKit binaries

Single-page scrape speed

Comparable on Chrome

Comparable on Chrome; more efficient on multi-context workloads

Language support in practice

Python, Java, and C# teams get a first-class path with Playwright. The Python API matches the JavaScript version closely and supports both sync and async patterns. Puppeteer's only Python option is the Pyppeteer port, which the project itself now flags as unmaintained. Between these two libraries, Playwright is the only production-ready option for non-Node.js teams.

Browser support and scraping targets

Puppeteer v23 covers Chrome and Firefox, so the browser-coverage gap narrows to WebKit. That gap becomes key when a target serves different content by User-Agent, applies different bot detection rules to WebKit traffic, or uses Safari-specific rendering quirks that appear only in WebKit. Playwright's ability to run across all three engines through a single API gives you a broader fallback without rewriting the rest of the pipeline.

Auto-waiting and flake reduction

Playwright auto-waits on every action by default. Puppeteer auto-waits too, but only when you use its Locator API (page.locator()). The same preconditions Playwright checks (visible, stable, enabled) are checked there. Code written with the older page.$() or ElementHandle APIs still needs explicit waitForSelector() calls, so older Puppeteer codebases often carry more manual waits than a Playwright script would.

Our guide to waiting for a page to load in Playwright covers when the automatic behavior still needs a manual assist.

CDP access as a trade-off

Puppeteer's direct CDP access enables performance profiling, JavaScript coverage reporting, and fine-grained network control that Playwright exposes only through its lower-level CDPSession. Pure scraping rarely needs any of this. CDP depth matters in monitoring-heavy or quality-assurance hybrid workflows, for example, a scraper that also records Core Web Vitals, or one that tracks render regressions across deployments.

Parallel scraping efficiency

Playwright's browser contexts let you run isolated sessions with separate cookies, localStorage, and network routing inside one browser process. 30 contexts use far less memory than 30 separate browsers. Puppeteer's equivalent is either one incognito context per session (similar idea, less polished) or one browser process per worker through puppeteer-cluster. The latter carries a full browser's memory overhead per identity rather than sharing one process. For high-concurrency scraping, Playwright's model uses resources more efficiently.

Continue reading for a practical example of their differences.

Puppeteer vs. Playwright benchmark: speed, memory, and proxy-routed throughput

We ran Puppeteer 25.10.0 on its bundled Chrome 152, Playwright 1.63.0 on its bundled Chromium 153, with puppeteer-cluster 0.25.0 driving the concurrency rows. Every scenario was run against books.toscrape.com, a site that contains exactly 50 catalog pages, each with 20 books. The 50-page runs hit the live site. To measure each library's performance without network delays, the test loaded a saved copy of catalog page 1 from the test machine and served placeholders for its images and files.

Scenario

Puppeteer median / slowest

Playwright median / slowest

Delta

Cold start (launch to first page loaded)

1.28s / 1.42s

0.95s / 0.97s

Playwright 26% faster

Warm navigation, per page

91ms / 101ms

305ms / 323ms

Puppeteer 3.4× faster

50-page run, direct, asset blocking on

23.07s / 23.81s

56.05s / 69.84s

Puppeteer 2.4× faster

50-page run, direct, blocking through CDP

21.77s / 25.03s

22.34s / 29.38s

Puppeteer about 3% faster

50-page run, direct, asset blocking off

22.39s / 26.01s

18.41s / 19.28s

Playwright 18% faster

Peak RSS, 10 sessions, context per session

1,327MB / 1,632MB

923MB / 930MB

Playwright uses 1.4× less memory

Peak RSS, 30 sessions, context per session

3,142MB / 4,225MB

2,594MB / 2,610MB

Playwright uses 1.2× less memory

Peak RSS, 10 sessions, browser per session

4,958MB / 5,034MB

1,990MB / 2,000MB

Playwright uses 2.5× less memory

Peak RSS, 30 sessions, browser per session

16,475MB / 17,906MB

5,952MB / 5,955MB

Playwright uses 2.8× less memory

50-page run via Decodo residential gateway

46.66s / 50.30s

140.54s / 218.35s

Puppeteer about 3.0× faster

Start collecting data in minutes

Get your residential proxies going in just a few steps with Decodo, no matter if you're running Playwright or Puppeteer.

Each library ran 5 times per scenario, alternated so any machine drift landed on both. The table carries 2 figures per cell. The median is the middle value, which represents the typical case. The second figure in the median column is the slowest of the 5 runs, which shows how bad a run can get. Benchmarks usually call that second number the p95.

A 50-page run counted only when all 50 pages returned exactly 20 product rows. Puppeteer's 5th proxy and CDP run failed with a detached-frame error, so their proxy median covers 4 runs.

What each Puppeteer vs. Playwright scenario measures

  • Cold start. The launch process to the first fully loaded page. Playwright starts up faster (0.95s against Puppeteer's 1.28s) and gives much more consistent load times. If you launch a new browser for every task, Playwright wins easily, though reusing 1 browser across a handful of pages hides the difference in the first navigation.
  • Warm navigation. Time per page once the browser is already up, measured over 20 navigations on a local page. It isolates the library's own per-page overhead from launch cost and network noise.
  • 50-page run, blocking through each library's own API. A realistic scrape with images, fonts, and media dropped through page.setRequestInterception() on Puppeteer and context.route() on Playwright.
  • 50-page run, blocking through CDP. The same scrape with Chromium's Network.setBlockedURLs doing the filtering instead of either library's API. It separates the cost of blocking assets from the cost of the interception layer.
  • 50-page run, no blocking. This run was the control. It showed how much of the gap belongs to the libraries and how much belongs to the blocking layer.
  • Peak RSS at 10 and 30 concurrent sessions. The peak RAM used by all browser processes combined, measured for both libraries in 2 isolation models: a context per session, and a browser per session. It tells you how many sessions a single server can handle before you rent a bigger one.
  • 50-page run via the residential gateway. The same scrape routed through a live proxy.

Bar charts compare Puppeteer and Playwright performance metrics across startup, CPU, and RAM.

A cold start stays small, and Playwright takes it. Playwright reaches its first loaded page in 0.95s against Puppeteer's 1.28s, and it's the steadier of the 2: its 5 runs span 40ms where Puppeteer's span 260ms. That gap pays off when you launch a browser per job.

Peak RSS means resident set size, the physical memory those processes hold. At 10 concurrent sessions, Playwright's browser contexts hold 923MB across 13 processes, and Puppeteer's hold 1,327MB across 18. Both libraries isolate a session in a context inside 1 browser process, so there’s a 1.4x distance between them.

The isolation model moves that number far more than the library does. Give each of 10 sessions its own browser, and Puppeteer holds 4,958MB across 97 processes against Playwright's 1,990MB across 40, because Puppeteer's bundled Chrome runs about 10 processes per browser where Playwright's Chromium runs 4.

At 30 browsers each, the split is 16.5GB with Puppeteer against 6.0GB with Playwright. Contexts hold the same 30 sessions, at 3.1GB with Puppeteer and 2.6GB with Playwright. If you're planning web scraping at scale, the isolation model sets your instance size.

Playwright loads a warm page in 41ms against Puppeteer's 85ms. Switch on context.route() to drop images, and Playwright moves to 305ms while Puppeteer holds at 91ms. 

The proxy leg costs less than the library choice. Proxies are what keep your IP out of the target's logs and your scraper unblocked, and routing through Decodo residential proxies doubled Puppeteer's run from 23s to 47s. That residential proxy leg added 24s to Puppeteer's run, while blocking assets through Playwright's context.route() API alone opened a 33s gap between the 2 libraries on the same hardware.

Ultimately, neither library is truly faster across the board. Playwright starts faster, navigates faster with no blocking, and uses less RAM in both isolation models, so more sessions fit on a single server. Puppeteer processes pages faster once you block unwanted assets through each library's own API. So, choose based on your memory ceiling and your asset-blocking setup.

Here's some sample code to test the run: 

import puppeteer from "puppeteer";
import { chromium } from "playwright";
import { Cluster } from "puppeteer-cluster";
import http from "node:http";
import { readFileSync } from "node:fs";
import { spawn } from "node:child_process";
const PROXY = "http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@gate.decodo.com:7000";
const RUNS = 5;
const PAGES = 50;
const EXPECTED_ROWS = 20;
const LOCAL_PAGE = "./fixtures/local-page.html";
const CATALOGUE = (n) => `https://books.toscrape.com/catalogue/page-${n}.html`;
const BLOCKED = new Set(["image", "font", "media"]);
const BLOCKED_URLS = ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.webp", "*.ico", "*.woff", "*.woff2"];
const ARGS = ["--no-sandbox", "--disable-dev-shm-usage"];
const COUNT_ROWS = () => document.querySelectorAll(".product_pod").length;
const median = (a) => {
  const s = [...a].sort((x, y) => x - y);
  const m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const parseProxy = (url) => {
  const u = new URL(url);
  return {
    server: `http://${u.host}`,
    username: decodeURIComponent(u.username),
    password: decodeURIComponent(u.password),
  };
};
const startServer = () => {
  const page = readFileSync(LOCAL_PAGE);
  const pixel = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
  const server = http.createServer((req, res) => {
    if (req.url === "/" || req.url.startsWith("/page")) {
      res.writeHead(200, { "content-type": "text/html" });
      res.end(page);
    } else if (req.url.endsWith(".css") || req.url.endsWith(".js")) {
      res.writeHead(200, { "content-type": "text/plain" });
      res.end("");
    } else {
      res.writeHead(200, { "content-type": "image/gif" });
      res.end(pixel);
    }
  });
  return new Promise((resolve) => {
    server.listen(0, "127.0.0.1", () => {
      resolve({ url: `http://127.0.0.1:${server.address().port}/`, close: () => server.close() });
    });
  });
};
// Peak memory across the browser process tree. Attribution walks down from this
// process, so an unrelated Chrome on the same machine stays out of the numbers.
const startSampler = () => {
  const script = `
    $root = ${process.pid}
    while ($true) {
      $all = Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,WorkingSetSize
      $kids = @{}
      foreach ($p in $all) {
        $pp = [int]$p.ParentProcessId
        if (-not $kids.ContainsKey($pp)) { $kids[$pp] = New-Object System.Collections.ArrayList }
        [void]$kids[$pp].Add($p)
      }
      $stack = New-Object System.Collections.Stack
      $stack.Push([int]$root)
      $seen = @{}; $ws = [double]0
      while ($stack.Count -gt 0) {
        $cur = $stack.Pop()
        if ($seen.ContainsKey($cur)) { continue }
        $seen[$cur] = $true
        if ($kids.ContainsKey($cur)) {
          foreach ($c in $kids[$cur]) {
            if ($c.Name -like 'chrome*') { $ws += [double]$c.WorkingSetSize }
            $stack.Push([int]$c.ProcessId)
          }
        }
      }
      Write-Output $ws
      Start-Sleep -Milliseconds 500
    }`;
  const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script]);
  let peak = 0;
  ps.stdout.on("data", (d) => {
    for (const line of d.toString().split(/\r?\n/)) {
      const v = Number(line.trim());
      if (v > peak) peak = v;
    }
  });
  return { stop: () => { ps.kill(); return peak / 1048576; } };
};
// Each library exposes the same 4 operations, so every scenario runs identical
// logic and only the library underneath changes.
const drivers = {
  puppeteer: {
    async launch(proxy) {
      const args = [...ARGS];
      if (proxy) args.push(`--proxy-server=${proxy.server}`);
      return { browser: await puppeteer.launch({ headless: true, args }), proxy };
    },
    async newPage({ browser, proxy }, blocking = "api") {
      const page = await browser.newPage();
      if (proxy) await page.authenticate({ username: proxy.username, password: proxy.password });
      if (blocking === "api") {
        await page.setRequestInterception(true);
        page.on("request", (r) => {
          if (BLOCKED.has(r.resourceType())) r.abort().catch(() => {});
          else r.continue().catch(() => {});
        });
      } else if (blocking === "cdp") {
        const cdp = await page.createCDPSession();
        await cdp.send("Network.enable");
        await cdp.send("Network.setBlockedURLs", { urls: BLOCKED_URLS });
      }
      return {
        goto: (url, timeout) => page.goto(url, { waitUntil: "domcontentloaded", timeout }),
        count: () => page.evaluate(COUNT_ROWS),
      };
    },
    close: ({ browser }) => browser.close(),
  },
  playwright: {
    async launch(proxy) {
      return { browser: await chromium.launch({ headless: true, args: ARGS, ...(proxy ? { proxy } : {}) }) };
    },
    async newPage({ browser }, blocking = "api") {
      const context = await browser.newContext();
      if (blocking === "api") {
        await context.route("**/*", (route) => {
          if (BLOCKED.has(route.request().resourceType())) route.abort();
          else route.continue();
        });
      }
      const page = await context.newPage();
      if (blocking === "cdp") {
        const cdp = await context.newCDPSession(page);
        await cdp.send("Network.enable");
        await cdp.send("Network.setBlockedURLs", { urls: BLOCKED_URLS });
      }
      return {
        goto: (url, timeout) => page.goto(url, { waitUntil: "domcontentloaded", timeout }),
        count: () => page.evaluate(COUNT_ROWS),
      };
    },
    close: ({ browser }) => browser.close(),
  },
};
const coldStart = async (driver) => {
  const server = await startServer();
  let handle;
  try {
    const t0 = performance.now();
    handle = await driver.launch();
    const page = await driver.newPage(handle);
    await page.goto(server.url, 30000);
    await page.count();
    return performance.now() - t0;
  } finally {
    if (handle) await driver.close(handle).catch(() => {});
    server.close();
  }
};
const warmNavigation = async (driver, blocking) => {
  const server = await startServer();
  let handle;
  try {
    handle = await driver.launch();
    const page = await driver.newPage(handle, blocking);
    await page.goto(server.url, 30000); // discard: the first navigation is still cold
    const times = [];
    for (let i = 0; i < 20; i += 1) {
      const t0 = performance.now();
      await page.goto(`${server.url}page${i}`, 30000);
      await page.count();
      times.push(performance.now() - t0);
    }
    return median(times);
  } finally {
    if (handle) await driver.close(handle).catch(() => {});
    server.close();
  }
};
// A page counts only when it returns the expected row count, so a run that
// finishes early by skipping pages is a failure and not a fast result.
const scrapeRun = async (driver, { proxy = false, blocking = "api" } = {}) => {
  const handle = await driver.launch(proxy ? parseProxy(PROXY) : null);
  const timeout = proxy ? 60000 : 30000;
  let t0;
  try {
    const page = await driver.newPage(handle, blocking);
    t0 = performance.now();
    for (let n = 1; n <= PAGES; n += 1) {
      const url = CATALOGUE(n);
      let ok = false;
      for (let attempt = 1; attempt <= 3 && !ok; attempt += 1) {
        try {
          await page.goto(url, timeout);
          if ((await page.count()) !== EXPECTED_ROWS) throw new Error("short page");
          ok = true;
        } catch (err) {
          if (attempt === 3) throw new Error(`${url}: ${err.message}`);
        }
      }
    }
  } finally {
    await driver.close(handle).catch(() => {});
  }
  return performance.now() - t0;
};
const peakMemory = async (lib, sessions, model) => {
  // The memory sampler uses PowerShell, so on other systems this run fails cleanly
  // instead of crashing the whole script.
  if (process.platform !== "win32") throw new Error("Peak RSS scenarios need Windows");
  const server = await startServer();
  const sampler = startSampler();
  const toClose = [];
  let done = false;
  let arrived = 0;
  let release;
  const barrier = new Promise((r) => { release = r; });
  const arrive = () => { arrived += 1; if (arrived === sessions) release(); };
  const hold = () => new Promise((r) => setTimeout(r, 4000));
  const blockIn = async (page) => {
    await page.setRequestInterception(true);
    page.on("request", (r) => {
      if (BLOCKED.has(r.resourceType())) r.abort().catch(() => {});
      else r.continue().catch(() => {});
    });
  };
  try {
    if (model === "context" && lib === "playwright") {
      const handle = await drivers.playwright.launch();
      toClose.push(handle.browser);
      await Promise.all(Array.from({ length: sessions }, async () => {
        const page = await drivers.playwright.newPage(handle);
        await page.goto(server.url, 60000);
        await page.count();
        arrive();
        await barrier;
      }));
      await hold();
      await drivers.playwright.close(handle);
    } else if (model === "context") {
      // Puppeteer scopes a context the same way Playwright does.
      const browser = await puppeteer.launch({ headless: true, args: ARGS });
      toClose.push(browser);
      await Promise.all(Array.from({ length: sessions }, async () => {
        const context = await browser.createBrowserContext();
        const page = await context.newPage();
        await blockIn(page);
        await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 60000 });
        await page.evaluate(COUNT_ROWS);
        arrive();
        await barrier;
      }));
      await hold();
      await browser.close();
    } else if (lib === "playwright") {
      await Promise.all(Array.from({ length: sessions }, async () => {
        const browser = await chromium.launch({ headless: true, args: ARGS });
        toClose.push(browser);
        const context = await browser.newContext();
        await context.route("**/*", (route) => {
          if (BLOCKED.has(route.request().resourceType())) route.abort();
          else route.continue();
        });
        const page = await context.newPage();
        await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 60000 });
        await page.evaluate(COUNT_ROWS);
        arrive();
        await barrier;
      }));
      await hold();
      await Promise.all(toClose.map((b) => b.close()));
    } else {
      const cluster = await Cluster.launch({
        puppeteer,
        concurrency: Cluster.CONCURRENCY_BROWSER,
        maxConcurrency: sessions,
        timeout: 180000,
        puppeteerOptions: { headless: true, args: ARGS },
      });
      toClose.push(cluster);
      // puppeteer-cluster reports failed tasks as events instead of throwing,
      // so keep the first one and rethrow it after the queue drains.
      let taskFailed = null;
      cluster.on("taskerror", (err) => { if (!taskFailed) taskFailed = err; });
      await cluster.task(async ({ page, data: url }) => {
        await blockIn(page);
        await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
        await page.evaluate(COUNT_ROWS);
        arrive();
        await barrier;
        await hold();
      });
      for (let i = 0; i < sessions; i += 1) cluster.queue(server.url);
      await cluster.idle();
      if (taskFailed) throw taskFailed;
      await cluster.close();
    }
    done = true;
    return sampler.stop();
  } finally {
    // On failure, stop the sampler and close anything still open, so a leftover
    // browser can't leak into the memory readings of later runs.
    if (!done) {
      sampler.stop();
      await Promise.allSettled(toClose.map((b) => b.close()));
    }
    server.close();
  }
};
const SCENARIOS = {
  "Cold start (ms)": (d) => coldStart(d),
  "Warm page, blocking via library API (ms)": (d) => warmNavigation(d, "api"),
  "Warm page, no blocking (ms)": (d) => warmNavigation(d, "none"),
  "50 pages, blocking via library API (ms)": (d) => scrapeRun(d, { blocking: "api" }),
  "50 pages, blocking via CDP (ms)": (d) => scrapeRun(d, { blocking: "cdp" }),
  "50 pages, no blocking (ms)": (d) => scrapeRun(d, { blocking: "none" }),
  "50 pages via proxy (ms)": (d) => scrapeRun(d, { proxy: true, blocking: "api" }),
  "Peak RSS, 10 sessions, context each (MB)": (d) => peakMemory(d.name, 10, "context"),
  "Peak RSS, 30 sessions, context each (MB)": (d) => peakMemory(d.name, 30, "context"),
  "Peak RSS, 10 sessions, browser each (MB)": (d) => peakMemory(d.name, 10, "browser"),
  "Peak RSS, 30 sessions, browser each (MB)": (d) => peakMemory(d.name, 30, "browser"),
};
const main = async () => {
  const table = [];
  for (const [name, run] of Object.entries(SCENARIOS)) {
    const results = { puppeteer: [], playwright: [] };
    for (let i = 1; i <= RUNS; i += 1) {
      // Alternate the libraries inside each run so machine drift hits both.
      for (const lib of ["puppeteer", "playwright"]) {
        const driver = { ...drivers[lib], name: lib };
        try {
          results[lib].push(await run(driver));
        } catch (err) {
          console.log(`  ${name} / ${lib} run ${i} failed: ${err.message}`);
        }
      }
    }
    const row = {
      scenario: name,
      puppeteer: results.puppeteer.length ? Math.round(median(results.puppeteer)) : "failed",
      playwright: results.playwright.length ? Math.round(median(results.playwright)) : "failed",
    };
    table.push(row);
    console.log(`${name.padEnd(30)} puppeteer ${row.puppeteer}   playwright ${row.playwright}`);
  }
  console.table(table);
};
main();

Suitability for web scraping

While comparison tables do help you pick a tool, they don't tell you how to handle a real target. Production scraping encounters CAPTCHAs, IP bans, and enterprise anti-bot systems and each library leaves gaps there that external infrastructure usually closes.

Stealth and anti-bot handling

Neither tool is stealthy by default. Both set navigator.webdriver = true and leave enough automation markers that a competent anti-bot vendor flags them quickly.

The Puppeteer approach. puppeteer-extra combined with puppeteer-extra-plugin-stealth patches the common fingerprint vectors, namely navigator.webdriver, WebGL vendor and renderer strings, plugin and mime-type arrays, chrome runtime properties. New detection methods sometimes appear before the plugin has a matching patch, which is normal for any volunteer-maintained project as detection techniques evolve quickly.

The Playwright approach. playwright-extra reuses the same stealth plugin. Playwright also exposes addInitScript(), which overrides browser properties per context and gives more granular control than a browser-wide patch. Running on WebKit can bypass detection rules written specifically against Chromium signals.

Beyond stealth plugins. For targets that compare canvas hashes , WebGL parameters, AudioContext fingerprints, or enumerated fonts, stealth plugins alone aren't enough. Several alternatives address different detection vectors:

  • Camoufox is a Firefox-based browser with deeper fingerprint spoofing built in
  • nodriver automates Chrome directly over CDP, without the Selenium/WebDriver  markers that most detection rules rely on
  • Patchright (a patched Playwright) and rebrowser-patches (for both Puppeteer and Playwright) close the Runtime.enable CDP leak that enterprise anti-bot vendors like Cloudflare and DataDome specifically detect, a primary detection vector for CDP-based automation

For the harder fingerprinting checks, how to bypass CreepJS  covers the evasion steps. The broader anti-scraping overview covers the detection taxonomy.

Proxy configuration

Both Puppeteer and Playwright accept an upstream proxy, but they differ in how authentication works and how many concurrent identities you can attach to one browser process.

Using Puppeteer, authenticated proxy, Hacker News scrape:

const puppeteer = require('puppeteer');
const PROXY_USERNAME = 'user-YOUR_PROXY_USERNAME';
const PROXY_PASSWORD = 'YOUR_PROXY_PASSWORD';
(async () => {
  const browser = await puppeteer.launch({
    args: ['--proxy-server=gate.decodo.com:7000'],
  });
  const page = await browser.newPage();
  await page.authenticate({
    username: PROXY_USERNAME,
    password: PROXY_PASSWORD,
  });
  await page.goto('https://news.ycombinator.com', {
    waitUntil: 'domcontentloaded',
  });
  const stories = await page.$$eval('.athing', rows =>
    rows.map(row => ({
      id: row.id,
      title: row.querySelector('.titleline > a')?.textContent,
      url: row.querySelector('.titleline > a')?.href,
    }))
  );
  console.log(stories);
  await browser.close();
})();

Puppeteer doesn't accept proxy credentials at launch, so you call page.authenticate() on each page before navigation. The --proxy-server flag applies to the whole browser, but it isn't the only route: browser.createBrowserContext({ proxyServer }) scopes an upstream proxy to a single context. The catch is that proxyServer takes a host and port only, so credentials still run through page.authenticate() on every page inside that context.

Playwright – per-context proxy, Hacker News scrape in Python:

from playwright.sync_api import sync_playwright
PROXY_SERVER = "http://gate.decodo.com:7000"
PROXY_USERNAME_BASE = "user-YOUR_PROXY_USERNAME"
PROXY_PASSWORD = "YOUR_PROXY_PASSWORD"
with sync_playwright() as p:
    browser = p.chromium.launch()
    # Two contexts, two different sticky sessions = two different exit IPs
    # held for the life of each session.
    context_a = browser.new_context(proxy={
        "server": PROXY_SERVER,
        "username": f"{PROXY_USERNAME_BASE}-session-a1b2c3-sessionduration-30",
        "password": PROXY_PASSWORD,
    })
    context_b = browser.new_context(proxy={
        "server": PROXY_SERVER,
        "username": f"{PROXY_USERNAME_BASE}-session-x9y8z7-sessionduration-30",
        "password": PROXY_PASSWORD,
    })
    page_a = context_a.new_page()
    page_b = context_b.new_page()
    page_a.goto("https://news.ycombinator.com")
    page_b.goto("https://news.ycombinator.com/newest")
    stories = page_a.locator(".athing .titleline > a").all_text_contents()
    print(stories)
    browser.close()

Both libraries can route 2 contexts inside 1 browser process through different exit IPs using one scraper, many identities, and one process. What separates them is that Playwright carries the username and password inside the same proxy object as the server, so a sticky session per context is a single option, as above. However, Puppeteer splits it with the host going on the context and the credentials going on each page. Multi-target pipelines can run either way. Both also support a browser-level proxy that applies globally, so per-context routing is opt-in when isolation matters.

What proxy works best with Playwright or Puppeteer?

Both Playwright and Puppeteer support all standard proxy types identically, so proxy selection depends entirely on your target site rather than framework compatibility. Decodo proxies work seamlessly with both libraries, supporting both rotating and sticky sessions across datacenter and residential IP pools. 

Can I use static residential proxies with Puppeteer or Playwright?

Both tools work with static residential proxies, as proxy integration happens at the browser network layer rather than through library-specific logic. Playwright natively supports authenticated proxies directly in its launch() or newContext() configurations, whereas Puppeteer requires passing proxy parameters via CLI flags or handling authentication headers separately.

How do I keep the same exit IP across a Puppeteer session?

Pass your proxy credentials directly into your browser configuration or request authentication handler. Then, maintain sticky sessions by appending a session ID and duration tag to the proxy username using this pattern: user-<username>-session-<id>-sessionduration-<minutes>. In Puppeteer, pass the proxy endpoint at browser launch with --proxy-server=http://host:port, and set the credential string containing the session ID using page.authenticate({ username, password }) on your target context.

CAPTCHA handling

Neither tool solves CAPTCHAs natively. As such, both have to integrate with third-party solvers through the same pattern, namely detect the challenge, send the page or site key to a solving service, and inject the returned token.

How To Bypass CAPTCHAs: The Ultimate Guide covers the trade-offs between solving services and detection strategies that apply to both tools.

Where both tools reach their limits

Targets protected by enterprise-grade anti-bot systems like Cloudflare Enterprise, Akamai Bot Manager, and DataDome  use behavioral analysis and TLS fingerprinting that no amount of client-side patching can fully defeat. Stealth plugins reduce the detection surface and residential proxies reduce the IP risk, but neither touches the TLS handshake, which a real Chrome or Chromium build already sends correctly.

What gets flagged is a mismatch: a proxy that re-issues its own TLS, or a User-Agent that doesn't match the JA3/JA4 fingerprint behind it. Neither tool adds the mouse-movement and keystroke-timing signals these systems also check for.

What each option really costs to maintain

A scraper you run yourself carries 3 cost lines: proxy bandwidth, computing resources for the browser fleet, and the engineering hours that keep selectors and detection workarounds current. A managed API brings all 3 into a per-request rate.

Bandwidth is the cost factor most teams overlook when choosing between proxies and scraping APIs. While residential proxy plans bill purely by data usage, scraping protected targets requires loading anti-bot scripts and heavy application code that quickly drive up real-world page sizes. Depending on your monthly request volume, paying for proxy data by the gigabyte can cost just as much as using a Web Data API with built-in JavaScript rendering. So, pay attention to your actual page payload.

Maintenance cost is also an important factor that depends on the team, especially on the build side. Selector fixes and detection workarounds cost the same whether you send 10K requests or 1M, so the bill tracks how many targets you watch and how hard they push back. Ultimately, compare your options by counting the hours your team spent repairing scrapers last quarter, dividing by 3 for a monthly average, and multiplying by an hourly rate. That's your monthly maintenance bill.

The build-versus-buy line follows how hard your targets push back. Against unprotected pages that need no residential IPs and break when a site redesigns, self-hosting stays cheap. Against anything running active anti-bot defenses, dealing with the bandwidth alone costs more than the managed API rate, and every maintenance hour widens the gap. Here are helpful Decodo options for the buy option:

Both reduce the infrastructure burden of running, maintaining, and patching a stealth browser setup internally.

Best for which task?

The decision depends on your language stack, your concurrency shape, and whether anything else in the pipeline needs the browser.

Task

Better fit

Why

Caveat

Web scraping at low concurrency

Either

Your blocking method moves the result more than your library does.

Skip Playwright's context.route() for asset blocking.

Web scraping at high concurrency

Playwright

30 sessions using Playwright used 8× less memory retrieving results.

A single context crash kills all ongoing sessions at once because every session shares a single browser process, though this approach uses significantly less memory overall than running separate browser instances.

PDF generation

Either

Both render from the same page.pdf() call, and default to Letter at 612×792 points.

Playwright's PDF generation is Chromium-only; Puppeteer's also works on Firefox with fewer options.

Screenshots

Playwright, if you need WebKit

The options overlap heavily, and Playwright adds WebKit output for rendering checks.

Puppeteer has covered Firefox since v23, so the real gap is WebKit.

Scraping with Python, Java, or C#

Playwright

Official bindings for all 3, with an API that mirrors the JavaScript version.

Puppeteer's only Python route is the Pyppeteer port, which the project itself flags as unmaintained.

Performance profiling alongside a scrape

Puppeteer

Direct CDP access for traces, code coverage, and fine-grained network control.

Playwright reaches the same surface through CDPSession, and only on Chromium.

End-to-end testing

Playwright

A test runner, fixtures, and a trace viewer are available out of the box.

Puppeteer expects you to bring Jest or Mocha.

AI agent control

Playwright

Microsoft maintains the official Playwright MCP server.

The chrome-devtools-mcp option on the Puppeteer side targets debugging and not scraping.

If your Puppeteer codebase is still on page.$() or ElementHandle, moving those call sites to Puppeteer's own Locator API gets you the same auto-waiting without switching libraries. Migrating to Playwright is worth it for more than just auto-waiting, namely when you're out of memory headroom, need Python, Java, or C#, or want WebKit.

How to migrate from Puppeteer to Playwright

Here’s a quick guide on how you can migrate from Puppeteer to Playwright.

Puppeteer

Playwright

Notes

puppeteer.launch()

chromium.launch()

Playwright names the engine, so firefox or webkit is a 1-word swap.

browser.newPage()

browser.newPage()

Same call. Playwright wraps each page in its own context and closes that context with the page.

browser.createBrowserContext()

browser.newContext()

Puppeteer replaced createIncognitoBrowserContext() with createBrowserContext() in v22.0.0. So, if you are on an older release, createIncognitoBrowserContext() remains valid.

page.goto(url)

page.goto(url)

Same signature, but check your waitUntil value as Puppeteer uses networkidle0/networkidle2, and Playwright uses networkidle.

page.$(), page.$$(), page.locator()

page.locator()

Both libraries automatically wait and retry actions using locators. Puppeteer's Locator API is the recommended, auto-waiting way to interact with elements.

page.waitForSelector()

auto-wait through locator()

Most explicit waits come out of the code on the way across.

page.type()

locator.fill()

type() dispatches a key event per character. fill() sets the value in 1 call, which matters over a remote browser.

page.evaluate()

page.evaluate()

Identical semantics, but Puppeteer accepts any number of arguments after the function, while Playwright accepts one.

page.setRequestInterception(true) plus page.on("request")

page.route() or context.route()

Same job, different mechanism. Puppeteer filters through CDP's Fetch domain; Playwright runs a handler per matched request and can scope it to a whole context.

--proxy-server flag or createBrowserContext({ proxyServer }), plus page.authenticate()

proxy option on launch() or newContext()

Both support a proxy per context. Playwright passes credentials inside the proxy object; Puppeteer's proxyServer takes only a host, so authentication stays on the page.authenticate().

page.screenshot() and page.pdf()

page.screenshot() and page.pdf()

Options overlap heavily but aren't identical. mask, animations, and caret are Playwright-only, captureBeyondViewport is Puppeteer-only, and pdf() stays Chromium-only; Puppeteer also runs on Firefox over WebDriver BiDi, with a smaller option set.

If you use Puppeteer-style code like browser.newPage() on Playwright, your code still works. Playwright automatically creates a fresh context behind every page, and that call also accepts the same options as newContext(), so you can still pass a proxy, storage state, or permissions directly to newPage(). What an implicit context can't do is get reused, specifically, creating the context explicitly and calling newPage() on it is the only way to share one identity, one set of cookies, or one login session across multiple pages.

Bottom line

Puppeteer gives you a Chrome-first library with stable Firefox support, direct CDP access, and a mature Node.js ecosystem. Playwright gives you broader coverage across 3 browser engines and a wider set of language SDKs, plus better parallel execution capabilities and more polished scraping tooling without extra plugins.

Playwright's auto-wait and lower memory per session play a bigger role as pipeline complexity grows, and keeping proxy credentials in 1 option stays convenient as the identity count climbs. Both tools require external components: stealth plugins, rotating residential proxies, and CAPTCHA solvers.

A managed scraping API removes most of the infrastructure overhead. So, pick a language stack and concurrency first, add proxies, stealth, and managed infrastructure as the targets require.

Let the API handle it

Our Web Data API handles proxy management, anti-bot bypass, and JavaScript rendering so your team wouldn't have to.

Share article:

About the author

Justinas Tamasevicius

Director of Engineering

Justinas Tamaševičius is Director of Engineering with over two decades of expertise in software development. What started as a self-taught passion during his school years has evolved into a distinguished career spanning backend engineering, system architecture, and infrastructure development.

Connect with Justinas via LinkedIn.

All information on Decodo Blog is provided on an as is basis and for informational purposes only. We make no representation and disclaim all liability with respect to your use of any information contained on Decodo Blog or any third-party websites that may belinked therein.

Frequently asked questions

Is Playwright still relevant?

Yes. Even with the shift towards AI, agents still need a browser engine underneath them, and Playwright remains one of the most common choices for that layer, whether you call it directly or through an MCP server.

Is Puppeteer still maintained?

Yes. Google's Chrome DevTools team actively maintains Puppeteer. The project ships regular updates that track new Chrome versions, and it added production-ready Firefox support in v23, so it's still a solid choice for Node.js teams scraping Chrome or Firefox.

Is Puppeteer owned by Google?

Yes. Google's Chrome DevTools team built and maintains Puppeteer, and it ships under the Apache 2.0 license. An Apache 2.0 license means you can fork, modify, or self-host the project if Google ever stopped maintaining it.

What is faster than Playwright?

HTTP clients are faster than any browser automation library, Playwright included, because they skip rendering entirely and fetch raw HTML or JSON directly. That only works when a target doesn't need JavaScript execution to expose its data.

Which is faster for web scraping, Playwright or Puppeteer?

It depends on how you block assets. With no blocking, Playwright finished our 50-page run in 18.41s against Puppeteer's 22.39s. But if you block images through each library's own interception API, it reverses, because Playwright's route() handler costs more than the images it drops. Block through CDP instead, and the 2 land within 3% of each other.

Do I need proxies when scraping with Playwright or Puppeteer?

Yes, for any meaningful volume. Targets that rate-limit or block repeated requests from the same IP need rotating residential proxies regardless of which library drives the browser. Both accept a proxy at launch and per browser context, though Playwright's version carries credentials in the same option.

Can I use Playwright with Python for web scraping?

Yes. Playwright ships a first-class Python library with an API that mirrors the JavaScript version closely and supports both sync and async patterns. Puppeteer has no first-party Python equivalent. The community-maintained Pyppeteer port exists, but the project itself flags it as unmaintained, so Playwright is the safer choice for Python teams.

Is Puppeteer or Playwright better for avoiding bot detection?

Neither tool avoids bot detection by default. Both set navigator.webdriver to true and leave automation markers that a competent anti-bot vendor flags quickly. The stealth plugin, puppeteer-extra-plugin-stealth, available in Playwright through playwright-extra, cuts detectability but doesn't eliminate it. For enterprise anti-bot protection, neither tool alone is enough, and you'll also need rotating residential proxies and a managed scraping API.

Playwright logo and title 'Playwright' showing subtitle 'Web Scraping Tutorial' on a dark dotted background

Playwright Web Scraping: A Practical Tutorial

Web scraping can feel like directing a play without a script – unpredictable and chaotic. That’s where Playwright steps in: a powerful, headless browser automation tool that makes scraping modern, dynamic websites smoother than ever. In this practical tutorial, you’ll learn how to use Playwright to reliably extract data from any web page.

Scraping the Web with Selenium and Python: A Step-By-Step Tutorial

Modern websites rely heavily on JavaScript and anti-bot measures, making data extraction a challenge. Basic tools fail with dynamic content loaded after the initial page, but Selenium with Python can automate browsers to execute JavaScript and interact with pages like a user. In this tutorial, you'll learn to build scrapers that collect clean, structured data from even the most complex websites.

© 2018-2026 decodo.com (formerly smartproxy.com). All Rights Reserved