🎉 Premium Proxies · 24-Hour Free TrialClaim Now
Comparisons

Nodriver vs Undetected-Chromedriver: Migration Guide

nodriver vs undetected-chromedriver: the WebDriver-to-CDP shift, a real code migration, what silently breaks, and two packaging bugs nobody mentions.

S SparkProxy 2 29 min read
Share

On nodriver vs undetected-chromedriver the short answer is: move to nodriver for anything new, but pin nodriver==0.47.0, because every release from 0.48.0 onward ships a byte that CPython 3.14 refuses to parse at import time, and the undetected-chromedriver build on PyPI has been unable to import on a clean 3.13 or 3.14 venv since the day distutils left the standard library.

The honest version of this comparison isn't a stealth shootout. Both libraries come from the same author, ultrafunkamsterdam, and nodriver's README calls itself the "official successor" of the other. The real question is whether to move a working Selenium codebase onto a raw CDP library, what that rewrite costs, and whether either package still works in October 2026. That last part is where every comparison article stops short, because answering it means running pip install in a fresh virtualenv and then actually importing the thing. We did, on CPython 3.14.2 and again on 3.13.5. Both installs succeeded. Neither package imported on 3.14.2, for two unrelated reasons, and only one of the two failures survives on 3.13.

For the broader "which stealth tool should I use at all" question, read undetected-chromedriver vs Patchright vs Camoufox. This post is narrower: you already run undetected-chromedriver and want to know whether to leave, what breaks, and how.

What actually changed: WebDriver out, raw CDP in

undetected-chromedriver isn't a stealth framework wrapped around Selenium. It is Selenium. The class declaration in __init__.py reads class Chrome(selenium.webdriver.chrome.webdriver.WebDriver), and it instantiates a ChromiumService pointing at a ChromeDriver binary it has downloaded and byte-patched. Every call you make travels Python to chromedriver over HTTP, then chromedriver to Chrome over the DevTools Protocol.

nodriver removes the middle hop. Its README puts it bluntly: "No more webdriver, no more selenium", and a feature bullet reads "No chromedriver binary or Selenium dependency." It launches Chrome itself and speaks CDP directly over a WebSocket.

