Nodriver Python Tutorial: Stealth Web Scraping in 2026

30 September 2026 (updated) | 31 min read

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

Nodriver Python tutorial: stealth web scraping in 2026

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, and tab.evaluate. Nodriver provides verify_cf() for Turnstile checkboxes. However, on a live widget the cf-turnstile-response value stayed empty.
  • In headless mode, navigator.userAgent still reports HeadlessChrome, 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:

Chrome DevTools inspecting a storefront product card. The selected li carries 11 classes starting product type-product post-246, and its children show the h2 title class and a price span with $ separate from 69.00.

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:

A pretty-printed JSON array open in Chrome, showing scraped product objects. Each has a title such as Abominable Hoodie, a price such as $69.00, a product URL on scrapingcourse.com and an image URL.

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:

A single WooCommerce product card saved with Element.save_screenshot: a photograph of a hooded sweatshirt above the title Abominable Hoodie, the price $69.00 and a Select options button.

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 valueResult
not setour own IP
http://host:8886proxy refused the unauthenticated request
http://user:pass@host:8886credentials stripped, request proxied, proxy refused it
http://KEY:render_js=false@host:8886our 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 RAMWall time for the batch
1597 MB4.1 s
2916 MB4.1 s
41,522 MB5.3 s
82,000 MB8.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:

ConfigurationChecks failedscreen
headed01470x956
headless3 (User Agent (Old), HEADCHR_UA, CHR_MEMORY)800x600
headless + the user-agent repair0 here, still blocked by DataDome1920x1080

The first 2 runs appear side by side:

The bot.sannysoft.com table captured twice on one machine. Under headless=False every row is green. Under headless=True the User Agent (Old) row is red and reads HeadlessChrome/153.0.0.0. That row is the first of the 3 failures, and the other rows in view stay green.

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

Four rows from bot.sannysoft.com. HEADCHR_UA passes headed and shows FAIL headless, with the userAgent value visible in each. CHR_MEMORY passes headed and shows FAIL headless with an empty result 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 bot.sannysoft.com results table under headless=True with the user-agent repair applied. Every row is green and User Agent (Old) reads Chrome/153.0.0.0 with no HeadlessChrome token.

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 example.com page with a red crosshair marking the coordinates template_location returned. The page shows only a heading, 2 lines of text and a link, with no checkbox or challenge widget present.

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.

ToolArchitectureBrowsersAPILicenseLatest release (checked Sept 2026)
nodriver 0.50.3direct CDP, no driverChromium onlyasync onlyAGPL-3.0May 2026
zendriver 0.16.0fork of nodriverChromium onlyasync onlyAGPL-3.0Aug 2026
Playwright 1.63.0own driver processChromium, Firefox, WebKitsync and asyncApache-2.0Sept 2026
Patchright 1.62.3patched PlaywrightChromium, Chrome channelsync and asyncApache-2.0Sept 2026
Camoufox 0.5.6recompiled FirefoxFirefox onlysync and asyncMIT, browser MPL-2.0Sept 2026
SeleniumBase 4.54, CDP ModeSelenium framework, CDP Mode from nodriverChromium onlysync and asyncMITSept 2026
undetected-chromedriver 3.5.5Selenium + patched driverChromium onlysyncGPL-3.0Feb 2024
ScrapingBee, hosted API (this blog's publisher)hosted browser, billed per requestmanaged headless browserHTTP, any languagecommercial servicecontinuously 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:

Playwrightnodriver
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 pageawait 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.

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.

image description
Satyam Tripathi

Satyam works in developer marketing for companies building web data and AI infrastructure products. He creates technical and product content that helps developers understand and use complex technologies.

Auto-mode picks the configuration that successfully scrapes your page

Try it now