Reading layout

undetected-chromedriver vs playwright-stealth

Both tools hide the same automation markers, but they intervene at different depths in the stack, which is the distinction that matters once you read past the feature lists in Browser Fingerprint and Stealth Configuration.

Stealth toolkit comparison matrix A table comparing undetected-chromedriver and playwright-stealth across four criteria: detection coverage, maintenance, startup speed, and concurrency. undetected-chromedriver rates high coverage but fragile maintenance, slower startup, and synchronous only; playwright-stealth rates medium-to-high coverage, stable maintenance, faster startup, and native async. Criterionundetected-chromedriverplaywright-stealthDetection coverageHighpatched binaryMedium–HighJS patch layerMaintenanceFragilebreaks on Chrome updatesStabletracks PlaywrightStartup speedSlowerbinary patch stepFasterno re-patchConcurrencySync onlythread per browserAsync nativeone event loop
How the two stealth toolkits compare across the criteria that usually decide the choice.

Choose undetected-chromedriver if you already run Selenium and want the deepest default masking for the smallest code change; choose playwright-stealth if you are building new, need many concurrent pages on one host, or want the browser version pinned by your package manager rather than by whatever Chrome the operating system happens to have installed today. The trade is coverage against maintenance: patching the driver and browser removes markers before any page script runs but couples you to the local Chrome major version, while injecting JavaScript into each document is more portable and more predictable but only ever as current as the patch set.

Neither is a licence to scrape. Reducing how suspicious your browser looks does not change what a site's terms permit, and both tools are equally capable of hammering a target if you forget to rate-limit.

Where Each One Intervenes

The clearest way to understand the difference is to locate each tool in the stack.

Where each stealth toolkit modifies the automation stack A four-layer stack from your automation code down to the page JavaScript context. undetected-chromedriver patches the driver and browser-process layers; playwright-stealth injects into the page JavaScript layer. Your automation codeSelenium API or Playwright APIDriver and protocolChromeDriver HTTP vs CDP WebSocketBrowser processlaunch flags and binary patchingPage JavaScriptnavigator, WebGL, plugins, canvasundetected-chromedriverpatches down herePlaywright usesCDP at this layerplaywright-stealthinjects hereDeeper patches clear more checks but break on every Chrome release; shallower ones travel better.
The two toolkits intervene at different depths. Patching the driver and browser removes markers before any page script runs; patching page JavaScript rewrites them after the browser has already started.

undetected-chromedriver works below your code. It patches the ChromeDriver binary itself β€” rewriting the cdc_ variable names that ChromeDriver injects into every document and that a two-line script can detect β€” and launches Chrome with an options set that never advertises automation. Because the modification happens to the driver executable and the launch, navigator.webdriver is never set in the first place rather than being redefined afterwards. There is nothing for a page script to catch mid-flight and nothing that a Object.getOwnPropertyDescriptor check can identify as a redefinition.

playwright-stealth works above the browser. It registers a set of JavaScript patches that Playwright injects into every new document via add_init_script, redefining navigator.webdriver, populating plugins and languages, spoofing the WebGL vendor and renderer strings, and smoothing a handful of other well-known probes. Because it rides on ordinary Playwright, you keep the async API, auto-waiting, request interception, and Playwright's bundled browser.

The practical consequence: undetected-chromedriver removes evidence, playwright-stealth overwrites it. Overwriting is detectable in principle β€” a redefined property has a different descriptor, and a spoofed getParameter is a function whose toString() no longer matches native code. Good patch sets handle both, but "handled by the patch set" is a moving target in a way that "the marker was never written" is not.

Detection Coverage in Practice

On a public fingerprint test page, a fresh undetected-chromedriver install typically clears the automation-flag rows with no configuration. playwright-stealth clears the same rows once applied, but you have to apply it to every context you create, and its coverage of newer probes depends on when the patch set was last updated.

Two areas are worth checking yourself rather than trusting either default. The first is the WebGL vendor and renderer pair, where a default that describes a machine nobody owns is worse than no spoofing at all β€” the mechanics are in Spoofing Canvas and WebGL Fingerprints. The second is the consistency between what the patches claim and what the browser actually is: a Linux container reporting Win32 will also report Linux-shaped font metrics, and font enumeration is not something either tool patches.