undetected-chromedrivernodriver
Protocol to the browser[W3C WebDriver](https://www.w3.org/TR/webdriver2/) over local HTTP, then CDPCDP over one WebSocket
Processes involvedPython, chromedriver, ChromePython, Chrome
Concurrency modelSynchronous, blockingFully asynchronous (`async`/`await`)
Third-party driver binaryYes, downloaded and patched at runtimeNone
Runtime dependencies`selenium>=4.9` and its tree`mss`, `websockets>=14`, `deprecated`
Python floorNone declared (classifiers stop at 3.11)`>=3.9`
LicenseGPL-3.0AGPL-3.0

That dependency row isn't a footnote. A clean pip install nodriver produced five packages besides pip: nodriver, mss, websockets, Deprecated and wrapt, the last of those pulled in by Deprecated. undetected-chromedriver resolved seventeen, Selenium 4.50.0 and its trio, requests and cffi subtrees among them. The licence difference matters if you ship a hosted service, since nodriver moved to network-copyleft AGPL-3.0.

The clearest proof that this is one swap and not a rewrite is nodriver's own bridge function in nodriver/core/util.py, which adopts a live undetected-chromedriver session:

# nodriver/core/util.py, abridged
async def create_from_undetected_chromedriver(driver) -> Browser:
    conf = Config()
    host, port = driver.options.debugger_address.split(":")
    conf.host, conf.port = host, int(port)
    browser = await start(conf)
    browser._process_pid = driver.browser_pid
    driver.service.stop()        # kill chromedriver, keep Chrome
    return browser

It reads the debugger address off the Selenium options, connects nodriver to the same Chrome, then stops the chromedriver service. The browser lives, the driver dies. That's the whole migration in five lines. Note the direction: it's one-way, and there's no Selenium-compatibility shim anywhere in the package. nodriver never claims to be a drop-in replacement, and it isn't one.

Both packages install fine, then refuse to import

This is the part other comparisons miss, and it's easy to miss, because pip reports success for both. Each command resolves and builds without a warning. The import on the next line is where it falls apart.

undetected-chromedriver dies on a module that no longer exists. pip install undetected-chromedriver gives you 3.5.5, uploaded 17 February 2024 and never updated. Line 4 of its patcher.py is from distutils.version import LooseVersion, and distutils left the standard library in Python 3.12. The 3.12 release notes say it directly: "PEP 632: Remove the distutils package." The same document records that venv no longer pre-installs setuptools (gh-95299), which removes the shim that used to hide this:

Traceback (most recent call last):
  File "<string>", line 1, in <module>
    import undetected_chromedriver
  File "...\site-packages\undetected_chromedriver\__init__.py", line 44, in <module>
    from .patcher import IS_POSIX
  File "...\site-packages\undetected_chromedriver\patcher.py", line 4, in <module>
    from distutils.version import LooseVersion
ModuleNotFoundError: No module named 'distutils'

The fix exists. Master's patcher.py line 4 reads from packaging.version import Version as LooseVersion, committed on 5 July 2025 as "Refactor: Update for Python 3.13+ compatibility" (c50b6a21). It has never been published to PyPI. The repaired code is on GitHub and the broken artifact is what pip hands you, fifteen months later.

The workaround is one line, because setuptools still vendors a distutils shim:

pip install setuptools
# import now works, with:
# DeprecationWarning: distutils Version classes are deprecated.
#   Use packaging.version instead.

Two caveats. We reproduced this on clean venvs under CPython 3.14.2 and CPython 3.13.5, identical traceback both times; 3.12 follows from the same stdlib removal, since that is the release PEP 632 landed in, but we didn't run a 3.12 interpreter ourselves. And plenty of environments have setuptools installed for unrelated reasons, which makes the failure invisible to a large share of users. If you've never hit it, that's why.

nodriver dies before Python finishes tokenizing the module. pip install nodriver gives 0.50.5, uploaded 5 October 2026. Then, on 3.14:

  File "...\site-packages\nodriver\cdp\network.py", line 1330
SyntaxError: Non-UTF-8 code starting with '\xb1' on line 1330,
             but no encoding declared; see https://peps.python.org/pep-0263/

One stray 0xb1 byte. It's a latin-1 ± inside the comment #: JSON (±Inf)., in a file with no PEP 263 encoding declaration, so the tokenizer refuses the whole module. This isn't an install artifact: the GitHub raw copy of nodriver/cdp/network.py also fails to decode as UTF-8 and holds exactly one 0xb1 byte.

Which interpreter you're on decides whether you ever see it, and that's the detail every write-up on issue #35 leaves out. Run a file carrying that byte in a comment as a script and 3.13.5 and 3.14.2 both raise the identical SyntaxError. Import it as a module and they disagree: 3.14.2 refuses it, 3.13.5 takes it without complaint, byte still in place. Same split on the real package, so import nodriver at 0.50.5 works on 3.13.5 and dies on 3.14.2. Move the byte into a string literal and 3.13 rejects it too, so what 3.13's import path is doing is skipping comment bytes without validating them. We couldn't find the CPython change that tightened this and the 3.14 release notes don't mention it, so take that as measured, not documented.

Then we byte-scanned the published wheels, every .py file in each one, for a UTF-8 decode:

nodriver versionUpload dateAll .py files UTF-8?`import nodriver` on 3.14.2
0.402025-02-28CleanWorks
0.422025-03-26CleanWorks
0.46.22025-09-06CleanWorks
**0.47.0****2025-07-06****Clean, the highest clean release****Works**
0.48.02025-10-29BrokenSyntaxError
0.48.12025-11-09BrokenSyntaxError
0.50.12026-05-11BrokenSyntaxError
0.50.22026-05-13BrokenSyntaxError
0.50.32026-05-13BrokenSyntaxError
0.50.42026-10-05BrokenSyntaxError
0.50.52026-10-05BrokenSyntaxError

Read the upload dates, not just the version strings. 0.46.2 went to PyPI on 6 September 2025, two months after 0.47.0 on 6 July 2025, so the release order runs backwards at that point. Resolvers go by version, so the highest encoding-clean release is 0.47.0, and pinning 0.46.2 because it looks like the newest clean one costs you 0.47.0 for nothing. Nothing is yanked, either: all 45 releases are still installable, broken ones included.

In every broken build the only offending file is nodriver/cdp/network.py, and it always holds exactly one 0xb1 byte. It was reported upstream from a Linux workstation as issue #35 on 31 March 2026, the reporter noting "Converting network.py to UTF-8 solves the issue." The one-line fix, PR #36, was opened three minutes later. Both are still open and unmerged as of 8 October 2026.

So your requirements file, if you start a nodriver project today:

# Highest encoding-clean nodriver release. 0.48.0+ ships a non-UTF-8
# byte in nodriver/cdp/network.py that CPython 3.14 refuses to import
# (upstream issue #35, PR #36 open). Note 0.46.2 was uploaded LATER
# than 0.47.0 but is the lower version, so 0.47.0 is the right pin.
nodriver==0.47.0

One honest caveat on that pin. nodriver 0.50.1 rewrote the CDP transport to "flat mode," which is what makes tab.find() reach into iframes and what tab.get_frames() is for, and the README warns "please test thoroughly, especially if you run large projects." Pinning 0.47.0 gives that up. We verified 0.47.0 installs and imports on both 3.14.2 and 3.13.5; we didn't functionally test it, so we have no evidence either way about other regressions. If iframe traversal matters to your targets, keep the new version and fix the byte yourself as a post-install step.

One trap there, and it's why most copies of this snippet don't work: your patch script can't import nodriver to find the file, because that import is the broken thing. Ask the import system where the package lives without executing it:

# fix_nodriver.py, run once after every install
import importlib.util, pathlib

spec = importlib.util.find_spec("nodriver")
p = pathlib.Path(spec.origin).parent / "cdp" / "network.py"
p.write_bytes(p.read_bytes().replace(b"\xb1", b"+/-"))
print("patched", p)

find_spec() resolves the location without running the package's __init__, so it survives a module that won't tokenize. Run against a broken 0.50.5 install on 3.14.2, it replaced the byte and import nodriver then succeeded. Owning that step forever is exactly what zendriver exists to spare you.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Frozen, not deprecated: what that means if you stay

The precise wording matters here, because it decides whether you have a migration deadline or just a stale dependency. You have the second one.

undetected-chromedriver is not archived and not disabled. The GitHub API returns archived: false, disabled: false. There is no deprecation notice in the repo. We grepped the raw README for nodriver, deprecat, no longer, unmaintain, maintenance, successor, archiv, abandon and discontinu. Zero matches on all nine. The README never mentions nodriver at all. Every claim of succession comes from nodriver's side, not from undetected-chromedriver's.

What you have instead are structural signals, and they are unambiguous:

Signalundetected-chromedrivernodriver
Latest PyPI release3.5.5, 2024-02-170.50.5, 2026-10-05
Last push to default branch2025-07-052026-10-05
GitHub releases publishedNone (API returns Not Found)None (API returns Not Found)
Issue tracker**Disabled** (`has_issues: false`)Open (`has_issues: true`)
Open issues frozen in place1,14115 (7 of them PRs)
DiscussionsEnabled, traffic redirected thereNot used
Stars / forks12,863 / 1,3514,817 / 441
PyPI development status(not declared)3 - Alpha

Counts are a snapshot taken 8 October 2026 and will drift.

The hardest signal is that disabled issue tracker. 1,141 issues sit open and frozen while nobody can file the 1,142nd. The README gives the intent in the author's own words: "I will be putting limits on the issue tracker. It has beeen abused too long. any good news? Yes, i've opened Undetected-Discussions which i think will help us better in the long run." (The typo is in the original.) Master also went quiet for seventeen months: the commit before that July 2025 compatibility work is 0aa5fbe2 "3.5.5" from 17 February 2024.

So the accurate phrase is effectively superseded, not "officially deprecated." The practical difference is real. Nothing is going to be pulled out from under you, no PyPI page is going to sprout a deprecation banner, and 3.5.5 will keep installing. What you don't get is a fix for anything, which is why the sharpest symptom of the wind-down isn't missing stealth updates. It's that the published artifact won't import on a modern clean venv while the repaired line sits unreleased in the repo.

Read that as a maintenance budget rather than a deadline. Staying is a decision to own pip install setuptools, your own Chrome-compatibility testing, and any patch you need, indefinitely. That's a tolerable bill for a pipeline that already works, and a bad one for anything you're still building.

One thing that is not broken, contrary to a common assumption: 3.5.5 survived the Chrome 115 driver-hosting migration, because patcher.py already branches on version between chromedriver.storage.googleapis.com and Chrome for Testing. Whether it still drives today's Chrome is a separate question we didn't test.

The same scraping job, written both ways

Same task in both libraries: open a page, wait for the heading, dismiss a cookie banner found by its text, read the heading, screenshot, persist cookies.

undetected-chromedriver, synchronous, Selenium idioms throughout:

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

options = uc.ChromeOptions()
options.add_argument("--window-size=1920,1080")

driver = uc.Chrome(options=options, headless=False, use_subprocess=True)
try:
    driver.get("https://www.sparkproxy.io/pricing")

    heading = WebDriverWait(driver, 15).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "h1"))
    )
    try:
        driver.find_element(
            By.XPATH, "//button[contains(., 'Accept')]"
        ).click()
    except Exception:
        pass

    print(heading.text)
    driver.save_screenshot("pricing.png")
    cookies = driver.get_cookies()        # list of dicts, yours to store
