This nodriver Python tutorial shows how to install the library, how to use the core API, and what we measured against bot detection.

Key takeaways
- Nodriver 0.50.3 was released on 13 May 2026. It's an asynchronous library which needs Python 3.10 or newer, and it controls Chrome directly through the DevTools Protocol. You therefore don't install Selenium or ChromeDriver.
- In practice, 6 tab methods are enough for finding, waiting, and evaluating. They are
tab.select,tab.select_all,tab.find,tab.find_all,tab.wait_for, andtab.evaluate. Nodriver providesverify_cf()for Turnstile checkboxes. However, on a live widget thecf-turnstile-responsevalue stayed empty. - In headless mode,
navigator.userAgentstill reportsHeadlessChrome, because the code which removes this token hasn't been called since version 0.46.1. Headless mode failed 3 of the 31 graded checks on bot.sannysoft.com in our run, but headed mode failed none. - Nodriver passed a Cloudflare "Just a moment" interstitial on nowsecure.nl in September 2026 without any help. However, a G2 page protected by DataDome loaded only in headed mode, and a different user agent didn't change this behavior.
Nodriver is an asynchronous browser automation library for Python. It controls Chrome through the Chrome DevTools Protocol, which is also called CDP. Most Python automation drives the browser through a separate driver process. The session which that process creates carries signals a page can test.
Nodriver runs no driver process of its own. Leon (ultrafunkamsterdam) wrote the library, and he also wrote undetected-chromedriver. For this reason import nodriver as uc keeps the old alias.
The command pip install nodriver installs version 0.50.3. However, 3 instructions which many guides repeat now fail. These are the quickstart install line, the websockets pin, and the call to the Cloudflare helper.
What is nodriver and why it replaced undetected-chromedriver
The nodriver Python library opens a WebSocket to Chrome's own debugging endpoint. Selenium typically controls Chrome through ChromeDriver and the WebDriver protocol instead. The resulting browser session can expose automation state such as navigator.webdriver, which reads True. A website can't inspect the driver executable or any file on your disk. CDP leaves traces of its own. Fingerprints and behavior are separate signals. This design removes one family of signals and not every signal.
On a new run, navigator.webdriver reads False and window.chrome is present. In headed mode, nodriver failed none of the 31 graded checks on bot.sannysoft.com. That page is a diagnostic, and a clean result there doesn't tell you how a commercial bot defense will react. The library launches Chrome with a new temporary profile, and it removes this profile on exit. If you set user_data_dir to your own directory, the profile is kept.
The README doesn't mention the license. Nodriver is AGPL-3.0, and that license carries a network clause. Whether the clause reaches your own deployment depends on facts which we can't judge here. Ask your counsel before you ship a modified copy. The library supports Chromium-based browsers only.
Is nodriver still maintained? The project moves slowly, because the last commit and the last release are both from 13 May 2026. The oldest requests in nodriver's pull request queue have been open for years, not for months. The issue tracker shows how many issues are open.
When we checked, the repository included no test suite and no CI which runs tests. The headless user-agent repair went 16 months without a call, and a test suite would probably have caught it. Zendriver is a fork which fixes 3 of the defects we measured. SeleniumBase is a Selenium framework, and its CDP Mode comes from nodriver. Its CDP driver file begins with the line CDP-Driver is based on NoDriver.
TL;DR nodriver example code
This script is complete, and it ran as printed on 0.50.3. You need Python 3.10 or newer, Chrome, and a display. Install the library with pip install nodriver. Save the file under any name except nodriver.py, because Python would import your file and not the library:
import nodriver as uc
async def main():
browser = await uc.start(headless=False)
try:
tab = await browser.get("https://www.scrapingcourse.com/ecommerce/")
# wait_for raises if the element never appears, but select would return None
await tab.wait_for(selector="li.product", timeout=20)
cards = await tab.select_all("li.product")
for card in cards:
title = await card.query_selector("h2.woocommerce-loop-product__title")
price = await card.query_selector("span.price")
# .text returns the FIRST text node only, and .text_all joins all of them
if title and price:
print(title.text_all, "|", price.text_all.strip())
finally:
# any exception above would otherwise leave Chrome running
browser.stop()
if __name__ == "__main__":
uc.loop().run_until_complete(main())
The script prints 16 products, one product per line:
Abominable Hoodie | $ 69.00
Adrienne Trek Jacket | $ 57.00
Aeon Capri | $ 48.00
Aero Daily Fitness Tee | $ 24.00
...
The space inside $ 69.00 isn't a typo, so the pagination script later adds a .replace(" ", "").
asyncio.run(main()) also printed the correct output. But on Python 3.12 it closed the loop before Chrome's subprocess transport finished. The shutdown raised RuntimeError: Event loop is closed. The README recommends uc.loop() instead.
Later snippets have no async def main() wrapper, and they belong inside this one. They use the same browser and tab. Some snippets call uc.start() again. Those calls show launch options, not extra lines. Move those options into your first uc.start(), because nothing closes the first browser.
Setting up nodriver in Python
Many guides still give 2 install instructions which are now wrong.
Installing nodriver and Chrome
You need Python 3.10 or newer and Chrome. The package metadata says 3.9 instead. On 0.50.3, line 28 of core/config.py reads PathLike = TypeVar("PathLike", bound=str | pathlib.Path). That union runs at import time, so import nodriver raises TypeError on 3.9. The gap is a packaging defect, because pip accepts the install and the import then fails. Install the library with pip:
pip install nodriver
You must install Chrome yourself, and nodriver expects it in the default location unless you set browser_executable_path. If you see the error no matching distribution found, your Python version is usually older than 3.9.
Don't follow the official quickstart's install line. At that commit, the line reads pip install undetected-chromedriver.
The second wrong instruction is the websockets pin. Many guides still tell you to pin websockets==13.1, but every release since 0.39 declares websockets>=14.
On a Linux server with no display, install Xvfb with apt install xvfb and run the browser in headed mode inside a virtual framebuffer. Headed mode did better than headless in our detection tests.
Verifying your install with a one-line scrape
This script shows that Chrome started, that the WebSocket connected, and that the DOM is readable. The scrape itself is the single evaluate line, and the rest is the async wrapper:
import nodriver as uc
async def main():
browser = await uc.start()
try:
tab = await browser.get("https://nowsecure.nl")
await tab.sleep(3)
print(await tab.evaluate("document.title"))
finally:
browser.stop()
uc.loop().run_until_complete(main())
The script prints the page title:
nowsecure.nl
If it prints the title and raises no exception, your install works.
Building your first scraper with nodriver
The ScrapingCourse sandbox is a WooCommerce storefront with stable class names and real pagination. When we ran it, every page except the last had 16 products. The selectors in this guide stay reproducible because the class names are stable. Many live retail sites aren't stable in the same way. Our guide to scraping WooCommerce product data describes the differences outside a sandbox.
Starting the browser and opening a page
You set the browser options at launch:
async def main():
browser = await uc.start(
headless=False,
browser_args=["--window-size=1920,1080"],
lang="en-US",
sandbox=True, # False disables Chrome's sandbox: throwaway containers only
# user_data_dir="/path/to/profile", # reuse a profile directory
# browser_executable_path="/path/to/chrome", # only if Chrome is not in the default location
)
tab = await browser.get("https://www.scrapingcourse.com/ecommerce/")
A Config object takes the same options. The option expert=True adds 1 flag, --disable-site-isolation-trials. Nodriver also contains _prepare_expert(), which would open shadow roots. However, nodriver never calls that method.
Finding elements with select, find, and xpath
You find the selectors in the inspector. Right-click a product card and choose Inspect. The <li> element then shows the classes you can use:

