Get new posts by email

New posts on data pipelines, scraping, and market-data ML — straight to your inbox.

It's completely free, and you can unsubscribe anytime.

By subscribing you agree to Substack's Terms of Use, its Privacy Policy and its Information collection notice.

Substack
Web scrapingTooling

Playwright vs Selenium: which to use and when

Locators, saved sessions and pinned browsers — plus the 52 fixed sleeps that show what auto-waiting does not fix.

Almost everything written comparing these two is about test suites. The comparison tables cover browser coverage, language bindings, parallel execution and CI integration, which are the right things to compare if you are choosing a tool to test your own web app in. I have never used either for that. I use them to get data out of pages that do not exist until JavaScript has run, and the things that decide the choice for scraping barely appear in those tables.

The architecture difference is real and is usually stated first: Selenium talks to a browser over HTTP through the WebDriver protocol, Playwright holds a persistent WebSocket to it. That does make Playwright faster per operation, and for a scrape it makes almost no difference, because the time goes on waiting for pages and on being polite to the server, not on the round trip to the browser. The differences that mattered to me were waiting, session reuse, browser versioning, and — fourth — whether a browser is needed at all.

Waiting: locators against explicit waits

Your code runs instantly and a page does not. Ask for a button that has not been drawn and you get an error rather than a button. Selenium's answer is that you say so explicitly, wrapping what you want in a wait that polls until a condition holds.

self.wait = WebDriverWait(self.driver, 15)
news_tab = WebDriverWait(self.driver, 10).until(
    EC.element_to_be_clickable((By.XPATH, "//a[contains(text(),'News')]")))

The scraper that line came from is 563 lines and twelve of them are except clauses. One catches StaleElementReferenceException, which is worth understanding even if you never touch Selenium. When you find an element, Selenium hands you a reference to that object in the page. If the page redraws that part of itself, and single-page sites do this constantly for reasons unrelated to you, the reference points at something that no longer exists and using it raises. The element is still on screen. Your handle to it is not.

Playwright hands you a locator instead: a stored description of how to find the element, re-resolved every time you use it, waiting on its own for the element to exist, be visible and be ready.

await page.locator("input[name='password']").fill(PASSWORD)
await page.get_by_role("button", name="Log in").click()
await page.wait_for_url("**/home", timeout=15000)

A redraw between two lines is not an error, because the description is re-run rather than a handle reused. Selenium 4 has relative locators and a much better API than the one most tutorials show, but it does not have this property, and this property is most of what people mean when they say Playwright is less flaky.

What the marketing claims is that auto-waiting means you stop writing sleep(5). I counted across twelve Playwright scrapers I had written: 52 calls to wait_for_timeout, which is a fixed sleep under another name, against 25 uses of the proper wait-for-condition call. Two out of three waits were still blind.

Some of those are deliberate pacing between requests and have nothing to do with the tool. The rest are the interesting case: auto-waiting answers "is this element ready", which requires an element whose readiness proves the page is done. Click a filter that triggers a background fetch which swaps numbers inside cells already on screen, and there is no new element to wait for. The correct answer is to wait on the network response rather than the DOM, which Playwright can do and which I did not reach for often enough.

async with page.expect_response(lambda r: "/api/quotes" in r.url and r.ok):
    await page.click("#apply-filter")

Auto-waiting removes the class of failure where the element was not there yet. It does not tell you when a page has finished, and no tool can, because only you know what "finished" means for the data you came for.

Session reuse: storage state against user profiles

This changed how I structure scrapers more than the waiting did, and it gets far less attention than the waiting does.

Signing in from code is the most fragile part of any scraper. The login page is the most heavily defended page on the site, and a wrong move costs the account rather than the run. Playwright lets you sign in once by hand in a visible browser and save what the browser learned — cookies and origin storage — to a JSON file, then start every later run from that file.

browser = await p.chromium.launch(headless=False)   # visible: log in as a human
context = await browser.new_context()
# ... fill the login form once, then:
await context.storage_state(path="storageState.json")

