---
title: "Playwright vs Selenium: Which One Should You Pick?"
description: "Playwright waits for elements on its own and takes a proxy per context; Selenium covers more languages and real Safari. We wrote the same job in both."
url: https://proxynet.io/blog/playwright-vs-selenium
date: 2026-09-19
author: "Acar Diveroli"
category: "Comparison, Web Scraping"
lang: en
---

# Playwright vs Selenium: Which One Should You Pick?

You are starting a new data collection or testing project and the page is built with JavaScript, so you need a real browser. Someone on the team has written Selenium for years; the new developer suggests Playwright. Both open a browser from code and click in it, both are free, and that similarity is what makes the choice hard. Most comparisons look at the question through a QA lens and skip the headings that decide the outcome in data collection, such as proxies and waiting.

This post compares the two tools along seven axes: architecture, automatic waiting, proxy handling, language support, browser setup, parallelism and debugging. We ran the code that does the same small job in both tools with Playwright 1.63, Selenium 4.49 and Chrome 153 on Windows 11, and we report what we saw in the relevant sections. "Which one is noticed less" is not the subject of this post: both tools open a real browser, and in both it is your job to respect the site's rules.

> **Note: Short answer**
>
> Playwright talks to the browser over a persistent connection, waits on its own until an element is ready, and takes the proxy with a username and password for each context separately; on a new project it gets you there with less code. Selenium follows the W3C WebDriver standard, supports more languages including Ruby and real Safari, and spreads across machines with Grid; if you have a working Selenium suite or a team built around Java and C#, there is usually no reason to leave it. Proxy authentication is not part of the standard in Selenium, and the practical route is an IP whitelist.

## What are Playwright and Selenium?

Selenium is an open source browser automation project that has been developed since 2004. The part in use today is Selenium WebDriver: your code sends commands to a browser driver, and the driver runs the browser. Our older [Selenium Proxy Integration](/blog/selenium) post still covers the setup; since that post dates from the Selenium 3 era, read its driver download and authentication steps together with the up-to-date information below.

Playwright is an open source browser automation library that Microsoft released in 2020. It drives the Chromium, Firefox and WebKit engines through a single API. Its installation and proxy settings are in [What Is Playwright and How to Use It With a Proxy](/blog/playwright-proxy); here we only cover the points where it parts ways with Selenium.

Both answer the same need: content that does not arrive with a plain HTTP request and only appears once JavaScript runs in the browser. Whether the page really needs a browser is something you find out with the check in [Static vs Dynamic Pages: Do You Need a Headless Browser?](/blog/static-vs-dynamic-pages). If the data sits in the page source, a plain HTTP client or [Scrapy](/blog/scrapy-proxy) is cheaper than either tool.

## What is the architectural difference: WebDriver, BiDi and CDP

Almost every behavioural difference between the two tools comes from how they talk to the browser.

In Selenium, a command travels like this:

1. Your code calls `driver.find_element(...)`.
2. The Selenium library turns that into an HTTP request from the W3C WebDriver standard.
3. The request goes to the driver program written by the browser vendor: ChromeDriver for Chrome, GeckoDriver for Firefox.
4. The driver applies the command to the browser and sends the result back as an HTTP response.

Every command is a separate request and response; the browser cannot tell you anything on its own. An error in the console or a network request going out in the background stays invisible unless you ask. To close that gap, the Selenium project is writing the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) standard together with browser vendors: a bidirectional protocol over WebSocket. In Selenium 4 you switch it on with `options.enable_bidi = True`; because the transition is still under way, two worlds live side by side in Selenium today.

Playwright takes a different route:

1. Your code calls `page.locator(...).click()`.
2. The Python, Java or .NET library passes that call to the Playwright driver bundled with the package. The driver is a Node.js process; in a virtual environment it sits as `playwright/driver/node.exe`.
3. The driver talks to the browser over a persistent connection. In Chromium that is the Chrome DevTools Protocol (CDP). For Firefox and WebKit, Playwright uses its own patched browser builds.
4. Because the connection is bidirectional, events in the browser (request, response, console message, download) flow into your code without you asking for them.

