# Playwright vs Puppeteer vs Selenium for web scraping

For scraping, the library matters less than you might think: on four JavaScript-heavy tasks, Playwright, Puppeteer and Selenium all got the data every time, within half a second of each other. Choose by fit: Playwright for built-in waiting and three browser engines, Puppeteer for Chrome in Node.js, Selenium for the most languages and Safari.

Updated 1 Oct 2026 · Tested on 1 Oct 2026 · https://skryp.dev/guides/playwright-vs-puppeteer-vs-selenium

## The result

We gave each library four tasks that defeat a plain HTTP request, on two sites built for scraping practice, and ran each one three times. This is the script and what it printed:

```javascript
// Four JavaScript-heavy tasks, each run 3 times with each library: did it get the data, and how long did it take?
// npm install playwright puppeteer selenium-webdriver
// timeout: 900
import { chromium } from "playwright";
import puppeteer from "puppeteer";
import { Builder, By, until } from "selenium-webdriver";
import chrome from "selenium-webdriver/chrome.js";

const TASKS = [
  { name: "Rendered by JavaScript", url: "https://quotes.toscrape.com/js/", want: 10 },
  { name: "Rendered after 10 s", url: "https://quotes.toscrape.com/js-delayed/", want: 10 },
  { name: "Infinite scroll (30+ items)", url: "https://quotes.toscrape.com/scroll", want: 30, scroll: true },
  { name: "Table loaded by a click", url: "https://www.scrapethissite.com/pages/ajax-javascript/", want: 6, click: "a#\\32 015", rows: "tr.film" },
];

// Each library does each task its own idiomatic way and returns how many items it got.
const LIBS = {
  Playwright: {
    async open() { const b = await chromium.launch(); return { b, page: await b.newPage() }; },
    async run({ page }, t) {
      await page.goto(t.url);
      if (t.click) { await page.click(t.click); await page.locator(t.rows).first().waitFor({ timeout: 20_000 }); return page.locator(t.rows).count(); }
      await page.locator("div.quote").first().waitFor({ timeout: 20_000 });
      while (t.scroll && (await page.locator("div.quote").count()) < t.want) { await page.mouse.wheel(0, 4000); await page.waitForTimeout(300); }
      return page.locator("div.quote").count();
    },
    close: ({ b }) => b.close(),
  },
  Puppeteer: {
    async open() { const b = await puppeteer.launch(); return { b, page: await b.newPage() }; },
    async run({ page }, t) {
      await page.goto(t.url);
      if (t.click) { await page.click(t.click); await page.waitForSelector(t.rows, { timeout: 20_000 }); return (await page.$$(t.rows)).length; }
      await page.waitForSelector("div.quote", { timeout: 20_000 });
      while (t.scroll && (await page.$$("div.quote")).length < t.want) { await page.mouse.wheel({ deltaY: 4000 }); await new Promise((r) => setTimeout(r, 300)); }
      return (await page.$$("div.quote")).length;
    },
    close: ({ b }) => b.close(),
  },
  Selenium: {
    async open() { return { d: await new Builder().forBrowser("chrome").setChromeOptions(new chrome.Options().addArguments("--headless=new")).build() }; },
    async run({ d }, t) {
      await d.get(t.url);
      if (t.click) { await d.findElement(By.css(t.click)).click(); await d.wait(until.elementLocated(By.css(t.rows)), 20_000); return (await d.findElements(By.css(t.rows))).length; }
      await d.wait(until.elementLocated(By.css("div.quote")), 20_000);
      while (t.scroll && (await d.findElements(By.css("div.quote"))).length < t.want) { await d.executeScript("window.scrollBy(0, 4000)"); await d.sleep(300); }
      return (await d.findElements(By.css("div.quote"))).length;
    },
    close: ({ d }) => d.quit(),
  },
};

const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
const rows = [];
for (const [lib, L] of Object.entries(LIBS)) {
  const launches = [];
  for (const t of TASKS) {
    const times = [];
    let ok = 0;
    for (let i = 0; i < 3; i++) {
      let t0 = performance.now();
      const s = await L.open();
      launches.push(performance.now() - t0);
      t0 = performance.now();
      try {
        if ((await L.run(s, t)) >= t.want) ok++;
      } catch {}
      times.push(performance.now() - t0);
      await L.close(s);
    }
    rows.push([lib, t.name, `${ok}/3`, (median(times) / 1000).toFixed(1)]);
  }
  rows.push([lib, "Browser start (median)", "", (median(launches) / 1000).toFixed(2)]);
}

console.log("library     task                          got the data  median seconds");
for (const [lib, task, ok, s] of rows) console.log(`${lib.padEnd(12)}${task.padEnd(30)}${ok.padStart(12)}${s.padStart(16)}`);
```