finally:
    driver.quit()

nodriver, asynchronous, no Selenium anywhere:

import nodriver as uc

async def main():
    browser = await uc.start(headless=False)
    tab = await browser.get("https://www.sparkproxy.io/pricing")

    # wait_for() blocks and raises on timeout. select() would not: see below.
    heading = await tab.wait_for(selector="h1", timeout=15)

    accept = await tab.find("accept all", timeout=5)
    if accept:
        await accept.click()

    print(heading.text)
    await tab.save_screenshot("pricing.png", format="png")
    await browser.cookies.save("sparkproxy.dat")   # file, not a dict
    await browser.stop()

if __name__ == "__main__":
    uc.loop().run_until_complete(main())

Three things in that second block will trip you up, and only one of them is obvious.

The obvious one is async. Every element and page call in nodriver is awaited.

The second is the entry point. nodriver's documented launcher is not asyncio.run. The README uses uc.loop().run_until_complete(main()) with the comment "# since asyncio.run never worked (for me)". If you wire nodriver into an existing asyncio app, that detail is worth knowing before you debug it.

The third is format="png". tab.save_screenshot() defaults to format="jpeg", while Selenium's save_screenshot writes PNG. Same filename, different bytes, silently.

Here's the API map for the calls you'll actually be replacing:

undetected-chromedriver / Seleniumnodriver
`uc.Chrome(...)``await uc.start(...)`
`driver.get(url)``await browser.get(url)` returns a `Tab`
`driver.find_element(By.CSS_SELECTOR, s)``await tab.select(s)`
`driver.find_elements(By.CSS_SELECTOR, s)``await tab.select_all(s)`
`driver.find_element(By.XPATH, x)``await tab.xpath(x)`
`//*[contains(text(), "…")]` XPath hacks`await tab.find("…")`, fuzzy by text length
`WebDriverWait(d, t).until(EC.…)``await tab.wait_for(selector=…, timeout=t)`
`element.send_keys("x")``await element.send_keys("x")`
`element.clear()``await element.clear_input()`
`driver.execute_script(js)``await tab.evaluate(js)`
`driver.page_source``await tab.get_content()`
`driver.get_cookies()` / `add_cookie()``await browser.cookies.save(f)` / `.load(f)`
`driver.execute_cdp_cmd(...)``await tab.send(cdp..(...))`
`driver.quit()``await browser.stop()`
`time.sleep(n)``await tab.sleep(n)`

tab.find() is genuinely nicer than the XPath text-contains dance. Its docstring explains why it matches on closest string length: search for "login" and "you'd probably want the login button element, and not thousands of scripts, meta, headings containing a string of 'login'."

What silently breaks on migration

Async conversion is the loud problem. You will see it immediately, your test suite will go red, and the fix is mechanical if tedious. Our async web scraping in Python guide covers the concurrency patterns that actually matter once you get there.

The dangerous breakages are the quiet ones.

select() and find() don't raise on timeout, whatever their docstrings say. This is the single worst migration trap in the library, and it's right there in tab.py. The docstring for select() promises "raise timeout exception when after this many seconds nothing is found." The code does this:

item = await self.query_selector(selector)
while not item:
    await self
    item = await self.query_selector(selector)
    if loop.time() - start_time > timeout:
        return item          # <-- returns None. No exception.
    await self.sleep(0.5)
return item