Every card shares the class product. A post ID and a stock state are different for each card, so li.product is a stable selector. Now look at the price node. The $ symbol is inside its own <span>, and 69.00 is a separate text node next to it.
The search methods differ in retry behavior. tab.select and tab.select_all retry until their timeout, but tab.query_selector and tab.query_selector_all try only once. No search method raises an exception when the selector matches nothing. The singular methods return None, the _all methods return an empty list, and a loop over an empty result is therefore safe. The docstrings say otherwise. select, select_all, find, and find_all each document a timeout exception, and none of them raises one.
tab.find(text, best_match=True) compares the length of each candidate with the length of your string, and it returns the closest candidate. It doesn't return the first match. When you search for a cookie button, the first match would be a script tag, so best_match gives the correct element:
cards = await tab.select_all("li.product") # 16 on page 1
button = await tab.find("Add to cart", best_match=True)
tab.xpath() is broken in 0.50.3. The method raises ProtocolException: 'DOM.enable' wasn't found [code: -32601], but the same call works on 0.48.1.
The cause is a positional argument which changed meaning in the 0.50.1 rewrite. xpath() calls self.send(cdp.dom.enable(), True), and the second parameter now means _attach. With _attach set to True, nodriver sends the command without a session ID. The command then goes to the browser target instead of the page.
find_all() doesn't use this parameter, and xpath() is a thin wrapper over find_all(), so find_all() returns the same elements when you call it directly:
# instead of await tab.xpath("//h2[contains(@class,'product__title')]")
titles = await tab.find_all("//h2[contains(@class,'product__title')]", timeout=10)
Our guide to practical XPath for web scraping explains how to write the expressions.
Extracting text, attributes, and images
During extraction nodriver can return the wrong value without raising an exception. element.text returns only the first text node. On a WooCommerce price of <span>$</span>69.00, price.text returns '$', so your output contains a column of currency symbols.
price.text_all returns '$ 69.00'. You then apply a strip() and a .replace(" ", "") to produce $69.00. When an element has only 1 text node, both methods return the same value, so text_all is the safer default.
element.attributes is a flat list which alternates name and value. element.attrs is the dict-like form, and you should use attrs:
for card in await tab.select_all("li.product"):
link = await card.query_selector("a.woocommerce-LoopProduct-link")
img = await card.query_selector("img")
url = link.attrs.get("href") if link else None
img_url = img.attrs.get("src") if img else None
css = card.attrs.get("class_") # note the underscore
You must check every result. query_selector returns None when the selector matches nothing, and None.attrs then ends the run with an AttributeError in the middle of a page.
The trailing underscore on class_ isn't a typo. class is a Python keyword, so nodriver renames the attribute.
Version 0.50.3 has no get_attribute() method. Element.__getattr__ returns None for any name which it can't resolve, and it doesn't raise an exception. That's why a typo like el.hreff returns None silently, and nodriver logs the failed lookup only at DEBUG level. The pull request which adds the method has been open since 5 September 2024.
Handling pagination on a sandbox ecommerce site
The next-page link has a stable class, so you can write a while loop which calls tab.select:
import json
products = []
while True:
await tab.wait_for(selector="li.product", timeout=20)
for card in await tab.select_all("li.product"):
title = await card.query_selector("h2.woocommerce-loop-product__title")
price = await card.query_selector("span.price")
link = await card.query_selector("a.woocommerce-LoopProduct-link")
img = await card.query_selector("img")
products.append({
"title": title.text_all if title else "",
"price": price.text_all.strip().replace(" ", "") if price else "",
"url": link.attrs.get("href") if link else None,
"image": img.attrs.get("src") if img else None,
})
next_link = await tab.select("a.next.page-numbers", timeout=5)
if not next_link:
break
href = next_link.attrs.get("href")
if not href:
break
await tab.get(href)
await tab # let the new document commit before querying it
The bare await tab isn't optional. If you remove it, the loop raises ProtocolException: Could not find node with given id [code: -32000] on a later page. This happens because select asks for a node while the new document is still committing. Both of our runs without the await failed in this way.
With the await in place, the loop ran to the last page and collected 188 records. Your own total will depend on the number of products in the catalog on the day you run it.
Don't use await tab.sleep(10) between pages. A fixed delay is too short on a slow load, and it wastes wall-clock time on a fast load. wait_for(selector=...) returns as soon as the element exists, and it raises asyncio.TimeoutError if the element never appears. Our guide to web scraping pagination covers load-more buttons and offset parameters.
Saving the scraped data to JSON
A single json.dump call writes the list to a file which you can diff between runs:
with open("products.json", "w", encoding="utf-8") as f:
json.dump(products, f, indent=2, ensure_ascii=False)
Keep ensure_ascii=False. Non-ASCII characters appear in 16 of the product titles, for example the ™ in Elisa EverCool™ Tee. If you escape them, that title becomes Elisa EverCool\u2122 Tee, and the file is harder to read. The 188 records look like this:

