Playwright vs Selenium Performance Benchmarks
Framework choice shows up in your infrastructure bill long before it shows up in your code, which is why this comparison sits alongside Using Playwright for Modern Web Automation.
The result up front: on a single page load of a static document, the two are close enough that the difference is noise. Playwright pulls ahead measurably on three things — cold start, concurrency, and anything requiring network interception — because it drives the browser over a persistent WebSocket rather than a per-command HTTP request, and because a second "session" is a browser context rather than a second browser. If your workload is one page at a time, do not migrate for speed. If it is fifty pages at a time on a memory-capped container, the marginal cost of the fifty-first page is the number that decides it.
Every figure on this page came from one machine and one target. They are indicative, not authoritative — the harness below exists so you can reproduce them on your own hardware, which is the only benchmark that should influence a decision.
Where the Time Actually Goes
Selenium speaks the W3C WebDriver protocol. Each command is an HTTP request to a local driver process, which translates it into whatever the browser understands and returns a JSON response. That is a full request-response cycle per find_element, per click, per get_attribute. On a local loopback the round trip is cheap in absolute terms — typically low single-digit milliseconds — but a page interaction that touches forty elements pays it forty times.
Playwright connects once to the browser over a WebSocket carrying the DevTools Protocol and keeps it open. Commands are frames on that connection, and events flow back unprompted, which is what makes its auto-waiting cheap: the browser tells Playwright when an element became actionable instead of Playwright polling to ask.
That difference matters less than people expect for raw navigation and more than people expect for two specific things. The first is polling. A Selenium WebDriverWait with the default 500 ms interval spends real time asleep; if the condition became true 20 ms in, you still wait out the rest of the poll interval. Choosing the right wait strategy is worth more than choosing the framework, which is the point of Explicit vs Implicit Waits in Selenium. The second is startup: Selenium spawns a driver process and a browser process per session, and process creation dominates any single-page measurement.
A Harness Whose Numbers Mean Something
Most published browser benchmarks measure process startup and call it page speed. Before comparing anything, fix the harness.
Four rules make the numbers comparable. Discard a warm-up iteration, because the first run pays for OS page cache, JIT warm-up, and TLS session establishment. Hold the browser build identical — comparing Selenium-driven Chrome 125 against Playwright-bundled Chromium 124 measures the browsers, not the frameworks. Report a median and a p95 rather than a mean, because browser timings have a long right tail and a single GC pause moves a mean by more than the effect you are trying to measure. And separate the startup cost from the per-page cost explicitly, since they scale completely differently.
Running It
Both scripts below measure the same thing: the wall-clock time from navigation start to DOMContentLoaded, repeated, with the launch cost measured separately. Both send an explicit User-Agent so the target sees an identical client.
pip install "playwright>=1.44" "selenium>=4.20"
playwright install chromium
import statistics
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
def build_options() -> Options:
options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
options.add_argument(f"--user-agent={USER_AGENT}")
return options
def bench_selenium(url: str, runs: int = 20) -> dict[str, float]:
"""Measure Selenium launch cost and per-page navigation cost separately."""
launch_start = time.perf_counter()
driver = webdriver.Chrome(options=build_options())
launch_s = time.perf_counter() - launch_start
try:
driver.get(url) # warm-up, discarded
samples: list[float] = []
for _ in range(runs):
start = time.perf_counter()
driver.get(url)
samples.append(time.perf_counter() - start)
finally:
driver.quit()
samples.sort()
return {
"launch_s": round(launch_s, 3),
"median_s": round(statistics.median(samples), 3),
"p95_s": round(samples[int(len(samples) * 0.95) - 1], 3),
}
if __name__ == "__main__":
print(bench_selenium("https://books.toscrape.com/"))
import asyncio
import statistics
import time
from playwright.async_api import async_playwright
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
async def bench_playwright(url: str, runs: int = 20) -> dict[str, float]:
"""Measure Playwright launch cost and per-page navigation cost separately."""
async with async_playwright() as p:
launch_start = time.perf_counter()
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent=USER_AGENT,
viewport={"width": 1920, "height": 1080},
)
page = await context.new_page()
launch_s = time.perf_counter() - launch_start
await page.goto(url, wait_until="domcontentloaded") # warm-up, discarded
samples: list[float] = []
for _ in range(runs):
start = time.perf_counter()
await page.goto(url, wait_until="domcontentloaded")
samples.append(time.perf_counter() - start)
await browser.close()
samples.sort()
return {
"launch_s": round(launch_s, 3),
"median_s": round(statistics.median(samples), 3),
"p95_s": round(samples[int(len(samples) * 0.95) - 1], 3),
}
if __name__ == "__main__":
print(asyncio.run(bench_playwright("https://books.toscrape.com/")))
Note what is deliberately not in these scripts: no implicit waits, no networkidle, no element lookups. networkidle in particular is a trap for benchmarking, because it resolves on a heuristic about network quiet rather than on a document event, and on a page with polling analytics it may never resolve at all.
Indicative Numbers
On one 8-vCPU Linux host, Python 3.12, headless Chromium 125, fetching books.toscrape.com over a wired connection, twenty runs after a discarded warm-up, the shape of the result was:
| Measurement | Selenium | Playwright |
|---|---|---|
| Cold launch to first usable page | ~1.9 s | ~1.1 s |
| Median repeat navigation | ~0.31 s | ~0.28 s |
| p95 repeat navigation | ~0.52 s | ~0.39 s |
Treat these as the shape rather than the values. Cold start differs by roughly a factor of two because Selenium starts two processes; steady-state navigation differs by a few percent because at that point both are just waiting for the same network and the same renderer. The p95 spread is where the protocol difference shows up: Playwright's event-driven completion has less jitter than Selenium's polled completion.
Anything you read that reports a large steady-state gap on a static page is almost certainly measuring launch cost amortised into the page number, or comparing an implicit wait against an event.
The number that changes the architecture is neither of these. It is the per-page cost once the browser is already warm and you are running many pages at once — the throughput ceiling. Measured that way on the same host, a single Selenium worker sustained roughly three pages a second against a static target before the driver's HTTP round trips became the bottleneck, while a Playwright event loop driving ten contexts sustained noticeably more before CPU saturated. Both numbers move by more than the gap between them if you change the target, so treat the ranking as robust and the magnitude as local to that box.
One thing that will not show up in either script is interaction cost. A benchmark that only navigates hides the protocol difference almost entirely, because navigation is one command. Add twenty find_element calls per page and the WebDriver round trips become visible; add an assertion loop and Selenium's polled waiting shows up as dead time that Playwright does not pay. If your real workload extracts forty fields per page, benchmark that shape rather than a bare goto.
Memory, and What Concurrency Costs
Throughput on a fixed box is a memory question long before it is a CPU question.
A single session costs roughly the same either way: a Python process, a browser, and — for Playwright — the Node driver that brokers the connection. Playwright is actually slightly heavier for one page, because of that driver. The divergence appears at page two. In Selenium, a second concurrent page means a second webdriver.Chrome(), which means another driver process and another browser: on the order of 190–200 MB. In Playwright, a second page in a new browser context reuses the same browser process and costs an order of magnitude less, because contexts share the renderer infrastructure while keeping cookies, storage, and cache isolated.
The practical consequence is a different scaling knob. With Selenium you scale by adding containers. With Playwright you scale contexts inside one browser until CPU, not memory, becomes the limit — and you cap that with a semaphore rather than a thread pool, following the pattern in Limiting Concurrency with Semaphores.
Contexts are not free of caveats. They share the browser process, so a renderer crash on one page can take down its siblings, and a page that leaks listeners will hold memory for as long as the browser lives. Recycling the browser every few hundred pages is cheap insurance.
Edge Cases and Caveats
- Version drift moves the numbers. Both projects ship frequently, and a Playwright minor release can bundle a new Chromium major. Record the exact
playwright --version, Selenium version, and browser build alongside any result, or the number is not reproducible even by you. - Headless mode is not one thing. Chrome's
--headless=newbehaves much more like headed Chrome than the old headless did, and it is measurably slower than the legacy mode. A benchmark that does not say which was used cannot be compared with one that does. - The target dominates on real sites. On a JavaScript-heavy page, both frameworks spend most of their time waiting for the same scripts to execute. Framework choice moves the number by percent; blocking third-party requests moves it by tens of percent, and Playwright's routing makes that easier to do.
- Docker changes the ranking. A container with a default 64 MB
/dev/shmwill make Chrome crash or thrash under either framework. Set--disable-dev-shm-usageor mount a larger/dev/shmbefore drawing conclusions. - Stealth layers add their own cost. Patching tools change startup time in ways that are not symmetric between the two stacks; see undetected-chromedriver vs playwright-stealth if that is part of your pipeline.
- A browser may be the wrong tool entirely. If the page's data arrives from a JSON endpoint, neither framework is fast — an HTTP client is, which is the argument made in Scrapy vs Playwright for Single-Page Apps.
Frequently Asked Questions
Is Playwright faster than Selenium for scraping? For cold starts and for many concurrent pages, yes, and by a wide margin in the concurrency case. For a single repeated navigation on a warm browser the difference is a few percent, because at that point both are waiting on the same network and the same rendering engine. Migrating an existing Selenium codebase purely for per-page speed is rarely worth it.
Why do published benchmarks disagree so much?
Because most of them measure different things. Launch cost folded into a per-page number, networkidle compared against domcontentloaded, implicit waits compared against events, and different browser builds all produce large apparent gaps. Any benchmark that does not state its wait condition and its browser version cannot be interpreted.
Does Playwright really use less memory? Not for the first session — it is slightly heavier, because of the Node driver process. It uses dramatically less per additional concurrent page, because a browser context reuses the running browser instead of starting a new one. That marginal figure is what determines how many pages fit on a box.
Which should I pick for a new scraping project? Playwright, unless you have a specific reason not to: async-native, cheaper concurrency, built-in request interception, and browser versions pinned by the package rather than by the host. Selenium remains the right answer when you need the W3C protocol for grid infrastructure you already run, or browsers Playwright does not bundle.
Related
- Using Playwright for Modern Web Automation — the parent topic for this comparison.
- Handling Infinite Scroll with Playwright — where the auto-waiting model earns its keep.
- Mastering Selenium for Dynamic Websites — the Selenium side of the same problem.
- Asynchronous Scraping with Asyncio and HTTPX — the concurrency model Playwright's async API plugs into.