find() ends the same way. So the honest translation of a Selenium wait isn't what most tutorials show you:

# Looks right. Fails silently when the element never appears:
#   AttributeError: 'NoneType' object has no attribute 'text'
#   ...three function calls later, nowhere near the real cause.
heading = await tab.select("h1", timeout=15)

# Actually raises, because wait_for() does raise:
#   asyncio.TimeoutError: time ran out while waiting for h1
heading = await tab.wait_for(selector="h1", timeout=15)

If you had WebDriverWait(...).until(...) inside a try/except TimeoutException and your retry logic hung off that exception, a straight port to select() deletes your error handling without a warning. Use wait_for() wherever you depended on the exception, and treat select() and find() as "element or falsy," which is what they are.

Cookie state moves from dicts to files. driver.get_cookies() hands you a list of dicts you can stuff into Redis per account. nodriver's CookieJar lives on the Browser, not the tab, and exposes get_all(), save(file='.session.dat', pattern='.*') and load(...). If your session store assumes per-driver dicts, that layer gets rewritten.

Profiles are ephemeral by default. nodriver uses a fresh temp profile per run and cleans it up on exit. Passing user_data_dir does two things at once: it reuses the profile and opts out of the cleanup, per the README, "by specifying it, it won't be automatically cleaned up when finished." Jobs that relied on a sticky Chrome profile need that argument added explicitly.

Headless guidance flips. undetected-chromedriver treats headless as officially unsupported ("headless is still WIP. Raising issues is needless"), and its JavaScript evasions only run in headless mode, since _configure_headless() is called solely inside if headless or getattr(options, "headless", None):. nodriver defaults headless=False and its README prefers a real display: "when running on a headless machine, like AWS... it's best to use some Xvfb tool, to emulate a screen."

expert=True isn't a stealth upgrade. nodriver's README: it "disables web security and origin-trials, as well as ensures shadow-roots are always open. This makes you more detectable though!" It also notes tab.cf_verify() "only works when NOT in expert mode" and needs opencv-python.

The Selenium ecosystem you give up

This is the cost nobody prices in. Leaving undetected-chromedriver doesn't just mean rewriting syntax, it means leaving an ecosystem built over a decade.

Gone, structurally, because there is no WebDriver client left to wrap:

  • selenium-wire and anything like it. The whole pattern was a local man-in-the-middle proxy wired into the WebDriver client to inject credentials and inspect requests. No client, no hook. In nodriver you observe traffic by registering CDP handlers yourself: tab.add_handler(cdp.network.RequestWillBeSent, my_callback). More power, no plugin.
  • expected_conditions and the whole WebDriverWait vocabulary. nodriver folds waiting into lookup, per the README: "an await tab.select('body') could be used as an indicator whether a page is loaded." There is no element_to_be_clickable, no staleness_of, no invisibility_of_element.
  • Selenium Grid, and remote WebDriver generally. Your distributed browser farm addressed over the WebDriver protocol doesn't speak CDP-over-WebSocket.
  • Page objects typed against WebElement. nodriver's Element has text, attrs, click(), send_keys(), apply(), get_html(), select_option(), save_screenshot(). Useful, overlapping, not the same class. Every type hint and helper in your page-object layer gets touched.
  • pytest-selenium and the Selenium-shaped test fixtures around it.

Carried over: Chrome itself, your CSS selectors, your XPath (via tab.xpath()), your target-site knowledge, and your proxy pool.

Budget honestly. A few scripts is an afternoon. A mature scraper with a page-object layer, retry policies hanging off TimeoutException, and a session store built on cookie dicts is a sprint, and the async conversion is the smallest part of it.

Proxies, auth, and IP reputation

Here nodriver is clearly, concretely better, and almost nobody says so.

undetected-chromedriver inherits Chrome's limitation: --proxy-server carries no credentials. If your pool authenticates with a username and password, your options are to allowlist the egress IP of every machine that runs the scraper, put a local forwarder in front of the browser, or ship a throwaway Chrome extension whose only job is to fill in the proxy credential dialog. We walk through the tradeoffs in Selenium proxy setup.

# undetected-chromedriver: works only for IP-allowlisted pools
options = uc.ChromeOptions()
options.add_argument("--proxy-server=http://gateway.sparkproxy.io:11000")
driver = uc.Chrome(options=options)