Going beyond the basics
For 2 of the methods below, the real behavior isn't what the name suggests.
Waiting for elements and page state
await tab.wait_for(selector=".product", timeout=10) waits until the element exists, and it raises asyncio.TimeoutError if the element never appears. Nodriver has no wait_for_ready_state() method, and select retries on its own until its timeout. Use wait_for when a missing element is an error, and use select when your code can handle None.
Working with iframes
By default, tab.find() and tab.find_all() search iframe content documents, though nodriver can't reach every frame. tab.get_frames() is new in 0.50.1, and it returns the frames which have their own CDP target. tab.get_frame_tree() returns the full CDP tree instead.
In that tree, the child_frames field of the root node has only its direct children. For this reason the 2 methods report different counts when frames are nested.
The CSS equivalent is select_all(selector, include_frames=True). On the page which we tested, this call raised TypeError: 'NoneType' object is not iterable, because an unreachable frame returns None. Use find_all for anything inside a third-party frame. It accepts a CSS selector, an XPath expression, or plain text, because it passes your string to the CDP search.
Running JavaScript with evaluate
await tab.evaluate(expression) returns primitives directly, but it returns objects in a deep-serialized form. return_by_value=True returns the plain value only for a truthy primitive. The name suggests it always returns a value. The call returns a RemoteObject wrapper for objects, arrays, and falsy values such as 0 and "".
A JavaScript scenario runs this kind of script in a hosted browser instead. For structured data, serialize it in the browser and parse it in Python:
raw = await tab.evaluate("""
JSON.stringify([...document.querySelectorAll('li.product')].map(e => ({
title: e.querySelector('h2').textContent,
price: e.querySelector('.price').textContent.trim(),
})))
""")
rows = json.loads(raw)
An expression which throws an error returns an ExceptionDetails object, and it doesn't raise the error in Python. An async expression returns None unless you pass await_promise=True.
Taking screenshots and managing windows
await tab.save_screenshot("page.png") writes the viewport. With full_page=True the method writes the whole scroll height. Element has its own save_screenshot for a single node. That method takes scale and has no full_page.
The format parameter decides the encoding, and the filename doesn't. The default value is jpeg, so a .png filename produces JPEG bytes, and you must pass format="png" to produce a PNG file:
await tab.save_screenshot("page.png", format="png", full_page=True)
card = await tab.select("li.product")
await card.save_screenshot("card.png", format="png")
await tab.set_window_state(state="maximized")
await tab.scroll_down(amount=25)
The second call in that block wrote this image, and it contains only the selected card:

If the page loads content while you scroll, scroll first and capture afterwards. full_page records only the part of the page which has already rendered. The same capability is available as a hosted call in ScrapingBee's screenshot API.
Saving and loading cookies
browser.cookies saves and restores a session, so a login can stay valid between runs. A user_data_dir also keeps a session, but that option has a problem. stop() closes the browser on a task which nobody awaits, so a script which exits immediately doesn't flush the cookie jar.
An await tab.sleep(2) after stop() was enough in our tests. A sleep is the wrong tool elsewhere, and stop() returns nothing you can await. The last line needs the requests package, which isn't a dependency of nodriver:
await browser.cookies.save("session.dat") # ~3 KB pickle: keep it out of git
await browser.cookies.load("session.dat")
jar = await browser.cookies.get_all(requests_cookie_format=True)
That file is a pickled cookie jar, and it contains working session tokens. Keep the file out of version control. You should also restore only the jars which your own runs wrote, because load() passes the path directly to pickle.load. A file from somebody else then runs their code inside your process. Give the file mode 600, and a secret store is better than a file which sits beside your code.
A session in requests is acceptable for an unprotected endpoint. Against a protected endpoint the request carries a Python TLS fingerprint, and not a browser fingerprint.
Setting a custom user-agent
You can set the user agent in 2 places:
# match the Chrome version to the build you are running:
# claiming Chrome 153 while you run a newer build is a mismatch a page can test
ua = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
# option 1: at launch, browser-wide
browser = await uc.start(browser_args=[f"--user-agent={ua}"])
# option 2: per tab, send this before you navigate
from nodriver import cdp
tab = browser.main_tab
await tab.send(cdp.network.set_user_agent_override(
user_agent=ua,
# add user_agent_metadata here once you have tested it
))
tab = await browser.get("https://www.scrapingcourse.com/ecommerce/")
The per-tab override is important in headless mode. Send it before browser.get(). If you apply the override afterwards, the first document request has already carried the old string. One identity which matches your Chrome build is safer than a rotation. The same call takes user_agent_metadata for navigator.userAgentData and platform for navigator.platform. A page can compare both against your string. We didn't test those 2 fields. Our user agent list gives strings which you can match to a build.
Routing traffic through a proxy
A proxy without authentication needs one browser argument:
browser = await uc.start(browser_args=["--proxy-server=http://host:port"])
With an authenticated proxy, a scraper can leak its real address. Chrome 153 stripped the credentials from --proxy-server, and no error appeared. No configuration we tested delivered them to the proxy.
--proxy-server value | Result |
|---|---|
| not set | our own IP |
http://host:8886 | proxy refused the unauthenticated request |
http://user:pass@host:8886 | credentials stripped, request proxied, proxy refused it |
http://KEY:render_js=false@host:8886 | our own IP, no error |
Chrome discarded the flag and connected directly when the credentials contained a character which breaks URL parsing. The = character in the last password is an example. Your scraper then reports success, but your real address reaches the target. Check your exit IP before you trust the run.
Nodriver already solves this problem, but the solution is documented only in a docstring. The quickstart's proxy example raises SyntaxError if you copy it. Pass the credentials to create_context, and don't pass them to browser_args. The example below carries URL-safe credentials only, and it checks them before it creates the proxy context or sends any traffic:
import os # export PROXY_USER and PROXY_PASS before running
# this example carries URL-safe credentials only
for name in ("PROXY_USER", "PROXY_PASS"):
if set("/#?") & set(os.environ[name]):
raise SystemExit(f"{name} has a character which urlparse cannot read here")
tab = await browser.create_context(
"https://httpbin.org/ip",
proxy_server=f"http://{os.environ['PROXY_USER']}:{os.environ['PROXY_PASS']}@host:8886",
)
That call routes the traffic through a local forwarder. The forwarder injects Proxy-Authorization upstream, so Chrome never sees a credential which it would refuse. A password which contains /, #, or ? raises ValueError here, because nodriver parses this URL with urlparse. Percent-encoding doesn't help, because urlparse returns the encoded text and the forwarder sends it unchanged. The forwarder also has a socks:// branch, and the docstring gives the format, but we didn't test socks.
A proxy which terminates TLS also needs its certificate accepted. Installing the proxy's CA certificate in your scraping image is the safer option. create_context also takes proxy_ssl_context, which the forwarder uses for the HTTPS proxy connection, and we didn't test that parameter. Adding --ignore-certificate-errors to your uc.start() call is a last resort, and it suits a diagnosis more than a run. This flag disables certificate validation for the whole browser, so use it only in a scraping profile. With the flag in place, httpbin.org/ip returned the provider's exit address and not our own address.
Version 0.50.3 has 2 problems. Nodriver prints the full proxy URL at INFO level, and that line contains the password, so keep the scraper's logger at WARNING. Each authenticated context also starts a forwarder which nothing closes. Reuse a single context, and don't create a new context for every request.
ScrapingBee's proxy mode uses the same proxy_server setting. Your API key becomes the username, and the API parameters become the password. Our guide to proxies with Python requests explains setup, authentication, and rotation. Our comparison of rotating proxy services covers the buying decision.
Running several scrapers at once
A separate browser for each URL is the obvious approach, but it also uses the most memory. We loaded the same storefront page in headless mode on an 8-core M3 with 16 GB of RAM. The numbers show the extra physical RAM which each run used:
| Concurrent browsers (macOS, 8 cores, 1 page type) | Extra RAM | Wall time for the batch |
|---|---|---|
| 1 | 597 MB | 4.1 s |
| 2 | 916 MB | 4.1 s |
| 4 | 1,522 MB | 5.3 s |
| 8 | 2,000 MB | 8.1 s, with 2 of 32 loads failing across 4 runs |
A run with 8 browsers processed 8 pages in twice the wall time of 1 browser on 1 page. Throughput therefore increased about 4 times, and memory increased about 3 times.
The same 8 jobs also ran as tabs inside a single browser. This configuration used between 500 MB and 900 MB at every concurrency level which we tried. The parameter new_tab=True opens these tabs:
import asyncio
urls = [f"https://www.scrapingcourse.com/ecommerce/page/{n}/" for n in range(1, 9)]
async def scrape(browser, url):
tab = await browser.get(url, new_tab=True)
try:
await tab.wait_for(selector="li.product", timeout=30)
return len(await tab.select_all("li.product"))
finally:
# each new_tab stays open until you close it
await tab.close()
# return_exceptions keeps 1 failed page from discarding the other 7 results
results = await asyncio.gather(*[scrape(browser, u) for u in urls], return_exceptions=True)
for url, r in zip(urls, results):
if isinstance(r, Exception):
print(f"failed {url}: {type(r).__name__}: {r}")
counts = [r for r in results if not isinstance(r, Exception)]
Headed mode uses more memory at the same concurrency. A run with 4 headed browsers used 2,046 MB, and the same run in headless mode used 1,522 MB. A higher concurrency also causes the same navigation race, so you should wrap each page in a retry.
These figures come from macOS and from 1 page type. The general pattern should transfer to other systems, but the absolute numbers belong to our test machine.
Running nodriver in headless mode (and when not to)
headless=True is the toggle, and the old headless="new" string is gone. The documentation doesn't mention one behavior. Headless nodriver identifies itself. Inside a headless tab, navigator.userAgent reads HeadlessChrome/153.0.0.0.
Nodriver contains a _prepare_headless() method which rewrites exactly this string. However, nodriver never calls this method. The call site disappeared in 0.46.1.
The bot.sannysoft.com page shows 58 rows and grades 31 of them. We ran it on 1 machine, within 1 minute:
| Configuration | Checks failed | screen |
|---|---|---|
| headed | 0 | 1470x956 |
| headless | 3 (User Agent (Old), HEADCHR_UA, CHR_MEMORY) | 800x600 |
| headless + the user-agent repair | 0 here, still blocked by DataDome | 1920x1080 |
The first 2 runs appear side by side:

HEADCHR_UA reads that same string, and CHR_MEMORY returns an empty object:

Treat 3 failures out of 31 as a minimum, and not as a typical result. These runs were on macOS, and headless Chrome keeps hardware rendering on that system. A container without a GPU reports a software renderer instead.
Guides for the old headless mode still recommend --disable-gpu, and that flag removes WebGL completely. Our guide to bypassing CreepJS covers the harder test.
The third row of the table uses the method below. We wrote the method by hand, and we also set a screen size which differs from the headless default:
from nodriver import cdp
async def restore_headless_identity(tab):
ua = str(await tab.evaluate("navigator.userAgent"))
await tab.send(cdp.network.set_user_agent_override(
user_agent=ua.replace("HeadlessChrome", "Chrome")))
await tab.send(cdp.emulation.set_device_metrics_override(
width=1920, height=1080, device_scale_factor=1,
mobile=False, screen_width=1920, screen_height=1080))
Call restore_headless_identity(browser.main_tab) before your first browser.get(). We ran the same page again:

The repair passed sannysoft in 2 consecutive runs. However, screen.colorDepth still reported 24 in headless mode and 30 in headed mode, and the DataDome-protected page was still blocked.
On a Linux server, run xvfb-run python your_scraper.py with headless=False. This command removes the headless signals which we measured, and it's a better default for stealth-sensitive work.
Bypassing Cloudflare with nodriver in 2026
Cloudflare offers several products, and our tests reached only the simpler ones. Nodriver loaded nowsecure.nl without any help, and that page shows a "Just a moment" interstitial. The sandbox in these examples is also behind Cloudflare.
We didn't measure Cloudflare Bot Management. The hardened target which we did measure runs DataDome.
Using verify_cf for Turnstile checkbox challenges
The name is the first problem. Nodriver's README documents tab.cf_verify(), but neither 0.50.3 nor 0.48.1 contains this method. The API reference gives the correct name, tab.verify_cf(). Snippets which you copy from the README therefore raise AttributeError.
The helper needs OpenCV. Without OpenCV the call fails with TypeError: cannot unpack non-iterable NoneType object:
pip install opencv-python
The call is await tab.verify_cf(flash=True). The method takes a screenshot of the viewport, finds the checkbox by template matching, and clicks the best match.
Don't call this method on every page, because it has no confidence threshold. The template match returns coordinates even when no checkbox exists, and these coordinates move with the window. Example.com gave (234, 102) at a width of 1100 pixels, and it gave (294, 102) at a width of 1400 pixels. That page contains no Turnstile widget:

The match is therefore only noise, and the helper clicks any element at that position.
Call the method only when a Turnstile is present on the page. You should also read the token, because the return value alone isn't reliable. On a public demo, a headed run left cf-turnstile-response empty and raised no exception:
import nodriver as uc
async def main():
browser = await uc.start(headless=False)
try:
tab = await browser.get("https://a-page-you-know-carries-a-turnstile.example")
await tab.verify_cf()
await tab.sleep(3)
token = await tab.evaluate(
"(document.querySelector('[name=cf-turnstile-response]') || {}).value || ''")
if not token:
... # the click produced no token, so handle the failure here
finally:
browser.stop()
uc.loop().run_until_complete(main())
We cover the mechanics in how to bypass Cloudflare and the ban page in error 1005 access denied.
When nodriver isn't enough: Patchright, Camoufox, and Rebrowser
We tested 1 hardened target, a G2 review page which runs DataDome. Nodriver, Zendriver, and Patchright each loaded this page in headed mode. DataDome blocked each library in headless mode. All of these tests ran on 1 machine, in 1 session.
Zendriver sends no HeadlessChrome string at all, and the page still refused it. The user agent doesn't separate a blocked run from a working run.
Headed mode, headless mode, and headless mode with the repair all returned an identical TLS and HTTP/2 fingerprint. Whatever DataDome uses to separate them, we didn't find it in those 2 signals.
Patchright patches Playwright, and its README offers it as a drop-in replacement. The README also asks you to run the browser in headed mode, which is consistent with our result.
Patchright failed 11 of the 31 graded sannysoft checks with the plain Playwright defaults, and headless nodriver failed 3. Patchright's own documented configuration failed 0 in headed mode. The same configuration in headless mode failed 3.
Camoufox uses a different method, because it is a patched Firefox build. Its documentation says the data is intercepted at the C++ level, so JavaScript inspection can't see the changes. Our Camoufox tutorial covers the setup.
Rebrowser-patches patches the Runtime.Enable leak in Puppeteer and Playwright, and it doesn't replace these libraries. We measured neither Camoufox nor Rebrowser.
Every library which we tested needed a display against that target. The display can be real or virtual, and you must give one to the scraping container.
Nodriver versus Playwright, Selenium, and modern stealth peers
These 8 tools have similar features, but they differ most on 3 points. They are the browsers which each tool drives, the obligations in the license, and whether anyone still fixes the code.
| Tool | Architecture | Browsers | API | License | Latest release (checked Sept 2026) |
|---|---|---|---|---|---|
| nodriver 0.50.3 | direct CDP, no driver | Chromium only | async only | AGPL-3.0 | May 2026 |
| zendriver 0.16.0 | fork of nodriver | Chromium only | async only | AGPL-3.0 | Aug 2026 |
| Playwright 1.63.0 | own driver process | Chromium, Firefox, WebKit | sync and async | Apache-2.0 | Sept 2026 |
| Patchright 1.62.3 | patched Playwright | Chromium, Chrome channel | sync and async | Apache-2.0 | Sept 2026 |
| Camoufox 0.5.6 | recompiled Firefox | Firefox only | sync and async | MIT, browser MPL-2.0 | Sept 2026 |
| SeleniumBase 4.54, CDP Mode | Selenium framework, CDP Mode from nodriver | Chromium only | sync and async | MIT | Sept 2026 |
| undetected-chromedriver 3.5.5 | Selenium + patched driver | Chromium only | sync | GPL-3.0 | Feb 2024 |
| ScrapingBee, hosted API (this blog's publisher) | hosted browser, billed per request | managed headless browser | HTTP, any language | commercial service | continuously deployed |
Nodriver reaches a loaded page a few lines after pip install. The API is small, and the library adds no framework of its own. This design suits a Chromium-only Python team which is comfortable with async.
If you come from Playwright, these are the equivalent calls:
| Playwright | nodriver |
|---|---|
page.goto(url) | await browser.get(url) |
page.query_selector(css) | await tab.query_selector(css) |
page.wait_for_selector(css) | await tab.wait_for(selector=css) |
page.evaluate(js) | await tab.evaluate(js) |
page.screenshot(path=...) | await tab.save_screenshot(path) |
| a second page | await browser.get(url, new_tab=True) |
Playwright now recommends page.locator() and its web-first waiting over the 2 selector calls above. The table keeps the older calls, because they match nodriver one to one.
If you already run Playwright, Patchright is built as a drop-in replacement, so a full migration shouldn't be necessary. If Chrome-specific fingerprinting is what blocks you, Camoufox changes the browser engine completely.
Standard Playwright and standard Selenium contain no stealth patches. Our guide to faster Playwright scraping covers performance, and our complete guide to Selenium in Python is a general introduction.
Should you use the fork instead? For new Chromium-only code, consider starting with Zendriver. Zendriver 0.16.0 keeps almost all of nodriver's API and uses the same AGPL-3.0 license. The fork also repairs the defects which we measured.
In Zendriver, the headless user agent has no HeadlessChrome token, tab.xpath() returns its elements, and select_all(include_frames=True) returns a result and raises no exception. Zendriver runs a pytest suite in CI on pushes to main, and nodriver runs none. This maintenance is what the fork adds. Zendriver didn't improve the result against our 1 hardened target.
Xvfb workers or a managed API
Every local library in that table runs on hardware which you provide, and ScrapingBee is the hosted exception. If you run these libraries in headed mode for stealth, you also need a display or an Xvfb wrapper. You pay for this display in every container. A hosted API removes this work.
ScrapingBee fetches the page for you. The service adds a headless browser and proxies when the target requires them, and it returns HTML by default. Markdown, JSON, and other formats are also available. Your own code doesn't ship the library.
Auto-Mode tries a series of configurations, and it starts with the cheapest. The credit system explainer sets out the credit costs. The documented billing rule charges for the one which succeeds, and it bills nothing when they all fail. A completed call reports its cost in an Spb-auto-cost header.
ScrapingBee's plans list the current credit allowance and the concurrency, and you can start on a free account. We made the same call against both targets:
KEY="your-api-key"
URL="https://www.scrapingcourse.com/ecommerce/"
curl -G "https://app.scrapingbee.com/api/v1/" \
-H "Authorization: Bearer $KEY" \
--data-urlencode "url=$URL" \
--data-urlencode "mode=auto"
The same call from Python:
# pip install scrapingbee
from scrapingbee import ScrapingBeeClient
client = ScrapingBeeClient(api_key="YOUR-KEY")
r = client.html_api("https://www.scrapingcourse.com/ecommerce/", params={"mode": "auto"})
r.raise_for_status()
# the header is absent when the request did not run in auto mode
print(r.headers.get("Spb-auto-cost", "n/a"), len(r.content))
html_api() is the current call, and client.get() still works on SDK 2.2.0. Use html_api() in new code.
--data-urlencode passes the query string of your own target without changes. If you leave the string unencoded, an & becomes a second ScrapingBee parameter, and the call returns 400.
The sandbox storefront returned Spb-auto-cost: 1, which is the cheapest configuration. G2 returned Spb-auto-cost: 75, which is the stealth configuration. That response contained 4,400 words of the page, and headless nodriver could not load this page at all. A failed request billed 0 credits on those runs.
Latency applies to each request, and not to a queue. Measure the benefit of extra concurrency before you buy it.
On the 1-credit target a single call took 2.2 seconds, and 20 concurrent calls finished in 5.4 seconds. Throughput increased from 0.45 requests per second to 3.7 requests per second. The 75-credit target scales more slowly. A single call took 45 seconds, and 3 concurrent calls took 73 seconds.
Concurrency is part of your plan, so on hard pages the throughput depends on how much you buy. Our guide to concurrent requests in Python shows the pattern. Set max_cost in advance to limit the cost. Auto-Mode then tries only the configurations at or below this limit.
This is a small sample from 2 targets, and it isn't a full benchmark. The figures also cover only the managed service.
Common errors and how to fix them
Failed to connect to browser. Chrome didn't start, or the debugging port never answered. The error message is misleading, because it names no_sandbox=True. Version 0.50.3 accepts that parameter without an error, and the parameter adds nothing. sandbox=False is what adds --no-sandbox.
Nodriver logs the real exception only at DEBUG level, so raise the level and read it first. When that exception names the sandbox, uc.start(sandbox=False) is the call which works on Linux. This call disables Chrome's own process sandbox, so you should use it only in a disposable container and with a non-root user. Set the level back afterwards, because debug output also contains proxy credentials and cookie values.
AttributeError: partially initialized module 'nodriver'. Your file is named nodriver.py, so Python imports it and not the library. Rename it.
AttributeError: 'Tab' object has no attribute 'cf_verify'. The correct method is verify_cf(), and your install is fine.
RuntimeError: Event loop is closed at shutdown. You used asyncio.run(main()). The scraping itself completed, and this traceback comes from the shutdown. Use uc.loop().run_until_complete(main()).
ProtocolException: Could not find node with given id. You queried the DOM while a navigation was still running. Put await tab after the navigation and before the next select. A rarer race survived this fix in 1 of about 16 runs, so you should also wrap the page in a retry loop. Our retry recipe for Python shows the pattern.
DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated. This comes from nodriver, and not from your code. Python 3.14 prints it, and Python 3.16 removes the function.
A websockets version error. Upgrade the package, and don't pin the version. Nodriver has required websockets>=14 since 0.39, so version 13.1 is below the declared minimum.
Frequently asked questions
Is nodriver detectable by Cloudflare in 2026?
Nodriver passed a "Just a moment" interstitial on nowsecure.nl without any help. It also contains verify_cf() for Turnstile checkboxes, but that call gave us no confirmation. In headless mode nodriver didn't reach a DataDome-protected page. For hardened targets, run headed under Xvfb. Patchright reached our hardened target in headed mode, and we didn't measure Camoufox. You can also send the request to ScrapingBee.
Is it legal to scrape a Cloudflare-protected site?
Using a stealth browser doesn't answer the question. Legality depends on your jurisdiction, the site's terms, whether you had authorization, the type of data, and your request rate. Personal data, copyright, and database rights each carry their own duties, and so does the data you keep after the run. Bot protection can also be a signal about what the operator permits. Read robots.txt, but treat it as a crawling convention and not as legal permission. Take legal advice for commercial work.
Nodriver versus Playwright: which should I use?
Use nodriver when you're Python-only, Chromium-only, and comfortable with async. For new code, consider starting with Zendriver, which repairs the defects we measured. Use Playwright when you need Firefox or WebKit, a sync API, or codegen. If you already use Playwright and the target blocks you, try Patchright, which is built as a drop-in replacement.
How do I make nodriver wait for a page to load?
Use await tab.wait_for(selector=".target", timeout=10). This call raises asyncio.TimeoutError if the element never appears. await tab.wait() and bare await tab pause for half a second and refresh the target list. Nodriver has no wait_for_ready_state() method. Avoid a hardcoded tab.sleep(N), because tab.select() already retries until its own timeout.
How does nodriver handle iframes?
Nodriver searches iframes automatically, though it can't reach every frame. tab.find() and tab.find_all() search inside iframe content documents. tab.get_frames() returns frames which have their own CDP target, and tab.get_frame_tree() returns the full tree. The 2 counts differ when frames are nested. On the page we tested, select_all(include_frames=True) raised TypeError, so use find_all inside third-party frames.
Why am I getting "Failed to connect to browser" with nodriver?
Chrome didn't start, or its debugging port never answered. Read the real exception first. The cause can be a missing Chrome, a missing system library, no display, a locked profile, or a busy port. When the exception names the sandbox, sandbox=False is the fix on Linux. The flag removes Chrome's process isolation, so restrict it to a throwaway container that runs as a non-root user. Nodriver silently ignores no_sandbox=True, which its own error message names.
How do I install and update nodriver with pip?
Run pip install nodriver on Python 3.10 or newer. Version 0.50.3 was released on 13 May 2026, and it was current in September 2026. Upgrade with pip install --upgrade nodriver. A "no matching distribution" error usually means that your Python version is too old.
Does nodriver work in headless mode?
Yes, pass headless=True. One problem is that navigator.userAgent still reports HeadlessChrome. That mode failed 3 of the 31 graded checks on bot.sannysoft.com in our tests, and headed mode failed none. An override passed sannysoft, but it didn't reach a DataDome-protected page. For hardened targets, run headed under Xvfb.