Output (ran 1 Oct 2026; Node.js 24.16.0, puppeteer 24.43.1, selenium-webdriver 4.50.0, playwright 1.63.0; 201.6 s):

```text
library     task                          got the data  median seconds
Playwright  Rendered by JavaScript                 3/3             2.1
Playwright  Rendered after 10 s                    3/3            12.0
Playwright  Infinite scroll (30+ items)            3/3             2.5
Playwright  Table loaded by a click                3/3             4.5
Playwright  Browser start (median)                                0.11
Puppeteer   Rendered by JavaScript                 3/3             2.1
Puppeteer   Rendered after 10 s                    3/3            11.9
Puppeteer   Infinite scroll (30+ items)            3/3             2.5
Puppeteer   Table loaded by a click                3/3             4.0
Puppeteer   Browser start (median)                                0.40
Selenium    Rendered by JavaScript                 3/3             2.3
Selenium    Rendered after 10 s                    3/3            12.1
Selenium    Infinite scroll (30+ items)            3/3             2.8
Selenium    Table loaded by a click                3/3             4.1
Selenium    Browser start (median)                                0.37
```

All three got the data every time, and their task times were within half a second of each other [checked 1 Oct 2026]. On these pages the waiting is the page's own (the second task deliberately renders after 10 seconds), not the library's. The clearest measured difference was starting the browser: 0.11 seconds for Playwright, which uses a lightweight headless build, against about 0.4 for the other two. That matters when you start a browser per page, and not when you reuse one.

The tasks were picked to need a browser: a page built by JavaScript, the same page rendering after a delay, an infinite-scroll list that loads more as you scroll, and a table that appears only after a click. Real sites add bot protection, heavier pages and slower servers, which change the times for all three alike.

## The same task in each library

Here is one task, reading the quotes from the page that renders after 10 seconds, written the way each library's documentation suggests. Each ran as shown.

### Playwright

```javascript
// Playwright: a page whose quotes appear 10 seconds after it loads. Locators wait for elements on their own.
// npm install playwright    npx playwright install chromium
import { chromium } from "playwright";

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://quotes.toscrape.com/js-delayed/");
const quotes = page.locator("div.quote");
await quotes.first().waitFor({ timeout: 20_000 });
const rows = await quotes.evaluateAll((els) => els.map((e) => ({ text: e.querySelector(".text").textContent, author: e.querySelector(".author").textContent })));
await browser.close();

console.log(`${rows.length} quotes, first by ${rows[0].author}`);
```

Output (ran 1 Oct 2026; Node.js 24.16.0, playwright 1.63.0; 12.6 s):

```text
10 quotes, first by Albert Einstein
```

Locators wait for their element automatically, and `evaluateAll` reads every match inside the page in one call.

### Puppeteer

```javascript
// Puppeteer: the same delayed page. Wait for the selector explicitly, then read the elements in the page.
// npm install puppeteer   (downloads a matching Chrome)
import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://quotes.toscrape.com/js-delayed/");
await page.waitForSelector("div.quote", { timeout: 20_000 });
const rows = await page.$$eval("div.quote", (els) => els.map((e) => ({ text: e.querySelector(".text").textContent, author: e.querySelector(".author").textContent })));
await browser.close();

console.log(`${rows.length} quotes, first by ${rows[0].author}`);
```

Output (ran 1 Oct 2026; Node.js 24.16.0, puppeteer 24.43.1; 15.1 s):

```text
10 quotes, first by Albert Einstein
```

You wait for the selector yourself, then `$$eval` runs a function over every match inside the page. The API is close to Playwright's, whose authors had worked on Puppeteer before.

### Selenium

```javascript
// Selenium WebDriver for Node.js: the same delayed page. An explicit wait holds until the quotes exist.
// npm install selenium-webdriver   (Selenium Manager downloads the matching ChromeDriver)
import { Builder, By, until } from "selenium-webdriver";
import chrome from "selenium-webdriver/chrome.js";

const driver = await new Builder().forBrowser("chrome").setChromeOptions(new chrome.Options().addArguments("--headless=new")).build();
try {
  await driver.get("https://quotes.toscrape.com/js-delayed/");
  await driver.wait(until.elementLocated(By.css("div.quote")), 20_000);
  const rows = [];
  for (const q of await driver.findElements(By.css("div.quote"))) {
    rows.push({ text: await q.findElement(By.css(".text")).getText(), author: await q.findElement(By.css(".author")).getText() });
  }
  console.log(`${rows.length} quotes, first by ${rows[0].author}`);
} finally {
  await driver.quit();
}
```