nodriver handles authenticated proxies natively, per browser context. Browser.create_context() in browser.py documents it: "mostly useful if you want to use proxies for different browser instances since chrome usually can only use 1 proxy per browser... http/https proxies with authentication are also supported: http://USERNAME:PASSWORD@SERVER:PORT". Under the hood it stands up a local ProxyForwarder and hands the forwarder's address to CDP Target.createBrowserContext.

import nodriver as uc

async def main():
    browser = await uc.start()

    # One context per target country, credentials inline, two tabs
    # with two separate exits in one browser process. On the
    # SparkProxy gateway the PORT picks rotation behaviour (11000
    # rotating HTTP/HTTPS, 11002 sticky session, 13000 SOCKS5) and
    # the COUNTRY goes in the username as -country-<iso2>.
    de = await browser.create_context(
        "https://www.sparkproxy.io/pricing",
        proxy_server="http://USER-country-de:PASS@gateway.sparkproxy.io:11000",
    )
    us = await browser.create_context(
        "https://www.sparkproxy.io/pricing",
        proxy_server="http://USER-country-us:PASS@gateway.sparkproxy.io:11000",
    )

    for tab in (de, us):
        await tab.wait_for(selector="h1", timeout=15)
        print(await tab.evaluate("document.title"))

    await browser.stop()

uc.loop().run_until_complete(main())

That's per-context proxying with auth in a single process, which undetected-chromedriver can't do without extension tricks. Swap the port to 11002 and add a session token to the username if a given context needs to hold one exit for the length of a login instead of rotating per request. If you want the extension route anyway, Config.add_extension() takes a zip or a directory and nodriver extracts it for you.

Now the part both libraries agree on. Neither one gives you an IP. undetected-chromedriver's README says it in bold capitals: "THIS PACKAGE DOES NOT, and i repeat DOES NOT hide your IP address, so when running from a datacenter (even smaller ones), chances are large you will not pass! Also, if your ip reputation at home is low, you won't pass!" Patching automation tells and sourcing clean IPs are two separate problems, and the second one is usually the one failing. Residential vs datacenter proxies covers that choice.

If you would rather not maintain a browser fleet for this at all, SparkProxy's Scraping API does the rendering and the IP sourcing in one request. The wait_for parameter maps directly onto the await tab.wait_for(selector=...) idiom above:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.sparkproxy.io/pricing" \
  --data-urlencode "render_js=true" \
  --data-urlencode "stealth=true" \
  --data-urlencode "country_code=DE" \
  --data-urlencode "wait_for=h1"

And the same call in Python, with a js_scenario standing in for the click-the-cookie-banner step:

import json
import requests

params = {
    "url": "https://www.sparkproxy.io/pricing",
    "render_js": "true",
    "stealth": "true",
    "premium_proxy": "true",
    "country_code": "DE",
    "wait_for": "h1",
    "format": "md",
    # instructions array, max 50; each entry is {"action_key": value}
    "js_scenario": json.dumps({
        "instructions": [
            {"wait_for": "h1"},
            {"click": "button[data-consent='accept']"},
            {"wait": 1000},            # milliseconds, capped at 30000
        ]
    }),
}

r = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params=params,
    timeout=120,
)
r.raise_for_status()
print(r.text[:500])

Costs are in credits, not currency: rotating proxy with HTTP only is 1 credit, rotating plus JS is 5, premium plus HTTP is 10, premium plus JS is 25, and each add-on (stealth, country_code, js_scenario, screenshot or PDF) adds 5. Self-hosting nodriver with your own pool is cheaper per request at volume on easy targets. That tradeoff is argued properly in web scraping API vs self-managed proxies.

Footprint and speed, without the marketing numbers

nodriver's README claims "performance gets a massive boost." Treat that as the project describing itself, not as a measurement. We ran no benchmark and won't attach a multiplier to CDP versus WebDriver, because nobody we can find has published a credible one. Be suspicious of any article that does.