For a Selenium codebase that is not ready to move, the lighter-weight alternative is covered in How to Configure Selenium Stealth to Avoid Detection, which patches the same surfaces without touching the driver binary.

It is also worth being precise about what neither tool addresses, because most disappointment comes from expecting more than either offers. Both operate strictly on browser-exposed properties. Neither changes your outbound IP, neither alters request pacing, neither introduces plausible mouse movement or dwell time, and neither affects the TLS handshake β€” Chrome's own BoringSSL handshake is genuine in both cases, which is a point in their favour, but it also means neither is doing anything about it. Behavioural scoring in particular is invisible to a fingerprint test page and increasingly common: an agent that navigates twelve product pages in eleven seconds with no scroll events and no cursor movement is unusual regardless of how clean navigator looks.

The corollary is a useful diagnostic. If two sessions with identical stealth configuration behave differently, the difference is not the stealth layer β€” it is the IP, the pacing, or the account state. Change one of those before changing tools.

Running Both

The two scripts below load the same fingerprint page under each tool. Both set an explicit, matching User-Agent so the only variable is the stealth mechanism.

pip install "undetected-chromedriver>=3.5" "playwright>=1.44" playwright-stealth
playwright install chromium
import undetected_chromedriver as uc

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 run_uc(url: str, chrome_major: int | None = None) -> dict[str, str]:
    """Load a page with undetected-chromedriver and report the markers a script sees."""
    options = uc.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1920,1080")
    options.add_argument(f"--user-agent={USER_AGENT}")

    driver = uc.Chrome(options=options, use_subprocess=True, version_main=chrome_major)
    try:
        driver.get(url)
        return driver.execute_script(
            "return {"
            "  webdriver: String(navigator.webdriver),"
            "  plugins: String(navigator.plugins.length),"
            "  cdc: String(Object.keys(document).some(k => k.startsWith('cdc_')))"
            "};"
        )
    finally:
        driver.quit()


if __name__ == "__main__":
    print(run_uc("https://bot.sannysoft.com/"))
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth

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 run_playwright_stealth(url: str) -> dict[str, str]:
    """Load the same page with playwright-stealth and report the same markers."""
    with Stealth().use_sync(sync_playwright()) as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            viewport={"width": 1920, "height": 1080},
            locale="en-US",
            user_agent=USER_AGENT,
        )
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=30000)
        markers = page.evaluate(
            "() => ({"
            "  webdriver: String(navigator.webdriver),"
            "  plugins: String(navigator.plugins.length),"
            "  cdc: String(Object.keys(document).some(k => k.startsWith('cdc_')))"
            "})"
        )
        browser.close()
        return markers


if __name__ == "__main__":
    print(run_playwright_stealth("https://bot.sannysoft.com/"))

The cdc check is the one that separates them conceptually: ChromeDriver injects those keys, so an unpatched Selenium session reports true, a patched one reports false, and Playwright never had them because it does not use ChromeDriver at all.

Note the playwright_stealth import. The package's public API has changed more than once β€” older releases exported stealth_sync(page), current ones expose a Stealth class with use_sync/use_async wrappers. Copying an import line from an old tutorial produces ImportError: cannot import name 'stealth_sync', and the fix is to read the installed package's exports rather than to downgrade.

Maintenance: the Real Deciding Factor

Coverage differences are small and shift with every release. Maintenance differences are structural and do not.

Breakage window after a Chrome major release A timeline in four events: Chrome auto-updates on the host, undetected sessions start failing with SessionNotCreatedException, the team pins the major version or upgrades the package, and runs resume until the next Chrome bump. The gap between the second and third events is the outage. One Chrome release, one outage windowChrome 137auto-updates onthe build hostSessions dieSessionNotCreatedExceptionYou reactversion_main=137or upgrade the pkgRuns resumeuntil the nextChrome bumpevery run failshours to daystimeAlarm on the exception name, not on output volume β€” the drift is silent until a session refuses to start.
The failure is predictable and its cause is always the same. Alarm on the exception name rather than on a drop in scraped rows, and the outage lasts minutes instead of days.