That choice has a price. Playwright's [browsers document](https://playwright.dev/python/docs/browsers) says it plainly: because it relies on patches, it does not work with branded Firefox and Safari. You can use Chrome and Edge through the `channel` option, but a WebKit build does not meet a "test in real Safari" requirement; Selenium drives the installed Safari through Apple's own driver.

## Comparison table

| | Playwright | Selenium |
|---|---|---|
| Protocol | Persistent connection; CDP in Chromium, patched builds in Firefox and WebKit | W3C WebDriver (HTTP), plus the emerging WebDriver BiDi (WebSocket) |
| Middle layer | Playwright driver bundled with the package | The browser vendor's driver (ChromeDriver, GeckoDriver) |
| Browsers | Chromium, Firefox, WebKit; Chrome and Edge through `channel` | Chrome, Edge, Firefox, Safari |
| Official languages | JavaScript and TypeScript, Python, Java, .NET | Java, Python, C#, Ruby, JavaScript |
| Waiting | Automatic before actions | You write it: `WebDriverWait` |
| Proxy scope | Per browser or per context | Per browser session |
| Proxy username and password | Separate fields in the `proxy` object | No field in the standard; the practical route is an IP whitelist |
| Listening to and blocking requests | Built in (`route`, `expect_response`) | Coming with BiDi; absent in classic WebDriver |
| Browser setup | `playwright install`, builds pinned to the version | Selenium Manager downloads the driver itself and uses the installed browser |
| Parallelism | Many contexts in one browser | One browser per task; Selenium Grid across machines |
| Debugging | Trace Viewer, Inspector, codegen | Screenshot, browser log, Selenium IDE |

## How do you write the same job in both tools?

Our test page is `quotes.toscrape.com/js-delayed/`: a practice site that prints its quotes with JavaScript and a ten second delay. The job: open the page through a proxy, read the quotes, click the "Next" link, verify the second page.

With Playwright:

```python
from playwright.sync_api import sync_playwright

URL = "https://quotes.toscrape.com/js-delayed/"
PROXY = {
    "server": "http://pr.proxynet.io:8000",
    "username": "user",
    "password": "pass",
}

with sync_playwright() as p:
    browser = p.chromium.launch(proxy=PROXY)
    page = browser.new_page()
    page.goto(URL)
    quotes = page.locator("div.quote")
    print("immediately:", quotes.count())  # 0: count() does not wait
    quotes.first.wait_for()  # waits until the first quote is printed on the page
    for quote in quotes.all():
        text = quote.locator("span.text").inner_text()
        author = quote.locator("small.author").inner_text()
        print(author, "-", text[:50])
    page.get_by_role("link", name="Next").click()  # waits on its own before clicking
    page.wait_for_url("**/page/2/")
    print(page.url)
    browser.close()
```

With Selenium:

```python
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

URL = "https://quotes.toscrape.com/js-delayed/"

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
# No username or password: your exit IP must be on the IP whitelist in the panel
options.add_argument("--proxy-server=http://pr.proxynet.io:8000")

driver = webdriver.Chrome(options=options)  # Selenium Manager finds the driver
try:
    driver.get(URL)
    print("immediately:", len(driver.find_elements(By.CSS_SELECTOR, "div.quote")))  # 0
    wait = WebDriverWait(driver, 15)
    quotes = wait.until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, "div.quote")))
    for quote in quotes:
        text = quote.find_element(By.CSS_SELECTOR, "span.text").text
        author = quote.find_element(By.CSS_SELECTOR, "small.author").text
        print(author, "-", text[:50])
    wait.until(EC.element_to_be_clickable((By.PARTIAL_LINK_TEXT, "Next"))).click()
    wait.until(EC.url_contains("/page/2/"))
    print(driver.current_url)
finally:
    driver.quit()
```

Both scripts printed the same ten quotes through our local test proxies and moved on to `/js-delayed/page/2/`. The line counts are close; the difference is in the detail. In Playwright the proxy credentials are inside the code, in Selenium they are not; the click is one line in Playwright, while in Selenium the clickability condition is written by hand. In both scripts the "immediately" line returned `0`: the page counts as loaded but the content is not there yet. That is the subject of the next section.