The code that does the scraping then contains no credentials at all, which means it can go in a repository and the session file can be handed over separately. Selenium's equivalent is pointing Chrome at a saved user-data directory. That works and it is heavier: a whole profile on disk rather than a small file, carrying state you did not ask for, and two runs cannot share it simultaneously.

Sessions expire, so this needs a check that notices you are logged out and stops rather than scraping a page of login prompts. I have been caught by that twice.

Browser versioning and unattended jobs

Selenium drives a browser you supply, so the browser and its driver must be version-compatible while Chrome updates itself roughly monthly. This was a genuine operational cost for years, patched over with webdriver-manager downloading a matching driver at startup. Since 4.6 Selenium ships Selenium Manager, which resolves both the driver and, since 4.11, the browser itself. If you last touched Selenium before that, most of what you remember about driver pain is fixed.

Playwright pins its own browser builds through playwright install, so the browser is a dependency of the project rather than a property of the machine. A scrape that ran last month runs the same way this month and the same way in a container. For jobs on a schedule I value that more than any API difference, and it is the one thing I would not want to give up.

Playwright's other operational advantage is the trace viewer, which records a run and replays it with the DOM, network and console at each step. Debugging a scrape that failed at three in the morning against a page that has since changed is otherwise close to impossible. I ignored it for far too long.

Blocking: where neither tool helps

Neither tool helps with the thing that actually stops scrapers, which is being refused. A site I was collecting from started returning 405 to a plain Python request while a real browser on the same machine loaded the page fine. It was not reading the headers. It was fingerprinting the TLS handshake, which is decided before any header is sent and differs between Python's networking stack and Chrome's. The fix was one import, and no browser.

# curl_cffi makes a plain HTTP request whose TLS fingerprint matches Chrome's.
from curl_cffi import requests as cffi_requests

resp = cffi_requests.get(url, headers=HEADERS, impersonate="chrome131")

Which leads to the largest saving available here and the reason I would not start this decision with either tool. A browser is the right answer when the data genuinely does not exist until JavaScript runs. Otherwise it is the most expensive thing on the shelf: starting Chrome to fetch a JSON endpoint costs hundreds of megabytes and about a second of startup to do what one HTTP request does in 200 milliseconds. Of my own collection scripts, roughly half never open a browser, and one of them is still named trendlyne_selenium.py despite containing no Selenium — it fetches HTML directly now, and I never renamed the file.

The check takes two minutes. Open the network tab, reload the page, filter to XHR, and look at what the page itself is calling. If the numbers you want arrive in a JSON response, call that endpoint with the right Referer and skip the browser entirely. When that is not possible and you do need a browser, request interception earns its keep immediately: blocking images, fonts and analytics typically halves page-load time, and you were never going to parse them.

Choosing between them

For new scraping work, Playwright, mostly for the pinned browsers and the saved session, with locators and the trace viewer as the reasons I would not switch back.

For an existing Selenium suite, nothing above justifies a rewrite. Both of my Selenium scripts still run and I have never ported them. A working scraper is worth more than a modern one, and Selenium Manager removed the maintenance argument that would have been my strongest reason.

For cross-browser work on browsers you do not control, or a team already running Grid, Selenium, and it is not close. That is a testing problem rather than a scraping one, and it is what the W3C standard and twenty years of infrastructure are for.

And if you would rather not manage browsers, queues and retries yourself, Crawlee wraps Playwright with request queueing, concurrency limits and retries already built. I wrote most of that by hand before I knew it existed, which I would not do again.

One limitation on all of this: it comes from moving the same jobs between the two tools over about two years, not from a controlled benchmark. I never ran both against the same site on the same day with a stopwatch, so nothing here is a speed claim. The parts I would defend are the failure modes and the operational ones, because those left logs.

References and further reading

Playwright's own page on actionability lists exactly which conditions each action waits for, which is the most useful page in either project's documentation and is the thing to read before deciding whether auto-waiting covers your case.

Selenium's waits documentation is direct about why implicit waits and explicit waits should not be mixed, which is a footgun I hit before I read it.

Documentation worth having open

Tools mentioned

Related posts

My own projects