What is structurally true and checkable:

  • One fewer process. undetected-chromedriver runs Python, chromedriver and Chrome. nodriver runs Python and Chrome. At high concurrency that is a process per session you stop paying for.
  • One fewer protocol translation per call. A Selenium command is an HTTP request to chromedriver, which then issues CDP. nodriver sends the CDP message directly.
  • No driver binary to download, patch, or version-match. undetected-chromedriver's Patcher detects your Chrome major version, fetches the matching chromedriver, opens it r+b, regex-matches rb"\{window\.cdc.*?;\}" and overwrites that byte range with b'{console.log("undetected chromedriver 1337!")}' padded to the same length. Startup cost plus a coupling to every Chrome update. nodriver has neither.
  • Async concurrency out of the box. 50 synchronous Selenium sessions means 50 threads or 50 processes. 50 nodriver tabs are 50 coroutines on one loop.

One source-level detection fact, stated carefully: nodriver never calls Runtime.enable. Greps for it across connection.py, tab.py, browser.py, util.py and config.py return zero matches, and the library issues cdp.runtime.evaluate(...) as one-off commands instead. That's a verified property of the source. Whether any anti-bot vendor keys on that signal today, and whether avoiding it helps in production, we didn't test and won't assert. For the signals detectors are known to read, see headless browser detection.

undetected-chromedriver's side is also narrower than its reputation suggests. It never passes --disable-blink-features=AutomationControlled: a grep of __init__.py and patcher.py for blink-features, AutomationControlled, excludeSwitches and useAutomationExtension returns nothing. Headful, it's the binary patch and a clean flag set, nothing more. We didn't launch Chrome and read navigator.webdriver in either library, so we're not telling you what a detection script sees.

nodriver vs undetected-chromedriver, side by side

undetected-chromedriver 3.5.5nodriver 0.50.5zendriver 0.17.1
Author / orgultrafunkamsterdamultrafunkamsterdamcdpdriver
Latest PyPI upload2024-02-172026-10-052026-10-02
Tagged GitHub releasesNoneNoneYes, v0.17.1
`requires_python`Not declared`>=3.9``>=3.10`
LicenseGPL-3.0AGPL-3.0AGPL-3.0
ProtocolWebDriver, then CDPCDPCDP
Sync or asyncSyncAsyncAsync
Entry point`uc.Chrome()``uc.loop().run_until_complete()``asyncio.run()`
Imports clean on 3.14.2No (needs `setuptools`)No at 0.50.5, yes at 0.47.0**Yes**
Authenticated proxy supportNo, extension workaroundYes, per contextYes (fork of nodriver)
Issue trackerDisabledOpen, 15 itemsOpen, 57 items
Dev status on PyPINot declared3 - Alpha3 - Alpha
Monetary priceFreeFreeFree

Verified 8 October 2026 against the GitHub API, PyPI JSON, and clean CPython 3.14.2 and 3.13.5 venvs.

When to stay on undetected-chromedriver

Migration isn't automatically correct. Stay where you are if any of these describe you.

Your scraper works and your targets are stable. A frozen dependency on a working pipeline is a known risk. A rewrite is an unknown one. "Last release February 2024" isn't an outage.

You depend on Selenium Grid or remote WebDriver. nodriver has no answer to a distributed WebDriver farm. That's the strongest single reason to stay.

You rely on selenium-wire style request interception. Possible in nodriver via CDP handlers, but you're implementing it, not installing it.

Your codebase is synchronous and large. Dropping an async library into a sync application means a threaded loop or a rewrite up the call stack. Sometimes the right answer is "not this quarter."

You are on Python 3.11 or earlier, or setuptools is already present. The distutils break never shows up for you, which removes the most urgent reason to move.

You need a genuinely maintained release today. This one cuts the other way, and it's the packaging state above read as a verdict: if "maintained" is your criterion, neither of these two qualifies, and the answer is zendriver.

If you're on the JavaScript side of this question, the equivalent decision is which stealth plugin to trust, which we cover in stealth plugins for Puppeteer and Playwright. And if your real question turns out to be "which stealth tool should I be on at all" rather than "should I migrate", that's a different comparison with a different shortlist, and undetected-chromedriver vs Patchright vs Camoufox is the one to read instead of this page.

zendriver, the fork that ships releases

zendriver is a live fork of nodriver maintained by the cdpdriver org, and its stated reason for existing is exactly the problem this article keeps running into. From its README: "This package is a fork of ultrafunkamsterdam/nodriver, created to add new features, compile unmerged bugfixes, and increase community engagement." And: "Unfortunately, contributions to the original nodriver repo are heavily restricted... At the time of writing, there are several pull requests open to fix critical bugs which have beeen left unaddressed for many months."

