     [Blog](https://scrapfly.io/blog)   /  [blocking](https://scrapfly.io/blog/tag/blocking)   /  [Nodriver Tutorial: Undetected Chrome Automation in Python](https://scrapfly.io/blog/posts/nodriver-tutorial)   # Nodriver Tutorial: Undetected Chrome Automation in Python

 by [Hisham Medhat](https://scrapfly.io/blog/author/hisham) Oct 27, 2026 37 min read [\#blocking](https://scrapfly.io/blog/tag/blocking) [\#headless-browser](https://scrapfly.io/blog/tag/headless-browser) [\#python](https://scrapfly.io/blog/tag/python) 

 [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial "Share on LinkedIn") [  ](https://x.com/intent/tweet?url=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial&text=Nodriver%20Tutorial%3A%20Undetected%20Chrome%20Automation%20in%20Python "Share on X") [  ](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial "Share on Facebook")    

 

 

Summarize this article with

 [  ](https://chat.openai.com/?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial) [  ](https://claude.ai/new?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial) [  ](https://x.com/i/grok?text=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial) [  ](https://www.perplexity.ai/search/new?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial) [  ](https://www.google.com/search?udm=50&aep=11&q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fnodriver-tutorial) 



Our very first Nodriver script printed `'webdriver': False`, which is a great start. Though, if we look at the rest of that output, the user agent ends in `HeadlessChrome/156.0.0.0`, and that alone gives our scraper away just as fast!

So, we'll take a look at how Nodriver compares to Selenium and then build a scraper with Nodriver 0.50.6 and Chrome 156. Then, we'll extend it with logins, background requests, proxies and servers, and wrap it all up with the errors we ran into along the way.



## Key Takeaways

- Nodriver controls Chrome over the Chrome DevTools Protocol with no ChromeDriver or Selenium, and `navigator.webdriver` read `False` in both headless and headed runs.
- Install `nodriver==0.50.6` on Python 3.10 or newer, since the import fails on Python 3.9, and always set `browser_executable_path`.
- `browser.get()` returns while the page is still loading, so wait for a selector with `tab.wait_for()` before reading anything.
- Chrome's `--proxy-server` flag rejects credentials with `ERR_NO_SUPPORTED_PROXIES`, while `browser.create_context(url, proxy_server=...)` accepts `http://user:pass@host:port`.
- Ported to Playwright, the same page logic connected to [Scrapfly Cloud Browser](https://scrapfly.io/products/cloud-browser-api) in 2.11 seconds and returned the same five products.

**Get web scraping tips in your inbox**Trusted by 100K+ developers and 30K+ enterprises. Unsubscribe anytime.







## What Is Nodriver and How Is It Different From Selenium?

Nodriver is a browser automation library for Python that controls Chrome through the Chrome DevTools Protocol (CDP). Its [Nodriver README](https://github.com/ultrafunkamsterdam/nodriver) calls it "the official successor of the Undetected-Chromedriver python package", and both repositories are under the same `ultrafunkamsterdam` GitHub account.

So, how is it different from Selenium? Well, it all comes down to how the commands get to Chrome:

text

```text
Selenium: script -> WebDriver -> ChromeDriver -> Chrome
Nodriver: script -> CDP over WebSocket -> Chrome
```



As we can see, the Nodriver line skips ChromeDriver, which is the very binary undetected-chromedriver used to patch. It's a great fit for web scraping too, as the README names its main focus as "the optimization to stay undetected for most anti-bot solutions".

Since it comes from the same author, we'll import it as `uc` too, just like [undetected-chromedriver](https://scrapfly.io/blog/posts/web-scraping-without-blocking-using-undetected-chromedriver) did. Unlike its predecessor, Nodriver is "fully asynchronous", so almost every call in our scripts needs an `await`. Note that only Chromium-based browsers are supported, and the README lists Chrome, Chromium, Edge and Brave as known to work. As for the licence, its PyPI metadata says AGPL-3.0.

Nodriver has also had a few releases in 2026. According to the README, 0.50.1 from May 11, 2026 is the one that switched it to flat-mode CDP connections. The version pinned here, 0.50.6, came out on October 8. PyPI lists four newer 0.50 releases from October 26, but the examples below remain tested on 0.50.6. We were curious if any of these changed the parts we use, so we diffed 0.50.3 against 0.50.6. The `core/` package with `Browser`, `Tab` and `Element` came out identical, and all of the changes were in 22 files of generated CDP bindings.



## How Do You Install Nodriver in Python?

Nodriver doesn't come with its own browser, it just launches whichever Chrome it finds on the machine. So, before installing Nodriver, we've installed Google Chrome separately. Then, we can install Nodriver in a fresh virtual environment (it needs Python 3.10 or newer) and check our Chrome version. We'll pin `nodriver==0.50.6` here, as that's the version all of our examples ran on:

shell

```shell
python3 -m venv .venv
source .venv/bin/activate
pip install "nodriver==0.50.6"
google-chrome --version
```



Here's the Chrome version we got:

text

```text
Google Chrome 156.0.8078.47
```



Note that along with Nodriver, pip also installed `websockets` 17.3, `mss` and `deprecated` as its dependencies.

We might notice that PyPI lists Nodriver as supporting Python `>=3.9`. That's not quite true! On Python 3.9 pip installed it without an error, but our Python 3.9.25 run then crashed on import inside Nodriver's own config module:

text

```text
    PathLike = TypeVar("PathLike", bound=str | pathlib.Path)
TypeError: unsupported operand type(s) for |: 'type' and 'type'
```



This is because the `str | pathlib.Path` syntax only exists since Python 3.10, so Python 3.9 can't evaluate it.

Another thing to watch out for is which browser Nodriver picks. On Linux, it checks every PATH directory for `google-chrome`, `chromium`, `chromium-browser`, `chrome` and `google-chrome-stable` and picks the match with the shortest path. As its source comment puts it: "assuming the shortest path wins".

So, if we have both `/usr/bin/chromium` and `/usr/bin/google-chrome` installed, Chromium is the one that launches! That's why every example below sets `browser_executable_path` to the exact browser we want.

A fresh install can also trip on a script saved as `nodriver.py`, which shadows the package, as we'll see in the errors section. We might also come across old advice to pin `websockets==13.1`, but that no longer applies since 0.50.6 requires `websockets>=14` and our install pulled in 17.3.

The package itself is on [Nodriver on PyPI](https://pypi.org/project/nodriver/), and the full API reference can be found in [the Nodriver documentation](https://ultrafunkamsterdam.github.io/nodriver/).



## How Do You Scrape a Page With Nodriver?

Nodriver's API is pretty small, and most of our scrapers will follow the same pattern. We launch Chrome with `await uc.start()`, open a page with `await browser.get(url)` and then wait for and select elements on the `Tab` object it returns. Note that `browser.get()` returns before the page has finished loading, so we always wait for the content first.

### Write Your First Nodriver Scraper With uc.start() and browser.get()

Let's start with a simple scraper that collects the product names from the web-scraping.dev products page. While we're there, we'll also check what the page can see of our browser:

python

```python
import json
import nodriver as uc

CHROME = "/usr/bin/google-chrome"


async def main():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        tab = await browser.get("https://web-scraping.dev/products")
        await tab.wait_for(".product h3 a", timeout=15)
        links = await tab.select_all(".product h3 a")
        print([a.text for a in links])
        print(links[0].attrs["href"])
        fingerprint = await tab.evaluate(
            "JSON.stringify({ua: navigator.userAgent, webdriver: navigator.webdriver})"
        )
        print(json.loads(fingerprint))
    finally:
        browser.stop()


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



In the example above, we wait for the product links to render, read their names and print the `href` of the first one. Then, we use `evaluate()` to ask the page for its user agent and `navigator.webdriver` value. Our run finished in about 3.3 seconds and printed:

text

```text
['Box of Chocolate Candy', 'Dark Red Energy Potion', 'Teal Energy Potion', 'Red Energy Potion', 'Blue Energy Potion']
https://web-scraping.dev/product/1
{'ua': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36', 'webdriver': False}
```



There are a few things in this code that might look a bit unusual, so let's take a quick look at them:

- `try/finally` - if our scraper crashes, the `finally` block still calls `browser.stop()` so Chrome doesn't keep running in the background. Note that there's no `await` in front of `stop()`, as in 0.50.6 it's a plain method and awaiting it raises a `TypeError`.
- `uc.loop()` - we use this helper because the Nodriver README does too, where the example comes with the comment "since asyncio.run never worked (for me)". We'll give `asyncio.run()` a try in the errors section later on.
- `json.loads` - strings and numbers from `evaluate()` come back as Python values, but objects come back as CDP pairs like `[['webdriver', {'type': 'boolean', 'value': False}]]`. That's not very pleasant to work with, so we let the page call `JSON.stringify` on the object and parse the string back with `json.loads`.

### Select Elements and Wait for Content With select, find and wait\_for

Our first scraper already used two of the tab's lookup methods: `wait_for()` to hold the script until the product links showed up and `select_all()` to collect them. There's also `select()` for grabbing a single element and `find(text, best_match=True)`, which looks elements up by their text instead of a selector. We'll use the latter to find the "Load More" button below just by its label.

We might be wondering why we need `wait_for()` at all. Right after `browser.get()` returned, our run read `document.readyState` as `loading` and `document.title` as an empty string, so the tab arrives well before the page does!

Let's take a look at what each method does when the element isn't there. We pointed all of them at a selector that doesn't exist on `web-scraping.dev/product/1`, with a 2-second timeout on 0.50.6:

| Method | On a missing element | Use it for |
|---|---|---|
| `await tab.wait_for(css)` | Raises `TimeoutError` (2.1 s) | Required content |
| `await tab.select(css)` | Returns `None` (2.5 s) | Optional content |
| `await tab.select_all(css)` | Returns `[]` (2.5 s) | Lists and cards |
| `await tab.find(text, best_match=True)` | Returns `None` (2.1 s) | Buttons, labels |
| `await tab.xpath(expr)` | Raises `ProtocolException` | Avoid on 0.50.6 |

Note that the `xpath` row never got to the timeout. Against Chrome 156, it failed right away with `ProtocolException 'DOM.enable' wasn't found [code: -32601]`, which is why our scrapers stick to CSS selectors.

Now, let's use `select()` and `find()` on elements that do exist on the same product page:

python

```python
import nodriver as uc

CHROME = "/usr/bin/google-chrome"


async def main():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        tab = await browser.get("https://web-scraping.dev/product/1")
        await tab.wait_for("h3.product-title", timeout=15)
        title = await tab.select("h3.product-title")
        price = await tab.select(".product-price")
        print(title.text, price.text)
        button = await tab.find("Load More", best_match=True)
        print(button)
    finally:
        browser.stop()


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



In the example above, we use two `select()` calls to read the product title and price, and `find()` gets the reviews button by its "Load More" label without any id in our code. Here's what our run printed:

text

```text
Box of Chocolate Candy $9.99 
<button id="load-more-reviews" class="btn btn-primary btn-rounded plausible-event-name=socials/twitter plausible-event-revenue-amount=10.29 plausible-event-revenue-currency=EUR">Load More</button>
```



For the title and price `.text` was enough, but for elements with nested tags it only returns the first text node. In that case, we can use `.text_all`, which joins all of them, and that's what we'll use in the login example later on.

### Scrape Several Pages at Once With Nodriver Tabs

Since Nodriver is asynchronous, we can scrape several pages at the same time by opening each one in its own tab. Let's scrape the first five products pages this way. All we need is `await browser.get(url, new_tab=True)` to open each URL in a new tab, and `asyncio.gather` to run our scrapes side by side:

python

```python
import asyncio
import nodriver as uc

CHROME = "/usr/bin/google-chrome"
URLS = [f"https://web-scraping.dev/products?page={n}" for n in range(1, 6)]


async def scrape(tab):
    await tab.wait_for(".product h3 a", timeout=15)
    return [a.attrs["href"] for a in await tab.select_all(".product h3 a")]


async def main():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        tabs = [await browser.get(url, new_tab=True) for url in URLS]
        pages = await asyncio.gather(*(scrape(tab) for tab in tabs))
        for tab in tabs:
            await tab.close()
        products = [name for page in pages for name in page]
        print(len(products), products[0], products[-1])
    finally:
        browser.stop()


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



Each `scrape()` call works on its own tab and returns that page's product links, and when `asyncio.gather` comes back with all five lists we close the tabs. All five pages came back with 5 products each, from `product/1` to `product/25`:

text

```text
25 https://web-scraping.dev/product/1 https://web-scraping.dev/product/25
```



This is much faster than going through the pages one by one! When we walked the same five pages in a single tab, it took 4.6 to 4.8 seconds over two runs, compared to 1.1 seconds with five tabs.

That being said, these tabs still belong to the same browser and share its cookies. So, for scrapes that need different identities, we'd start a separate browser for each one or use the proxy contexts we'll cover in the proxy section.

[Web Scraping With Playwright in 2026: A Python GuideScrape JavaScript-rendered pages with Playwright and Python in 2026: locators, waiting, request interception, resource blocking, and concurrency.](https://scrapfly.io/blog/posts/web-scraping-with-playwright-and-python)



## How Do You Fill Forms and Reuse Login Cookies in Nodriver?

Sometimes the data we want to scrape is only available after logging in, so our scraper has to fill in the login form just like a real user would. In Nodriver, we can type text into inputs with the `send_keys()` method and press buttons with `click()`.

Since every `uc.start()` call launches Chrome with a fresh temporary profile, we'd have to log in again on every run. This is slow and logging in over and over can also trigger extra checks on the account. To avoid this, we can save the browser cookies to a file with `browser.cookies.save()` and load them back in the next session with `browser.cookies.load()`.

Let's take a look at this using the web-scraping.dev login page (only log into websites you're allowed to). We'll fill in the `#username` and `#password` inputs, click the `button[type=submit]` button and save the session:

python

```python
import os
import nodriver as uc

CHROME = "/usr/bin/google-chrome"
SESSION = "session.dat"


async def login_and_save():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        tab = await browser.get("https://web-scraping.dev/login")
        await (await tab.wait_for("#username")).send_keys("user123")
        await (await tab.select("#password")).send_keys("password")
        await (await tab.select("button[type=submit]")).click()
        await tab
        await tab.wait_for("#secret-message", timeout=15)
        print((await tab.find("Logged in as", best_match=True)).text_all)
        print([c.name for c in await browser.cookies.get_all()])
        await browser.cookies.save(SESSION)
        print(os.path.getsize(SESSION), "bytes saved")
    finally:
        browser.stop()


async def reuse_session():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        await browser.cookies.load(SESSION)
        tab = await browser.get("https://web-scraping.dev/login")
        await tab.wait_for("#secret-message", timeout=15)
        print((await tab.find("Logged in as", best_match=True)).text_all)
    finally:
        browser.stop()


if __name__ == "__main__":
    uc.loop().run_until_complete(login_and_save())
    uc.loop().run_until_complete(reuse_session())
```



In the example above, `login_and_save()` logs in as the demo `user123` user and saves the cookies to `session.dat`. Then `reuse_session()` loads that file into a new browser before its first page, so if we run this code we should see both browsers logged in:

text

```text
Logged in as User123
['auth', 'login_ip']
517 bytes saved
Logged in as User123
```



Just like that, our second browser is logged in without ever filling in the form! All it needed was the `session.dat` file, which is only 517 bytes and holds two cookies: `auth` and `login_ip`.

We might have noticed the lone `await tab` line right after the click. When we submit a form, the browser starts loading a new page, and awaiting the tab lets Nodriver catch up with this navigation before we look for any elements. If we leave it out, our scraper can end up querying the old page right as it's being replaced. That's exactly what happened in 2 out of 4 of our test runs, which crashed in `wait_for()` with `ProtocolException: Could not find node with given id [code: -32000]`. Once we added it back, all 5 of our runs went through without errors.

Note that these saved cookies are as good as a password here. If we run `reuse_session()` without loading `session.dat`, the page doesn't show the secret message at all, so anyone with this file can log in as `user123` and it should be stored somewhere safe.

Cookies are usually enough to keep a login, but Chrome keeps a lot more in its profile, like local storage and cache. For long-running scrapers, we can pass a directory to `uc.start(user_data_dir=...)` and the whole profile will stay on disk between runs.

[How to Scrape FormsLearn how to scrape forms through a step-by-step guide using HTTP clients and headless browsers.](https://scrapfly.io/blog/posts/how-to-scrape-forms)



## How Do You Capture Background API Requests With Nodriver CDP Events?

Many websites load their data in the background through XHR or GraphQL calls, and capturing these responses is often easier than parsing the HTML they turn into. In Nodriver, we can do this through the Chrome DevTools Protocol (CDP), which lets us listen to the network events the browser sees.

Unfortunately, Nodriver 0.50.6 has no helper for waiting on one specific request. That's alright though, as all the raw CDP events and commands are available in `nodriver.cdp`. We'll listen to `Network.responseReceived` to find our response and to `Network.loadingFinished` to know when its body is complete. Then, we can ask Chrome for the body with `Network.getResponseBody`. Note that these events only fire once the Network domain is enabled, and the handlers are registered with `tab.add_handler()`.

Let's give it a go with the web-scraping.dev reviews page. Its load more button fetches more reviews through a GraphQL `POST /api/graphql` call:

python

```python
import asyncio
import json
import nodriver as uc
from nodriver import cdp

CHROME = "/usr/bin/google-chrome"


async def main():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        tab = await browser.get("https://web-scraping.dev/reviews")
        button = await tab.wait_for("#page-load-more", timeout=15)
        print(len(await tab.select_all(".review")), "reviews in the DOM")

        graphql_ids = set()
        finished = asyncio.get_running_loop().create_future()

        def on_response(event: cdp.network.ResponseReceived):
            if "/api/graphql" in event.response.url:
                graphql_ids.add(event.request_id)

        def on_finished(event: cdp.network.LoadingFinished):
            if event.request_id in graphql_ids and not finished.done():
                finished.set_result(event.request_id)

        tab.add_handler(cdp.network.ResponseReceived, on_response)
        tab.add_handler(cdp.network.LoadingFinished, on_finished)
        await tab.send(cdp.network.enable())

        await button.click()
        request_id = await asyncio.wait_for(finished, timeout=15)
        body, _ = await tab.send(cdp.network.get_response_body(request_id))
        reviews = json.loads(body)["data"]["reviews"]
        print(len(reviews["edges"]), "reviews captured, hasNextPage:", reviews["pageInfo"]["hasNextPage"])
        print(reviews["edges"][0]["node"])
    finally:
        browser.stop()


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



Here, the first handler collects the request ids of GraphQL responses. The second resolves a future once one of those bodies has fully arrived, and only then do we ask Chrome for the body. If we run this script, we should see the reviews captured from the GraphQL call:

text

```text
20 reviews in the DOM
20 reviews captured, hasNextPage: True
{'rid': 'cat-ear-beanie-9', 'text': 'Exceptional beanie! I wear it every time I go out in cold weather. The material is comfortable and durable. Highly recommend it.', 'rating': 5, 'date': '2023-04-05'}
```



As we can see, the GraphQL response carried 20 more reviews as JSON on top of the 20 already on the page, and `hasNextPage` tells us there are more to load.

We might have noticed that `cdp.network.enable()` comes after `browser.get()`. That's on purpose! When we enabled it on the blank start tab and then called `tab.get(url)`, all 3 runs failed with `ProtocolException: No resource with given identifier found [code: -32000]`. After we moved the call below `browser.get()`, it worked on every run.

The [CDP Network domain reference](https://chromedevtools.github.io/devtools-protocol/tot/Network/) lists every event and field we've used here. To learn more, see our guide to [scraping background requests with headless browsers](https://scrapfly.io/blog/posts/web-scraping-background-requests-with-headless-browsers-and-python).

And once we know the endpoint, we don't even need a browser!

[Web Scraping Graphql with PythonIntroduction to web scraping graphql powered websites. How to create graphql queries in python and what are some common challenges.](https://scrapfly.io/blog/posts/web-scraping-graphql-with-python)



## How Do You Use Proxies With Nodriver?

Nodriver supports proxies in two ways. If our proxy doesn't need credentials, we can pass Chrome's `--proxy-server` flag through `browser_args`. If it does, we'll have to open a browser context with `await browser.create_context(url, proxy_server="http://USER:PASS@HOST:PORT")` instead.

This is a bit awkward, as Chrome doesn't accept proxy credentials in its launch flags and `uc.start()` has no proxy parameter. So, `create_context()` gets around it by running a small forwarder locally that adds the credentials for us.

### Use --proxy-server for Proxies Without Credentials

To use a proxy without credentials, all we have to do is add the flag to `browser_args`. Here, we read the proxy URL from a `PROXY_URL` environment variable like `http://proxy.example.com:8000`:

python

```python
import os
import nodriver as uc

CHROME = "/usr/bin/google-chrome"


async def main():
    browser = await uc.start(
        headless=True,
        browser_executable_path=CHROME,
        browser_args=[f"--proxy-server={os.environ['PROXY_URL']}"],
    )
    try:
        tab = await browser.get("https://web-scraping.dev/products")
        await tab.wait_for(".product h3 a", timeout=15)
        print([a.text for a in await tab.select_all(".product h3 a")])
    finally:
        browser.stop()


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



We ran it through a local [proxy.py](https://github.com/abhinavsingh/proxy.py) instance with no authentication and got all five products back. Note that the same flag works for IP-allowlisted proxy accounts as well, where the provider checks our server's IP instead of a password.

That being said, our scraper isn't the only thing using this proxy. To see what else goes through it, let's count the tunnels in the proxy log from the same run:

shell

```shell
grep -o 'CONNECT [^ ]*' proxy.log | awk '{print $2}' | sort | uniq -c | sort -rn | head -8
```



text

```text
      9 www.google.com:443
      6 web-scraping.dev:443
      5 www.gstatic.com:443
      3 update.googleapis.com:443
      3 redirector.gvt1.com:443
      3 clients2.google.com:443
      3 accounts.google.com:443
      2 play.google.com:443
```



The full log had 40 tunnels, and above are the top eight. Only 6 of them went to web-scraping.dev, while the other 34 went to Google's own services. So, if our proxy plan is billed by bandwidth, we'd be paying for Chrome's background traffic as well!

Another thing to keep in mind is that a browser started like this keeps the same proxy for as long as it runs. So, to rotate proxies we'd need to start a new browser or context.

[How to Rotate Proxies in Web ScrapingIn this article we explore proxy rotation. How does it affect web scraping success and blocking rates and how can we smartly distribute our traffic through a pool of proxies for the best results.](https://scrapfly.io/blog/posts/how-to-rotate-proxies-in-web-scraping)

### Add an Authenticated Proxy With create\_context

In [GitHub discussion #1798](https://github.com/ultrafunkamsterdam/undetected-chromedriver/discussions/1798) from March 2024 it's pointed out that "chrome does not support credentials via browser args", and the accepted answer writes a CDP auth event handler by hand.

We tried putting `user:pass@` into the `--proxy-server` flag anyway. Chrome just showed its `ERR_NO_SUPPORTED_PROXIES` error page, and our `wait_for()` timed out.

Instead, we can pass the credentials to `create_context()` as part of the proxy URL:

python

```python
import os
import nodriver as uc

CHROME = "/usr/bin/google-chrome"
PROXY = f"http://{os.environ['PROXY_USER']}:{os.environ['PROXY_PASS']}@{os.environ['PROXY_HOST']}"


async def main():
    browser = await uc.start(headless=True, browser_executable_path=CHROME)
    try:
        tab = await browser.create_context("https://web-scraping.dev/products", proxy_server=PROXY)
        await tab.wait_for(".product h3 a", timeout=15)
        print([a.text for a in await tab.select_all(".product h3 a")])
    finally:
        browser.stop()


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



Here, `create_context()` returns the first tab of a new browser context, which goes through Nodriver's forwarder. To test it, we ran a local `proxy --port 8900 --basic-auth user:pass` and set `PROXY_HOST=127.0.0.1:8900`. Here's what our script printed:

text

```text
['Box of Chocolate Candy', 'Dark Red Energy Potion', 'Teal Energy Potion', 'Red Energy Potion', 'Blue Energy Potion']
```



As we can see, all five products came through. If we check the proxy log, this time there are only 2 tunnels to `web-scraping.dev:443` and 1 to `www.google.com`. That's because the context sends only its own page traffic through the proxy rather than the whole browser's.

What if the password is wrong? To check, we put a wrong one in `PROXY_PASS`, and the products page didn't load at all. Instead, the forwarder printed:

text

```text
Upstream proxy rejected connection: HTTP/1.1 407 Proxy Authentication Required
```



We can also use more than one proxy in the same browser. For example, two `create_context()` calls with two different proxies gave us two contexts, each going through its own proxy. To see which IP a context got, we can load `https://httpbin.dev/anything` in it and read the `origin` field. Note that Chrome wraps that JSON in a `<pre>` element, so we'll need `text_all` to read it.

Finally, what about SOCKS proxies? The `create_context` docstring writes them as `socks://USERNAME:PASSWORD@SERVER:PORT`. Though, we only used HTTP proxies in our runs, so we haven't tested this one.



Scrapfly

#### Need to bypass anti-bot protection?

Scrapfly's Unblocker handles Cloudflare, DataDome, and more — automatically.

[Try Free →](https://scrapfly.io/register)## How Do You Run Nodriver Headless or on a Server?

To run Nodriver 0.50.6 headless, all we need is `headless=True` in `uc.start()`. Unfortunately, this leaves a `HeadlessChrome` token in the user agent, which some websites check for. To avoid it on a server, we can run headed Chrome inside an `xvfb-run` virtual display on Linux. Alternatively, we can attach Nodriver to a Chrome that our container already runs.

### What Does Headless Mode Change in Nodriver's Fingerprint?

Let's find out by running the same script in both modes. It opens the products page and uses JavaScript to read `navigator.userAgent`, the `navigator.webdriver` flag and the brand list from `navigator.userAgentData`. Here, the `HEADLESS` environment variable sets the `headless` argument, so it's the only thing that changes between runs:

python

```python
import json
import os
import nodriver as uc

CHROME = "/usr/bin/google-chrome"
HEADLESS = os.environ.get("HEADLESS", "1") == "1"


async def main():
    browser = await uc.start(headless=HEADLESS, browser_executable_path=CHROME)
    try:
        tab = await browser.get("https://web-scraping.dev/products")
        await tab.wait_for(".product h3 a", timeout=15)
        fingerprint = await tab.evaluate("""JSON.stringify({
            ua: navigator.userAgent,
            webdriver: navigator.webdriver,
            brands: navigator.userAgentData.brands.map(b => b.brand).join(", "),
        })""")
        print(json.loads(fingerprint))
        print(len(await tab.select_all(".product h3 a")), "products")
    finally:
        browser.stop()


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



If we run it with the default `HEADLESS=1` on Chrome 156 for Linux, we'll get:

text

```text
{'ua': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36', 'webdriver': False, 'brands': 'Not:A-Brand, Chromium, Google Chrome'}
5 products
```



For the headed run, Chrome needs a display, so we'll start it with `HEADLESS=0 xvfb-run -a python fingerprint.py`:

text

```text
{'ua': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36', 'webdriver': False, 'brands': 'Not:A-Brand, Chromium, Google Chrome'}
5 products
```



Here's a quick comparison of both outputs:

| Signal | `headless=True` | Headed under `xvfb-run` |
|---|---|---|
| User agent | `HeadlessChrome/156.0.0.0` | `Chrome/156.0.0.0` |
| `navigator.webdriver` | `False` | `False` |
| Brand list | `Not:A-Brand, Chromium, Google Chrome` | Same |
| Products returned | 5 | 5 |

As we can see, the user agent is the only value that changed. The `HeadlessChrome/156.0.0.0` string is the headless signal in these values. To remove it, set `HEADLESS=0` in the fingerprint script and run headed Chrome inside a virtual display:

shell

```shell
HEADLESS=0 xvfb-run -a python fingerprint.py
```



Note that `xvfb-run` comes with the `xvfb` package on Debian and Ubuntu, and with `xorg-server-xvfb` on Arch.

Interestingly, Nodriver 0.50.6 does ship a helper that removes "Headless" from the user agent. It's called `_prepare_headless()`, and if we grep the package for it, the only hit is its own definition in `core/tab.py`.

Then why not pass our own user agent? We could, though it'd say `Chrome/156.0.0.0` long after Chrome itself moves on, and that mismatch can give us away. To test our own setup, we can use [Scrapfly's automation detector](https://scrapfly.io/web-scraping-tools/automation-detector).

[What is a Headless Browser? Top 5 Headless Browser ToolsQuick overview of new emerging tech of browser automation - what exactly are these tools and how are they used in web scraping?](https://scrapfly.io/blog/posts/what-is-a-headless-browser)

### Attach Nodriver to a Chrome You Run in Docker or on Another Host

To attach Nodriver to an existing Chrome, the browser has to be started with the `--remote-debugging-port` flag. In the example below, `subprocess.Popen` plays the container's role and starts Chrome on port 9333. Then, we pass that host and port to `uc.start()`, which makes Nodriver connect to the running Chrome instead of launching its own:

python

```python
import asyncio
import subprocess
import tempfile
import time
import nodriver as uc

CHROME = "/usr/bin/google-chrome"


async def main():
    chrome = subprocess.Popen([
        CHROME, "--headless=new", "--remote-debugging-port=9333",
        f"--user-data-dir={tempfile.mkdtemp()}",
    ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    try:
        await asyncio.sleep(2)

        start = time.perf_counter()
        browser = await uc.start(host="127.0.0.1", port=9333)
        print(f"attached in {time.perf_counter() - start:.2f}s")
        tab = await browser.get("https://web-scraping.dev/products", new_tab=True)
        await tab.wait_for(".product h3 a", timeout=15)
        print([a.text for a in await tab.select_all(".product h3 a")])
        await tab.close()
        browser.stop()
        print("chrome still running:", chrome.poll() is None)
    finally:
        chrome.terminate()
        chrome.wait(timeout=5)


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



Here, we also measure how long `uc.start()` takes and check whether Chrome's process is alive after `browser.stop()`. If we run it, we'll get:

text

```text
attached in 0.26s
['Box of Chocolate Candy', 'Dark Red Energy Potion', 'Teal Energy Potion', 'Red Energy Potion', 'Blue Energy Potion']
chrome still running: True
```



As we can see from the last line, Chrome was still running after `browser.stop()`. Nodriver didn't start this Chrome, so stopping it is our job, or the container's.

When running in a container, there are a couple of things to keep in mind:

- root user - Nodriver checks for root on Linux and, when it finds it, logs `detected root usage, auto disabling sandbox mode`. Chrome then runs without its sandbox, so it's best to use a non-root user.
- debugging port - all our script needed to take over Chrome was `127.0.0.1:9333`. So, if that port is reachable from the internet, so is our browser! Keep it bound to localhost or a private network.



## Why Does Nodriver Fail to Start, Hang or Raise Errors?

Below are nine errors we've reproduced with Nodriver and how to fix them. The first three aren't caused by our scraping code at all, but by the Python version, the browser path and a script saved as `nodriver.py`. The other six come from the calls our scripts make. All of them ran on 0.50.6 with Python 3.12, except for the first one which needs Python 3.9:

| Error | Cause | Fix |
|---|---|---|
| `TypeError: unsupported operand type(s) for \|: 'type' and 'type'` | Python 3.9 | Python 3.10+ |
| `FileNotFoundError: could not find a valid chrome browser binary` | No browser on PATH | Set `browser_executable_path` |
| `AttributeError: module 'nodriver' has no attribute 'loop'` | Script named `nodriver.py` | Rename the file |
| `TypeError: object NoneType can't be used in 'await' expression` | `await browser.stop()` | Drop the `await` |
| `ProtocolException 'DOM.enable' wasn't found` | `tab.xpath()` | CSS selectors |
| `Could not find node with given id` | Query during navigation | `await tab` first |
| `No resource with given identifier found` | Network enabled before `tab.get()` | Enable after navigation |
| `ERR_NO_SUPPORTED_PROXIES` | Credentials in `--proxy-server` | `create_context()` |
| `407 Proxy Authentication Required` | Wrong proxy credentials | Check the credentials |

The full browser error message reads "please make sure chrome is installed.or use the keyword argument 'browser\_executable\_path=/path/to/your/browser'". Note that the missing space after the period is Nodriver's typo, not ours.

Since almost every Nodriver call we've used so far is awaited, it's easy to add an `await` to `browser.stop()` as well:

python

```python
    await browser.stop()
```



This way, the scrape still finishes but the run ends with this traceback line:

text

```text
TypeError: object NoneType can't be used in 'await' expression
```



This is because in 0.50.6 `stop()` is a regular `def` function rather than an `async def` one. So, calling it returns `None` and awaiting `None` raises the error above.

Another gotcha is replacing the `uc.loop().run_until_complete(main())` line of our first scraper with `asyncio.run(main())`, which the README's example doesn't use. On Python 3.12 the run still exited with code 0, but on shutdown it printed:

text

```text
Exception ignored in: <function BaseSubprocessTransport.__del__ at 0x7f1da5fc7560>
Traceback (most recent call last):
RuntimeError: Event loop is closed
```



Here, we cut out the middle of the traceback. Since the exit code is 0 it's only noise, but `uc.loop()` doesn't print any of it, which is why all of our examples use `uc.loop()`.

Note that some of the older fixes floating around are outdated. For example, in [GitHub discussion #2090](https://github.com/ultrafunkamsterdam/undetected-chromedriver/discussions/2090) from November 2024 a user recommends running `pip install websockets==13.1` to anyone who "doesn't want to waste half a day trying to figure it out". Since 0.50.6 requires `websockets>=14`, that 2024 pin no longer applies, and our runs used 17.3.

Finally, when running headed on a server without a display, Chrome has nowhere to open its window. Set `headless=False` before running a script under `xvfb-run`; for the fingerprint example above, use `HEADLESS=0 xvfb-run -a python fingerprint.py`.



## Where Does Nodriver Stop Being Enough for Web Scraping?

Our fingerprint script read `navigator.webdriver` as `False` in both modes. The property is still present; its value alone does not prove the browser is undetectable. That being said, the Chrome behind it is the same stock build we installed, as Nodriver doesn't patch anything inside it. So, what can anti-bot systems still detect?

The browser identity is the obvious one. If we take another look at the headed run, its user agent is `Chrome/156.0.0.0`, which is simply the Chrome we installed earlier. That's because Nodriver doesn't bring a browser along. It runs whatever Chrome is on our machine, and websites get that Chrome's fingerprint and network stack as they are. We've covered what else they can pick up in our breakdown of [CDP automation detection](https://scrapfly.io/blog/posts/chrome-cdp-stealth-browser-automation-detection-explained).

In headless mode it's even easier to spot, as our headless run still reported `HeadlessChrome/156.0.0.0` as its user agent.

Then there's the IP address. Swapping it with `create_context()` doesn't change much if the proxy comes from a datacenter, as websites can still tell it apart from a home connection.

Lastly, there's maintenance and licensing. Nodriver is async-only, works only with Chromium and is licensed under AGPL-3.0. So, it's best to pin versions and rerun scrapers after every Chrome or Nodriver upgrade.

What about CAPTCHAs? Nodriver doesn't include a general CAPTCHA solver. Its `verify_cf()` helper uses `opencv-python` to locate and click a checkbox. The included image template is English; the method also accepts a custom `template_image`. Note that the README calls this method `cf_verify()`, so let's check if that name exists in 0.50.6:

python

```python
import nodriver as uc

print(hasattr(uc.Tab, "cf_verify"), hasattr(uc.Tab, "verify_cf"))
```



text

```text
False True
```



Turns out it doesn't, so `tab.cf_verify()` raises an `AttributeError` and `tab.verify_cf()` is the one to use. For more, see our [Cloudflare-specific Nodriver setup](https://scrapfly.io/blog/posts/how-to-bypass-cloudflare-anti-scraping) guide.

[Best Stealth Browsers for Web Scraping in 2026A neutral, benchmark-literate ranking of the best open-source stealth browsers for web scraping in 2026, with the success, speed, and maintenance tradeoffs.](https://scrapfly.io/blog/posts/best-stealth-browsers)



## How Do You Move a Nodriver Scraper to a Managed Browser?

To move a Nodriver scraper to a managed stealth browser, we can port its page logic to Playwright and connect it to Scrapfly Cloud Browser over CDP. Alternatively, if we only need the rendered HTML, the Web Scraping API can replace the browser altogether.

The Cloud Browser docs list integrations for Playwright, Puppeteer, Selenium, Browser Use, Stagehand and Vibium but not Nodriver. That's alright though, as the page logic stays the same and only a few calls need to change.

We'd recommend moving once Chrome versions, Xvfb, proxies and servers take up more of our time than the scraper does. Here's a quick comparison of all three routes:

| Route | Browser | You run | Fits when |
|---|---|---|---|
| Self-hosted Nodriver | Your installed Chrome | Chrome, Xvfb, proxies, servers | Modest volume, targets accept it |
| Playwright on Scrapfly Cloud Browser | Scrapium (patched Chromium) | Your Playwright code | Clicks, forms, logins on protected sites |
| Scrapfly Web Scraping API | Managed rendering | One HTTP call per page | You only need the HTML |

Scrapium is Scrapfly's stealth Chromium, and according to the [Scrapium page](https://scrapfly.io/scrapium) it has "550+" Chromium source files patched.

[Web Scraping With Cloud BrowsersIntroduction cloud browsers and their benefits and a step-by-step setup with self-hosted Selenium-grid cloud browsers.](https://scrapfly.io/blog/posts/web-scraping-with-cloud-browsers)

### Port Your Nodriver Page Logic to Playwright on Scrapfly Cloud Browser

Most of the Nodriver calls we've used translate directly to Playwright:

| Nodriver | Playwright on Cloud Browser |
|---|---|
| `await uc.start()` | `p.chromium.connect_over_cdp(wss_url)` |
| `await browser.get(url)` | `page.goto(url)` |
| `await tab.wait_for(css)` | `page.wait_for_selector(css)` |
| `select_all(css)` + `.text` | `page.locator(css).all_inner_texts()` |
| `await tab.evaluate(js)` | `page.evaluate(js)` (returns dicts) |
| `browser.cookies.save()` | For short-term live state: `session=` + `auto_close=false` (not a cookie-file export) |
| `create_context(proxy_server=...)` | `proxy_pool=` + `country=` |
| `cdp.network` handlers | `page.on("response", ...)` |

Let's port our first scraper over. Note that the API key is read from the `SCRAPFLY_KEY` environment variable:

python

```python
import os
import time
from playwright.sync_api import sync_playwright

WS = (
    f"wss://browser.scrapfly.io?api_key={os.environ['SCRAPFLY_KEY']}"
    "&proxy_pool=public_datacenter_pool&os=windows&country=us"
)

with sync_playwright() as p:
    start = time.perf_counter()
    browser = p.chromium.connect_over_cdp(WS)
    print(f"connected in {time.perf_counter() - start:.2f}s to {browser.version}")
    context = browser.contexts[0]
    page = context.pages[0] if context.pages else context.new_page()
    page.goto("https://web-scraping.dev/products")
    page.wait_for_selector(".product h3 a")
    print(page.locator(".product h3 a").all_inner_texts())
    print(page.evaluate("""() => ({
        ua: navigator.userAgent,
        webdriver: navigator.webdriver,
        platform: navigator.platform,
        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    })"""))
    browser.close()
```



In the example above, we connect over CDP, reuse the context the remote browser already has and open a page in it only if none exists. Running it with Playwright 1.64.1 prints:

text

```text
connected in 2.11s to 155.0.8059.84
['Box of Chocolate Candy', 'Dark Red Energy Potion', 'Teal Energy Potion', 'Red Energy Potion', 'Blue Energy Potion']
{'ua': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36', 'webdriver': False, 'platform': 'Win32', 'timezone': 'America/New_York'}
```



The whole run took 8 seconds. As we can see, the user agent and platform match the `os=windows` parameter and the timezone matches `country=us`. Note that the context came back without any open pages, so the `new_page()` call is necessary.

There are a few more connection parameters, which are listed in the [Cloud Browser docs](https://scrapfly.io/docs/cloud-browser-api/getting-started):

- `proxy_pool` picks a datacenter or residential pool, and `country` takes an ISO-2 code.
- `os` sets the browser's operating system to `linux`, `windows` or `macos`. There are also `android`, `iphone` and `ipad` personas.
- `session` together with `auto_close=false` keeps the browser alive, so we can come back to it later with [session resume](https://scrapfly.io/docs/cloud-browser-api/session-resume).
- `timeout` is set to 15 minutes by default and can go up to 30 minutes.

For all of the [connect\_over\_cdp](https://playwright.dev/python/docs/api/class-browsertype#browser-type-connect-over-cdp) options, see Playwright's documentation. The [Cloud Browser Playwright integration](https://scrapfly.io/docs/cloud-browser-api/playwright) docs also have a few more examples.

### Use the Web Scraping API When You Only Need the HTML

If all we need is the rendered HTML, we don't need to run a browser at all! The Web Scraping API can render the page and get around blocking in a single API call. For this, we'll need the Scrapfly SDK and `parsel`, which `result.selector` uses, so let's install both with `pip install scrapfly-sdk parsel`:

python

```python
import os
from scrapfly import ScrapeConfig, ScrapflyClient

client = ScrapflyClient(key=os.environ["SCRAPFLY_KEY"])
result = client.scrape(ScrapeConfig(
    url="https://web-scraping.dev/products",
    render_js=True,
    unblocker=True,  # asp=True is the older alias
    country="us",
))
print(result.status_code)
print(result.selector.css(".product h3 a::text").getall())
```



Here, we enable `render_js` to render the page in a browser and `unblocker` to turn on Unblocker (previously known as ASP). With scrapfly-sdk 0.12.0 the call took 5.1 seconds and printed:

text

```text
200
['Box of Chocolate Candy', 'Dark Red Energy Potion', 'Teal Energy Potion', 'Red Energy Potion', 'Blue Energy Potion']
```



Scrapfly also has SDKs for TypeScript, Go and Rust. For more, see the [Web Scraping API](https://scrapfly.io/products/web-scraping-api) page and the [Unblocker docs](https://scrapfly.io/docs/scrape-api/unblocker).



## FAQ

Is Nodriver better than undetected-chromedriver?For new code, Nodriver. Nodriver is the successor of undetected-chromedriver by the same author. Instead of patching ChromeDriver, it drops Selenium and ChromeDriver altogether and talks to Chrome over CDP. That being said, Nodriver is async-only and works with Chromium-family browsers only, so for older synchronous code our [undetected-chromedriver guide](https://scrapfly.io/blog/posts/web-scraping-without-blocking-using-undetected-chromedriver) is still useful.







Is Nodriver still maintained in 2026?Yes. To check, we can use PyPI's JSON API, which lists the upload date of every release:

python

```python
import json
from urllib.request import urlopen

releases = json.load(urlopen("https://pypi.org/pypi/nodriver/json"))["releases"]
for version, files in releases.items():
    if version.startswith("0.50"):
        print(version, files[0]["upload_time"][:10])
```



text

```text
0.50.1 2026-05-11
0.50.2 2026-05-13
0.50.3 2026-05-13
0.50.4 2026-10-05
0.50.5 2026-10-05
0.50.6 2026-10-08
0.50.7 2026-10-26
0.50.8 2026-10-26
0.50.9 2026-10-26
0.50.99 2026-10-26
```



As of October 27, PyPI lists three 0.50 releases in May 2026 and seven in October. These examples remain pinned to 0.50.6.







Can I use Nodriver in a commercial project?Yes. Nodriver is licensed under AGPL-3.0, which allows commercial use. That being said, attention should be paid to Section 13 of the licence, "Remote Network Interaction", when running a modified Nodriver as a network service, as it requires offering the modified source to its users.







Does Nodriver solve CAPTCHAs?Nodriver has no general CAPTCHA solver, but 0.50.6 includes `tab.verify_cf()` to locate and click a checkbox using `opencv-python`. Its included image template is English; a custom template can be supplied. The README calls it `cf_verify()`, which doesn't exist in 0.50.6, and the helper rejects expert mode. For more on getting past these challenges, see our [Cloudflare bypass guide](https://scrapfly.io/blog/posts/how-to-bypass-cloudflare-anti-scraping).







Can Nodriver connect to a remote browser?Yes. To attach to a running Chrome we can pass its `host` and `port` to `uc.start()`, as long as Chrome was started with `--remote-debugging-port`. Note that `browser.stop()` doesn't close an attached Chrome, so its lifecycle and its debugging port are up to us.

Scrapfly Cloud Browser isn't one of those, as its docs cover clients like Playwright and Puppeteer but not Nodriver. For that, see the Playwright port in the managed browser section above.









## Summary

Nodriver is the successor of undetected-chromedriver that drives a real Chrome over CDP without any WebDriver layer. We've taken a look at how to install it, scrape pages, fill forms, reuse login cookies and capture background API requests through CDP events.

We've also covered a few gotchas. Nodriver launches whichever browser has the shortest path, so it's best to set `browser_executable_path`, and we pinned version 0.50.6 as that's what we tested. `browser.get()` returns before the page is loaded so we wait for selectors, and proxy credentials only work through `create_context()`. Finally, headless Chrome still sends `HeadlessChrome` in its user agent, so on servers we ran it headed with `xvfb-run` or it can be attached to a headed Chrome we start ourselves.

For targets where stock Chrome gets blocked or that need clicks, forms and logins, we ported the same logic to Playwright and ran it on [Scrapfly Cloud Browser](https://scrapfly.io/products/cloud-browser-api), or used the [Web Scraping API](https://scrapfly.io/products/web-scraping-api) when only the HTML is needed. That being said, where stock Chrome gets through, self-hosted Nodriver is a great choice!



Legal Disclaimer and PrecautionsThis tutorial covers popular web scraping techniques for education. Interacting with public servers requires diligence and respect:

- Do not scrape at rates that could damage the website.
- Do not scrape data that's not available publicly.
- Do not store PII of EU citizens protected by GDPR.
- Do not repurpose *entire* public datasets which can be illegal in some countries.

Scrapfly does not offer legal advice but these are good general rules to follow. For more you should consult a lawyer.

 

   [  Add as a preferred source ](https://google.com/preferences/source?q=scrapfly.io) Table of Contents















 

  Table of Contents

- [Key Takeaways](#key-takeaways)
- [What Is Nodriver and How Is It Different From Selenium?](#what-is-nodriver-and-how-is-it-different-from-selenium)
- [How Do You Install Nodriver in Python?](#how-do-you-install-nodriver-in-python)
- [How Do You Scrape a Page With Nodriver?](#how-do-you-scrape-a-page-with-nodriver)
- [Write Your First Nodriver Scraper With uc.start() and browser.get()](#write-your-first-nodriver-scraper-with-uc-start-and-browser-get)
- [Select Elements and Wait for Content With select, find and wait\_for](#select-elements-and-wait-for-content-with-select-find-and-wait-for)
- [Scrape Several Pages at Once With Nodriver Tabs](#scrape-several-pages-at-once-with-nodriver-tabs)
- [How Do You Fill Forms and Reuse Login Cookies in Nodriver?](#how-do-you-fill-forms-and-reuse-login-cookies-in-nodriver)
- [How Do You Capture Background API Requests With Nodriver CDP Events?](#how-do-you-capture-background-api-requests-with-nodriver-cdp-events)
- [How Do You Use Proxies With Nodriver?](#how-do-you-use-proxies-with-nodriver)
- [Use --proxy-server for Proxies Without Credentials](#use-proxy-server-for-proxies-without-credentials)
- [Add an Authenticated Proxy With create\_context](#add-an-authenticated-proxy-with-create-context)
- [How Do You Run Nodriver Headless or on a Server?](#how-do-you-run-nodriver-headless-or-on-a-server)
- [What Does Headless Mode Change in Nodriver's Fingerprint?](#what-does-headless-mode-change-in-nodriver-s-fingerprint)
- [Attach Nodriver to a Chrome You Run in Docker or on Another Host](#attach-nodriver-to-a-chrome-you-run-in-docker-or-on-another-host)
- [Why Does Nodriver Fail to Start, Hang or Raise Errors?](#why-does-nodriver-fail-to-start-hang-or-raise-errors)
- [Where Does Nodriver Stop Being Enough for Web Scraping?](#where-does-nodriver-stop-being-enough-for-web-scraping)
- [How Do You Move a Nodriver Scraper to a Managed Browser?](#how-do-you-move-a-nodriver-scraper-to-a-managed-browser)
- [Port Your Nodriver Page Logic to Playwright on Scrapfly Cloud Browser](#port-your-nodriver-page-logic-to-playwright-on-scrapfly-cloud-browser)
- [Use the Web Scraping API When You Only Need the HTML](#use-the-web-scraping-api-when-you-only-need-the-html)
- [FAQ](#faq)
- [Summary](#summary)
 
    Join the Newsletter  Get monthly web scraping insights 

 

  



Scale Your Web Scraping

Anti-bot bypass, browser rendering, and rotating proxies, all in one API. Start with 1,000 free credits.

  No credit card required  1,000 free API credits  Anti-bot bypass included 

 [Start Free](https://scrapfly.io/register) [View Docs](https://scrapfly.io/docs/onboarding) 

 Not ready? Get our newsletter instead. 

 

 ## Related Articles

 [  

 python nodejs 

### How to use Headless Chrome Extensions for Web Scraping

In this article, we'll explore different useful Chrome extensions for web scraping. We'll also explain how to install Ch...

 

 ](https://scrapfly.io/blog/posts/how-to-use-browser-extensions-with-playwright-puppeteer-and-selenium) [     

 python proxies 

### How to Use Proxies with Selenium: Authentication, Rotation, and Residential Proxies

A tested walkthrough of Python Selenium proxies: why Chrome drops credentials, the auth methods that still work, rotatio...

 

 ](https://scrapfly.io/blog/posts/how-to-use-proxies-with-selenium) [  

 python blocking 

### Web Scraping Without Blocking With Undetected ChromeDriver

In this tutorial we'll be taking a look at a new popular web scraping tool Undetected ChromeDriver which is a Selenium e...

 

 ](https://scrapfly.io/blog/posts/web-scraping-without-blocking-using-undetected-chromedriver) 

  ## Related Questions

- [ Q How to capture background requests and responses in Playwright? ](https://scrapfly.io/blog/answers/how-to-capture-xhr-requests-playwright)
- [ Q 3 ways to install Python Requests library ](https://scrapfly.io/blog/answers/how-to-install-requests-python)
- [ Q How To Copy as cURL With Google Chrome? ](https://scrapfly.io/blog/answers/how-to-copy-as-curl-with-chrome)
- [ Q How to capture background requests and responses in Selenium? ](https://scrapfly.io/blog/answers/how-to-capture-xhr-requests-selenium)
 
  



   



 Bypass anti-bot protection automatically, **1,000 free credits** [Start Free](https://scrapfly.io/register)