undetected-chromedriver needs a driver matching the Chrome major version installed on the host. When Chrome auto-updates β€” which on a normal Linux image happens without anyone deciding to β€” every session starts failing with SessionNotCreatedException: This version of ChromeDriver only supports Chrome version N. The fix is quick (pin version_main to the new major, or upgrade the package) but the detection is slow, because nothing else in the pipeline changes. That gap between breakage and discovery is the actual cost, and it is why alerting on the exception name rather than on output volume matters β€” the general pattern is covered in Detecting Silent Scraper Failures.

Playwright inverts this. The browser is downloaded by playwright install and pinned by the Playwright version, so a host-level Chrome update changes nothing. Upgrades happen when you change a lockfile, on your schedule, in a reviewable diff. The equivalent breakage is a Playwright minor release changing the stealth package's API, which surfaces immediately at import time rather than at runtime in production.

On servers running undetected-chromedriver, disabling Chrome's background updater and pinning the browser package is worth the small operational effort. It converts a surprise into a scheduled task.

Startup and Concurrency Cost

undetected-chromedriver patches the driver binary on first use and, with use_subprocess=True, spawns an extra process, so cold start is noticeably slower than plain Selenium β€” commonly a second or more of additional latency on first launch, less on subsequent ones once the patched driver is cached. It is synchronous by construction, so concurrency means threads, and each thread carries its own driver and browser.

Playwright reuses one browser process across many contexts, so the marginal cost of an additional page is small, and the async API lets one event loop drive dozens of them. The measurement discipline behind those claims, and the memory figures, are in Playwright vs Selenium Performance Benchmarks.

For most workloads this is the tiebreaker. If you need one hardened browser against a difficult target, startup cost is irrelevant and coverage wins. If you need forty pages at once, memory decides and Playwright wins by an order of magnitude.

Edge Cases and Caveats

  • version_main is not optional in production. Leave it unset and the library guesses from the installed Chrome, which is exactly the value that changes underneath you. Pin it, and bump it deliberately.
  • Stealth must be applied per context, not per browser. A new Playwright context created later in the run does not inherit patches unless the stealth wrapper is in scope for it. This is a common source of "it worked on page one".
  • Neither handles interactive challenges. Turnstile, hCaptcha and reCAPTCHA v3 need genuine challenge execution or a solver, as described in Solving CAPTCHAs with Python.
  • A patched browser on a burnt IP is still blocked. Fingerprint work and IP work are independent, and the IP usually matters more β€” see Rotating Proxies and Managing IP Blocks.
  • Headless still differs from headed. Both tools reduce the gap; neither closes it. If a target only defeats you in headless mode, test headed on the same host before changing tools.
  • Both leave temporary state behind. undetected-chromedriver writes a patched driver and a throwaway profile directory per run, and Playwright creates a user-data directory per persistent context. On long-lived workers these accumulate until the disk fills, which surfaces as an unrelated-looking launch failure. Clean them up explicitly rather than relying on process exit.
  • Choosing is not required. Routing the hardest targets through undetected-chromedriver while running high-volume routine crawling on Playwright is a normal arrangement, and per-target selection beats standardising on one when the targets genuinely differ. The surrounding workflow is in Using Playwright for Modern Web Automation.

Frequently Asked Questions

Which has better detection coverage out of the box?undetected-chromedriver, on a fresh install, because it removes the ChromeDriver artefacts and automation flags before any page script runs rather than redefining them afterwards. playwright-stealth covers the same well-known surfaces through injected JavaScript and is usually sufficient, but its edge against a brand-new probe depends on how recently the patch set was updated.

Which is easier to keep working over time?playwright-stealth, because Playwright downloads and pins its own browser, so a host-level Chrome update cannot break it. undetected-chromedriver is tied to the locally installed Chrome major version and will fail with a session-creation error whenever that version moves, unless you pin both the browser package and version_main.

Can undetected-chromedriver be used with asyncio? Not natively β€” it is built on synchronous Selenium, so concurrency means one thread and one browser per worker. If you need many concurrent pages on a single event loop, or you are memory-constrained, Playwright's context model is the better fit.

Is it reasonable to use both in one project? Yes, and it is common. Difficult targets that reward deeper masking go through undetected-chromedriver; high-volume routine crawling goes through Playwright for its concurrency and lower per-page cost. Selecting per target is usually cheaper than forcing one tool to cover both cases.