## What is the difference between automatic waiting and WebDriverWait?

On dynamic pages most errors come out of timing: the code looks for an element that JavaScript has not printed yet. Selenium's [waits document](https://www.selenium.dev/documentation/webdriver/waits/) calls this a race condition; sometimes the browser is ready first and the code works, sometimes the code moves first and you get an error.

In Selenium the fix is in your hands. `driver.get()` waits for the page's `load` event, but it knows nothing about content added later by JavaScript. The default value of the implicit wait is zero; if the element is not there, the error comes back immediately. The recommended route is the explicit wait: `WebDriverWait` polls the page until a condition becomes true. The document adds one more warning: do not use the two together, or the timings become unpredictable.

Playwright buries the same work inside the action. According to the [actionability document](https://playwright.dev/python/docs/actionability), a `click()` call waits until the element is visible, its position is stable, it can receive the click and it is enabled; if the conditions are not met within the timeout, it throws a `TimeoutError`. Calls that work with a single element, such as `fill()` and `inner_text()`, also wait for the element to appear.

This has a limit: automatic waiting is for actions, not for counts. In the example above `quotes.count()` returned `0` without waiting. Playwright's API document adds the same note for `locator.all()`: it does not wait for matching elements, it gives you whatever is on the page at that moment. If you are going to read a list, wait for the first element with `first.wait_for()`. That is why "you do not write waits in Playwright" is only half true.

The second difference is in the element reference. In Selenium, `find_element` returns a reference to a DOM node as it stands; if the page redraws that part, the reference goes stale and you get a `StaleElementReferenceException`. In Playwright a `locator` is a recipe that is resolved again on every use, and the same error is rarely seen. The selector language is up to you in both tools: [CSS Selector vs XPath: Which One for Web Scraping?](/blog/css-selector-vs-xpath).

## How does proxy handling differ?

We covered the Playwright side in the sibling post, so here is the summary: the `proxy` object is passed to `launch()` or `new_context()`, `username` and `password` are separate fields, and in a single browser every context can go out from a different IP. A context isolates cookies, storage and the proxy. Because the browser and the machine stay the same, the [browser fingerprint](/blog/browser-fingerprinting) does not change between contexts; a context is a session separator, not a separate device.

In Selenium the proxy is a session capability: it is given as the browser starts and binds every tab of that browser; for a different proxy you close it with `driver.quit()` and open a new browser. The authentication side is missing from the standard itself. [The W3C WebDriver proxy definition](https://www.w3.org/TR/webdriver2/#proxy) lists the `httpProxy`, `sslProxy`, `socksProxy`, `socksVersion` and `noProxy` keys; there is no key for a username or a password. Selenium's official example also shows only the `<HOST:PORT>` form. The standard says the server address may carry credentials, but [Chromium's proxy document](https://chromium.googlesource.com/chromium/src/+/HEAD/net/docs/proxy.md#Proxy-credentials-in-manual-proxy-settings) states that Chrome does not use credentials embedded in the proxy settings.

We tried what this leads to in practice with a local proxy that asks for authentication. The results belong to single runs on this machine (Selenium 4.49, Chrome 153):

| What we tried | What happened |
|---|---|
| `--proxy-server=http://server:port`, no credentials | The proxy returned `407`. `driver.get()` threw nothing; the page was blank and `driver.title` came back empty |
| `--proxy-server=http://user:pass@server:port` | Chrome opened the `ERR_NO_SUPPORTED_PROXIES` error page, again with no exception |
| `user:pass@server:port` in the `Proxy` object | The page opened, but not a single request reached the proxy log: the browser connected directly, with our own IP |
| A port that does not ask for authentication (the IP whitelist case) | Worked without trouble |

The third row is the most dangerous: the script looks like it is working while the traffic is not going through the proxy. There is also no such Chrome argument as `--proxy-auth`, which older posts sometimes mention. Selenium's `add_auth_handler` call on the BiDi side is described in the documentation for a site's own Basic authentication; it is not a documented route for proxies, and our attempt with a proxy defined ended in a page load timeout.

That is why the route we recommend in Selenium is an IP whitelist: you add the exit IP of the server the script runs on to the list in the panel and give the `--proxy-server` value without credentials. The comparison of the two methods is in [Proxy Authentication: User:Pass vs IP Whitelist](/blog/proxy-authentication-methods), and SeleniumBase's proxy syntax is in [How to Use Proxy with SeleniumBase?](/blog/how-to-use-proxy-with-seleniumbase).

We made one more observation in the same runs: the installed Chrome that Selenium opened also connected to Google's update and account services through the proxy, alongside the target site; in Playwright's own Chromium build only the page's own requests appeared in the proxy log. When you pay for traffic by the gigabyte with [Residential Proxy](https://proxynet.io/residential-proxy), that background traffic is worth watching from your panel.

## Language support and ecosystem

Selenium's official libraries are for Java, Python, C#, Ruby and JavaScript. Playwright's are for JavaScript and TypeScript, Python, Java and .NET. If you are a Ruby team, the choice makes itself.

Even where the languages overlap on paper, the centres of gravity differ. Selenium is a twenty year old project; a large share of corporate test teams write Selenium in Java or C#, there are suites of hundreds of scenarios built with TestNG, JUnit, NUnit and Cucumber, and the cost of rewriting them is usually greater than the convenience Playwright brings. Playwright's most mature face is the Node.js side: its own test runner comes with parallel execution, screenshot comparison and automatic trace recording. In Python the recommended route is the pytest plugin. We touch on data collection with C# in [Web Scraping With C#: HttpClient and Proxy](/blog/csharp-web-scraping).

For scraping, the choice of language often comes before the choice of tool: if the pipeline that processes the data is in Python, both tools do the job; if it is in Node.js, Playwright sits more naturally. The comparison of the two languages is in [Web Scraping: JavaScript or Python?](/blog/web-scraping-javascript-vs-python).

## Browser setup: Selenium Manager and playwright install

Older Selenium guides tell you to download the ChromeDriver matching your Chrome version by hand and pass its path with `executable_path`. Both are behind us: in Selenium 4.49, `webdriver.Chrome()` only accepts the `options`, `service` and `keep_alive` parameters, and since version 4.6 the job of finding the driver belongs to [Selenium Manager](https://www.selenium.dev/documentation/selenium_manager/), which ships with the library: it detects the version of the installed browser, downloads the matching driver and keeps it in the `~/.cache/selenium` folder. In our test it downloaded ChromeDriver 153 for the installed Chrome 153 with no extra step. According to the document it can also download Chrome, Firefox and Edge if the browser is not installed; the tool is still versioned as beta.

Playwright's approach is the opposite: it does not trust the browser on the system. `playwright install chromium` downloads its own build, and every Playwright version is pinned to a specific browser build. If you update the library and forget to run `install` again, you get the "Executable doesn't exist" error; we got it too while testing for this post.

The trade-off is this: Selenium drives the browser your users actually use and that updates itself, which is meaningful for testing but means behaviour can change when the browser updates. In Playwright the browser version is locked with the code, and you choose when to update.

## Parallel work: Grid or contexts?

In Playwright the unit of parallelism is the context. One browser process opens, and each task takes its own context and, if you want, its own proxy:

```python
import asyncio

from playwright.async_api import async_playwright

URLS = [f"https://quotes.toscrape.com/js/page/{n}/" for n in range(1, 4)]
PROXY = {"server": "http://pr.proxynet.io:8000", "username": "user", "password": "pass"}

async def scrape(browser, url):
    context = await browser.new_context(proxy=PROXY)  # isolated session with its own proxy
    try:
        page = await context.new_page()
        await page.goto(url)
        quotes = page.locator("div.quote span.text")
        await quotes.first.wait_for()
        return url, await quotes.count()
    finally:
        await context.close()

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()  # a single browser process
        for url, count in await asyncio.gather(*(scrape(browser, u) for u in URLS)):
            print(url, count)
        await browser.close()

asyncio.run(main())
```

In Selenium the unit is the browser itself. The same job is written with a thread pool that opens one driver per task:

```python
from concurrent.futures import ThreadPoolExecutor

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

URLS = [f"https://quotes.toscrape.com/js/page/{n}/" for n in range(1, 4)]

def scrape(url):
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--proxy-server=http://pr.proxynet.io:8000")  # with an IP whitelist
    driver = webdriver.Chrome(options=options)  # one browser process per task
    try:
        driver.get(url)
        quotes = WebDriverWait(driver, 15).until(
            EC.presence_of_all_elements_located((By.CSS_SELECTOR, "div.quote span.text"))
        )
        return url, len(quotes)
    finally:
        driver.quit()

with ThreadPoolExecutor(max_workers=3) as pool:
    for url, count in pool.map(scrape, URLS):
        print(url, count)
```

Both scripts returned ten quotes from each of the three pages. The difference is in resource use: the first opens one browser, the second opens three browsers and three driver processes. At twenty concurrent sessions that difference lands straight on memory. When concurrency and parallelism actually help in scraping is in [Concurrency vs Parallelism: What Sets Scraping Speed?](/blog/concurrency-vs-parallelism).

Where Selenium is strong is distribution across machines. Selenium Grid routes commands to browsers on remote machines; according to its official document its purpose is to run tests in parallel on more than one machine. Running an "Edge on Windows, Safari on macOS, Firefox on Linux" matrix from a single suite is Grid's job. Playwright has no direct equivalent: it leaves distribution across machines to your CI system. Its ability to connect to Grid is marked experimental in the documentation and covers only Chromium based browsers.

## Debugging tools

Playwright is clearly ahead under this heading. A recording that starts with `context.tracing.start(screenshots=True, snapshots=True)` and ends with `tracing.stop(path="trace.zip")` opens with the `playwright show-trace trace.zip` command: the DOM snapshot before and after every action, the network requests and the console messages all sit on a timeline. The question of why a crawl that ran overnight came back empty is answered from that file in the morning. `PWDEBUG=1` opens the Inspector and steps through the script, and `playwright codegen` turns what you do in the browser into code.

Selenium has no single equivalent tool. You take a screenshot with `driver.save_screenshot()`, and in Chrome you turn on the `goog:loggingPrefs` capability and read console messages with `driver.get_log("browser")`; we tried both and they work. The record and replay need is met by the Selenium IDE extension. Live listening to network requests is coming with BiDi, but for a record you walk through later you lean on reporting frameworks.

## Speed: why we give no numbers

Most comparisons on the internet carry a "Playwright is this much faster" percentage; those numbers were taken on the author's own machine, on the author's own page. The architecture creates an expectation in Playwright's favour: an open connection instead of an HTTP request per command, a context instead of a browser. But in a real crawl running through a proxy, most of the time is spent on the network and in the target site's response time; in our example both scripts finished in the shadow of a ten second page delay.

If you are going to measure, measure on your own target, with the same proxy and concurrency, on a sample of a few dozen pages. In most projects the real gain comes not from switching tools but from not opening a browser when you do not need one.

## Use cases

- **Collecting data from dynamic pages:** on a new project Playwright's request listening and resource blocking cut traffic; the general setup is on the [data scraping solution](/data-scraping) page.
- **Crawling many pages regularly:** the queue and discovery logic is tool independent; see the [web crawler solution](/web-crawler) page and the [What Is Pagination and How Do You Crawl It?](/blog/pagination-web-scraping) post.
- **Testing your application from different countries:** one context per country in Playwright, one session per country in Selenium; the detail is on the [app testing](/app-testing) page.
- **Cross browser compatibility testing:** if you need real Safari and an operating system matrix, Selenium and Grid.
- **Bulk crawling of independent pages:** in both tools a single gateway address with [Rotating Proxy](https://proxynet.io/rotating-proxy) is enough; the gateway handles the rotation.

Whichever tool you pick, the frame stays the same: follow the `robots.txt` rules and the terms of use, use the official API when there is one, and keep your request rate at a level the site can carry. The detail is in [What Is a robots.txt File and How Do You Read It?](/blog/robots-txt).

## Common mistakes

- **Embedding credentials in the proxy address in Selenium.** Chrome does not use them; in our test the result was either an error page or a direct connection with no proxy at all.
- **Assuming the page opened because `driver.get()` threw nothing.** Selenium does not give you the status code; verify the element you expect and the exit IP.
- **Thinking `count()` or `all()` will wait in Playwright.** They do not; call `first.wait_for()` first.
- **Solving waiting with `time.sleep()`.** It is both slow and brittle; use `WebDriverWait` in Selenium and locator waiting in Playwright.
- **Mixing implicit and explicit waits in Selenium.** The official document warns about it: the timings become unpredictable.
- **Not calling `driver.quit()` in Selenium.** Every session left open leaves a Chrome and a driver process behind; use `try/finally`.
- **Moving a working Selenium suite only because it is fashionable.** The cost of the migration is not in the selectors, it is in the waiting logic and the test infrastructure.
- **Updating Playwright without updating the browsers.** Every version wants its own build; add a `playwright install` step to your CI image.

## Decision guide

| Need | Recommendation |
|---|---|
| A new scraping project in Python or Node.js | Playwright |
| A proxy that works with a username and password | Playwright; switch to an IP whitelist in Selenium |
| Dozens of independent sessions on one machine, a separate IP per session | Playwright, proxy per context |
| A working, maintained Selenium test suite | Stay with Selenium |
| A corporate test team centred on Java or C# | Either works; the existing framework and experience decide |
| Ruby | Selenium |
| Testing in real Safari and on different operating systems | Selenium and Grid |
| Debugging after the fact in jobs that run overnight | Playwright, Trace Viewer |
| Reading a network response, blocking images and fonts | Playwright (built in) |
| Data in the page source or at a JSON endpoint | Neither: a plain HTTP client or Scrapy |

## Frequently asked questions

### Does Playwright replace Selenium?

It is often the first choice on new projects, but saying it has replaced Selenium would not be right. Selenium is the reference implementation of a W3C standard, its drivers are written by browser vendors, and the missing bidirectional communication is being added through the standard with WebDriver BiDi. Both projects are actively developed.

### Is moving from Selenium to Playwright hard?

Selectors carry over for the most part; CSS and XPath work in both. The part that takes effort is the waiting logic: `WebDriverWait` blocks go away and a locator based flow takes their place; on the testing side, page objects, reporting and CI steps are rebuilt as well. A small script moves in a day; in a suite of hundreds of scenarios it is less risky to write the new scenarios in Playwright first and leave the old ones where they are.

### Can the two be used together in the same project?

Yes, they do not interfere with each other. The common arrangement is to keep the existing Selenium regression suite while writing new work in Playwright. The point to watch is managing two separate browser setup models together in the CI image.

### Can a proxy with a username and password never be used in Selenium?

Not through the standard route: the WebDriver proxy definition has no credential field, and Chrome does not use credentials embedded in the address. Third party packages that put a local forwarding proxy in between fill that gap, but they add a dependency. If you are running from a server with a fixed IP, an IP whitelist is both simpler and safer; the password never appears in the code.

### Is Selenium in Python slower than Playwright?

There is no general answer. The architecture is in Playwright's favour, but in a crawl through a proxy the time is mostly decided by the network and the target site. Do not carry published percentages over to your own job; measure it yourself on the same target, with the same proxy and concurrency.

### Do the two behave differently in headless mode?

Both run without a window. Playwright opens headless by default and downloads a separate "headless shell" build for it; to see the window you write `launch(headless=False)`. Selenium opens with a window by default and switches to windowless mode in Chrome with the `--headless=new` argument. The proxy setting is the same in both modes.

## Summary

Playwright and Selenium do the same job by different routes. Selenium sends commands over W3C WebDriver to the browser vendor's driver; it covers more languages, real Safari and distribution across machines with Grid, and leaves waiting and proxy credentials to you. Playwright sets up a persistent connection with the browser; it waits on its own, takes the proxy with a username and password per context, listens to network traffic and makes debugging easier with a trace file. On a new scraping project Playwright gets you moving with less code; if you have a working Selenium suite, connecting it to a proxy with an IP whitelist is usually enough. You can find the proxy types you can use with either tool in our [proxy services](/proxy).