The record backs it. zendriver publishes tagged GitHub releases, which neither of the other two do: v0.17.1 on 2 October 2026, with a steady PyPI cadence through 0.15.1 (19 November 2025), 0.16.0 (16 August 2026), 0.17.0 (27 September 2026), 0.17.1 (2 October 2026). Snapshot on 8 October 2026: 1,454 stars, 108 forks, 57 open issues, tracker open. Its wheels also pass the byte scan we ran upstream, 0.16.0 and 0.17.1 both, and import zendriver works on CPython 3.14.2 with no pin and no patch step.

The API mirrors nodriver's, with one pleasant difference:

import asyncio
import zendriver as zd

async def main():
    browser = await zd.start()
    page = await browser.get("https://www.sparkproxy.io/pricing")
    await page.save_screenshot("pricing.png")
    await browser.stop()

asyncio.run(main())        # plain asyncio.run, no loop() helper

Two caveats to keep this honest. zendriver needs Python 3.10 or newer, stricter than nodriver's 3.9 floor, and carries more dependencies (asyncio-atexit, deprecated, emoji, grapheme, mss, websockets). And we verified packaging health and release cadence only. Whether its merged bugfixes make it more or less detectable than nodriver is something we didn't test, and nobody should claim it for you either.

Frequently asked questions

FAQ

Start new work on nodriver, pinned to 0.47.0, because it's the architecture the author is still shipping and it handles authenticated proxies per browser context. Keep an existing undetected-chromedriver scraper if it depends on Selenium Grid, on selenium-wire request interception, or on a large synchronous codebase, since those three have no nodriver equivalent you can install rather than build.

No. There is no Selenium-compatibility shim and the project never claims one. The only bridge is create_from_undetected_chromedriver(), a one-way adapter that attaches nodriver to a Chrome already launched by undetected-chromedriver and then stops the chromedriver process. Migrating code must be rewritten as async.

A single 0xb1 byte, a latin-1 ± in the comment #: JSON (±Inf)., sits in nodriver/cdp/network.py, which has no PEP 263 encoding declaration. Every release from 0.48.0 through 0.50.5 is affected; 0.47.0 and earlier are clean. CPython 3.14 refuses the module on import while 3.13 lets the byte through, so the same install can work on one interpreter and fail on the next. Pin nodriver==0.47.0, patch the byte yourself with importlib.util.find_spec, or use zendriver.

Yes, which is a real upgrade over undetected-chromedriver. await browser.create_context(url, proxy_server="http://USER:PASS@host:port") works per browser context, so one process can hold several tabs on separate exits instead of Chrome's usual one proxy per browser. nodriver starts a local forwarder to carry the credentials, since Chrome's own --proxy-server flag accepts none. Any geo or session targeting your provider encodes in the username travels with it.

If you want an artifact that installs and imports without manual fixes today, zendriver. It's a fork of nodriver built specifically to merge the bugfixes nodriver leaves open, it publishes tagged releases, and its 0.17.1 wheel imports cleanly on CPython 3.14.2. The API is nearly identical, so the switching cost is small.

No, and the waits are the hazard. WebDriverWait(...).until(...) raises TimeoutException, but nodriver's select() and find() return a falsy value on timeout despite docstrings promising an exception. Use tab.wait_for(selector=...), which does raise asyncio.TimeoutError, anywhere your retry logic depended on catching one.

Special Discount · 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter proxies, residential proxies, and Scraping API. We maintain our own headless rendering infrastructure, which is why this comparison is built on primary sources rather than on other articles. Every version number, date, and count here was pulled from the GitHub API, PyPI JSON endpoints, and the projects' own source files on 8 October 2026. Both import failures were reproduced first-hand in clean CPython 3.14.2 and 3.13.5 virtualenvs on that date, the eleven nodriver wheels in the bracket table were byte-scanned individually, and the patch script was run against a broken 0.50.5 install to confirm it recovers the import. Where we didn't test something, such as detection outcomes or performance, we say so instead of guessing. Corrections welcome at support@sparkproxy.io.

Keep reading

Related articles

curl_cffi vs tls-client vs hrequests (2026)

curl_cffi vs tls-client vs hrequests: there are only two engines here. Compare TLS and HTTP/2 fingerprint control, profile freshness, async model and install.

SparkProxy·Comparisons