Output (ran 1 Oct 2026; Node.js 24.16.0, selenium-webdriver 4.50.0; 13.1 s):

```text
10 quotes, first by Albert Einstein
```

An explicit wait holds until the element exists. Reading each element is a separate call to the browser, which adds up on pages with thousands of items; returning the data from a single `executeScript` call avoids that.

## How they differ

| | Playwright | Puppeteer | Selenium |
| --- | --- | --- | --- |
| Languages (official) | JavaScript/TypeScript, Python, Java, .NET | JavaScript/TypeScript (Node.js) | Java, Python, C#, Ruby, JavaScript |
| Browsers | Chromium, Firefox, WebKit; branded Chrome and Edge | Chrome, Firefox | Chrome, Edge, Firefox, Safari |
| Waiting | Automatic on locators | Explicit `waitForSelector` | Explicit `WebDriverWait` |
| Browser setup | `npx playwright install` downloads its browsers | Downloads Chrome for Testing on install | Uses your installed browser; Selenium Manager fetches the driver |
| Browser start in our runs | 0.11 s | 0.40 s | 0.37 s |

Language and browser support come from each project's documentation, read on 1 October 2026 [checked 1 Oct 2026].

## Which to choose

- **Playwright** if you are starting fresh: automatic waiting means less code that breaks on slow pages, one API covers three browser engines, and it is available in Python as well as Node.js. The [Python guide](https://skryp.dev/guides/web-scraping-python#3-pages-built-with-javascript-playwright) uses it.
- **Puppeteer** if your project is Node.js and only needs Chrome: it is a focused library with a large body of examples. See [Web scraping with JavaScript](https://skryp.dev/guides/web-scraping-javascript).
- **Selenium** if your team works in Java, C# or Ruby, needs real Safari or Edge, or already has Selenium infrastructure such as a Grid. See [Web scraping with Selenium and Python](https://skryp.dev/guides/selenium-web-scraping).

## When none of them is the answer

All three put a browser on your machine or your server, and the work that follows is yours: keeping browsers updated, running them in parallel, and handling sites that block automated browsers or show different content by country. If that is most of your effort, a hosted service runs the browser per request. [Skryp's live browser](https://skryp.dev/product/browser) does it from an agent or the API, and its [scrape](https://skryp.dev/product/scrape) call decides on its own whether a page needs a browser at all.

## Questions

**Playwright or Puppeteer?**

Playwright, for most new scraping projects: locators wait automatically, it drives Chromium, Firefox and WebKit, and it works from Python, Java and .NET as well as Node.js. Puppeteer suits a Node.js project that only needs Chrome. In our runs both got the data every time at the same speed.

**Is Playwright better than Selenium for web scraping?**

For new projects it usually means less code, because it waits for elements on its own, and its browser starts faster (0.11 s against 0.37 s in our runs). Selenium is the better fit if you work in C# or Ruby, need real Safari, or already run Selenium Grid. Both got the data from every page we tried.

**Puppeteer or Selenium for web scraping?**

Puppeteer if you work in Node.js and only need Chrome; Selenium if you need another language or browser, or already have Selenium infrastructure. On our four JavaScript-heavy tasks both succeeded every time, within half a second of each other.


## Evidence

- On four JavaScript-heavy tasks (a rendered page, a page that renders after 10 seconds, infinite scroll and a table loaded by a click), each run three times on 1 October 2026, Playwright 1.63, Puppeteer 24.43 and Selenium 4.50 for Node.js all got the data 12 of 12 times; their median task times were within half a second of each other, and median browser start was 0.11 s (Playwright), 0.37 s (Selenium) and 0.40 s (Puppeteer). (observed test, checked 1 Oct 2026; quotes.toscrape.com and scrapethissite.com practice pages; headless Chromium-based browsers on a MacBook in South Africa. Limits: Practice sites, one machine, one connection; real sites with bot protection or heavy pages will differ. Playwright's start time uses its lightweight headless shell.)
- Selenium's main project ships language bindings for Java, Python, C#, Ruby and JavaScript and drives Chrome, Edge, Firefox and Safari; Playwright is available for JavaScript/TypeScript, Python, Java and .NET and drives Chromium, Firefox and WebKit plus branded Chrome and Edge; Puppeteer is a Node.js library that drives Chrome and Firefox. (provider docs, checked 1 Oct 2026; selenium.dev/downloads and /documentation/webdriver/browsers, playwright.dev/docs/languages and /docs/browsers, pptr.dev/supported-browsers Limits: Community bindings exist for other languages; only each project's official ones are listed.)
