๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Comparisons

Pydoll vs Nodriver: Who Really Handles Turnstile?

Pydoll vs nodriver read from source: what pydoll's built-in Turnstile handling actually does, nodriver's quieter claim, and five bugs in both captcha paths.

S SparkProxy 3 30 min read
Share

Pydoll vs nodriver usually gets framed as "the one that handles captchas against the one that only hides." That framing is wrong in both directions, and the correction is more useful than the comparison. Pydoll does advertise Cloudflare Turnstile handling, and its own documentation is unusually careful about what that means. Nodriver makes a similar claim more quietly, through a method most write-ups never mention, using a completely different mechanism. This post reads both implementations line by line from the repositories on 9 October 2026, quotes what each project says about itself, states plainly what neither can do, and then compares them as async CDP drivers on the things you will actually hit: API shape, waits, event handling, licensing and who is still shipping fixes.

What "handles Cloudflare Turnstile" means

Three very different operations get sold under the same verb, and conflating them is how teams end up with a pipeline that works on Tuesday and fails on Wednesday.

The first is waiting. A large share of Turnstile deployments run in non-interactive or managed mode: the widget loads, runs its own proof-of-work and telemetry collection in a cross-origin iframe, and clears itself in a second or two without the visitor touching anything. A script that sleeps until the interstitial disappears has not solved anything. It waited. The challenge solved itself because the environment looked acceptable.

The second is clicking. When Turnstile renders the interactive checkbox variant, there is a real DOM element a real person would click. Finding it and clicking it is browser automation, the same category of work as filling a login form. The challenge still decides whether to accept the click, and it decides using signals that have nothing to do with who moved the mouse: IP reputation, TLS and HTTP/2 fingerprint, the consistency of the browser identity, and prior history for that cookie.

The third is solving: producing a valid token without the browser legitimately earning it, by replaying a challenge payload, farming it to a human, or breaking the puzzle with a model. That is what a captcha-solving service sells, and it is a different product with a different legal footing.

Pydoll and nodriver both do the second operation at most. Neither does the third. Neither project claims to do the third, and neither can. Everything else in this post follows from that. If you want the breakdown of which Turnstile mode a given site is running and how the token flow works, we took that apart in how to bypass Cloudflare Turnstile.

Pydoll's claim, in its own words

Pydoll's README opens its pitch with a single sentence that lists Turnstile alongside ordinary automation features:

"It clicks, types and scrolls like a person, applies coherent fingerprint profiles, handles Cloudflare Turnstile, and extracts typed data from the page."

Read on its own, "handles Cloudflare Turnstile" is the sort of line that gets screenshotted into a comparison table as a checkmark. Pydoll then spends the rest of the README walking it back, carefully and voluntarily. The feature bullet is already hedged:

"Humanized interactions: mouse movement along Bezier curves, realistic typing, and scroll physics. Often enough to pass behavioral challenges like Cloudflare Turnstile or reCAPTCHA v3, depending on your browser and IP reputation."

The dedicated section is blunter:

"Pydoll handles Cloudflare Turnstile the same way a person does: it finds the widget and places a realistic, humanized click on it. Whether Turnstile accepts the click depends on your browser fingerprint and IP reputation, which is why the two sections above come first."

And a note immediately under the demo GIF says the quiet part out loud:

"This isn't a bypass. Pydoll performs the same click a real user would; whether it passes depends on your environment (browser fingerprint and IP reputation)."

The documentation page is more explicit still. It describes the feature and then draws the line in one sentence: "Pydoll can click a Cloudflare Turnstile checkbox for you, the same click a person makes on the widget. It does not solve image or puzzle challenges, and whether the click is accepted depends on your IP reputation and fingerprint, not on Pydoll. Treat this as automating the click, not defeating the captcha." The same page rules out two whole challenge families: "It does not handle reCAPTCHA or hCaptcha. Those are not supported by this feature."

There is one piece of friction worth flagging, because it is the source of most of the confusion downstream. That page lives at the URL path /docs/stealth/captcha-bypass/, and its own first paragraph says it isn't a bypass. The URL is what gets linked, quoted and cached. The sentence is what is true. Pydoll is more honest about this feature than most of the blog posts that cite it, which is a strange and slightly funny position for a project to be in.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Nodriver's quieter claim

The usual version of this comparison says nodriver makes no captcha claim at all. That is not accurate as of release 0.50.6.

Nodriver's README has a short section, three lines long, with no hedging whatsoever:

"tab.cf_verify() finds the checkbox and click it successfully. this only works when NOT in expert mode. currently built-in english only. requires opencv-python package to be installed"

"Finds the checkbox and click it successfully" is a stronger sentence than anything in pydoll's documentation, and it arrives with none of pydoll's caveats about fingerprint and IP. There is no note saying this isn't a bypass, because there is no note at all.

The release notes for 0.50.1 make a second, related claim about the flat-mode connection rewrite:

"find() will include iframes, so you can even search for 'verify you are human' and click the verification checkbox in js challenges."

And the repository description, which is what GitHub search and social cards surface, frames the whole project against challenge systems: "Providing a blazing fast framework for web automation, webscraping, bots and any other creative ideas which are normally hindered by annoying anti bot systems like Captcha / CloudFlare / Imperva / hCaptcha."

So the honest scoreboard is not "one claims, one doesn't." Both ship a helper that clicks a Cloudflare checkbox. Pydoll documents its limits in four separate places; nodriver documents none. The difference between the projects is candour about the same capability, not the capability.

Two mechanisms: shadow DOM walk vs template match

The implementations have almost nothing in common. This is the part no feature table captures, and it determines how each one fails.

Pydoll works entirely inside the DevTools Protocol. Tab.expect_cloudflare_turnstile(time_to_wait_captcha=5) is an async context manager. On entry it enables page events if they were off and registers a handler on Page.loadEventFired. When that event arrives, _handle_cloudflare_turnstile starts polling: it calls find_shadow_roots(deep=False) and returns the first shadow root whose inner_html() mentions challenges.cloudflare.com. Having found it, _click_cloudflare_checkbox walks shadow root, challenge iframe, body, inner shadow root, input[type="checkbox"], and clicks. Every step in that walk is issued with timeout=0 on purpose, and the source comment explains why: "any node captured here can go stale while Cloudflare re-renders the iframe, and polling locally on a stale node both wastes time and can let four sequential waits overrun the caller's deadline." A failure restarts the whole traversal from the top after a 0.5 second sleep, until the 5 second deadline expires.

Nodriver takes a screenshot and does computer vision on it. Tab.verify_cf() first refuses to run in expert mode, raising an exception whose message reads "this function is useless in expert mode, since it disables site-isolation-trials." Otherwise it delegates to template_location(), which imports cv2, writes the viewport to screen.jpg in the current working directory, writes the bundled English template to cf_template.png, grayscales both, runs cv2.matchTemplate(im_gray, template_gray, cv2.TM_CCOEFF_NORMED), takes the maximum location from cv2.minMaxLoc, computes the centre of the matched rectangle, and returns those pixel coordinates. verify_cf then calls mouse_click(x, y) and optionally flashes a marker there. The docstring is candid about the template's scope: it is "included is english only," and a non-English interface requires you to crop your own screenshot "where the target is exactly in the center."

pydoll `expect_cloudflare_turnstile()`nodriver `verify_cf()`
LayerCDP DOM and shadow-root traversalScreenshot plus OpenCV template match
TriggerContext manager around a navigationExplicit call after the page is up
Finds the widget byShadow root containing `challenges.cloudflare.com`Grayscale correlation against a bundled PNG
ClicksThe `input[type="checkbox"]` elementViewport pixel coordinates
Language dependenceNoneEnglish template bundled; bring your own otherwise
Extra dependencyNone`opencv-python`, not a declared dependency
Timeout`time_to_wait_captcha`, default 5s, 0.5s pollNone; single pass
Refuses to run whenNever`expert=True` is set on the browser config
RetriesFull traversal restart until deadlineNo retry loop

Mechanism dictates failure shape. Pydoll's walk either finds a Cloudflare shadow root or it does not; a page with no widget costs you five seconds and clicks nothing. Nodriver's match always returns a best guess, which is the subject of the next section.

What neither library can do

Worth stating in a block you can paste into a design doc, because this is the set of expectations that breaks pipelines.

Neither library solves reCAPTCHA v2 image grids, hCaptcha, Arkose FunCaptcha, GeeTest sliders, or any puzzle that requires classifying content. Pydoll's documentation says so for reCAPTCHA and hCaptcha in as many words. Nodriver ships no helper for any of them.

Neither library mints a Turnstile token. Both click a checkbox and let Cloudflare decide. If Cloudflare declines, the library has no second move.

Neither library changes the signals that actually drive the decision. Pydoll's own README is explicit that the network layer is out of reach: the fingerprint profile "must match the real Chrome binary you drive (the network-layer TLS / HTTP2 fingerprint is authentic and cannot be spoofed)." Its captcha page adds the blunter version: "No browser configuration overcomes a flagged IP."

Neither library is a ranking of general stealth effectiveness, and this post deliberately does not provide one. Patch-layer placement is the variable that decides that question, and we measured it separately in undetected-chromedriver vs Patchright vs Camoufox. What the detectors look at in the first place is covered in headless browser detection.

The practical consequence is that the captcha helper is the least important thing you are choosing between. Both are a convenience on top of trust you earned elsewhere, and the cheapest way to earn it is to not trip the challenge at all, which is the argument we make at length in how to avoid captchas when web scraping.

Five failure modes in the captcha paths

All five are readable in the source as published today. None is hypothetical.

Pydoll's context manager can hang forever. On exit it does yield and then await captcha_processed.wait(). That event is set only from inside the Page.loadEventFired handler. There is no timeout on the wait itself; time_to_wait_captcha bounds the inner polling loop, which never starts until the load event fires. Enter the block without triggering a full document load, on an already-loaded page, a client-side route change, or a navigation that ends in a download, and the async with never exits. Give it your own ceiling:

import asyncio

from pydoll import Chrome


async def guarded_turnstile(url: str, ceiling: float = 20.0) -> str:
    async with Chrome() as browser:
        tab = await browser.start()

        async def run() -> None:
            async with tab.expect_cloudflare_turnstile(time_to_wait_captcha=8):
                await tab.go_to(url)

        try:
            await asyncio.wait_for(run(), timeout=ceiling)
        except asyncio.TimeoutError:
            # No load event fired inside the block, so the event was never set.
            await tab.go_to(url)
        return await tab.page_source()


asyncio.run(guarded_turnstile("https://example.org/gated"))

Pydoll reports success it cannot verify. _handle_cloudflare_turnstile ends by logging the last exception and returning; it never raises and never reports whether a click landed. The README example prints "Turnstile handled, continuing..." on the line after the block, which reads like confirmation and is not. Reaching that line only proves a load event fired. Assert on the page instead:

import asyncio

from pydoll import Chrome

CHALLENGE_MARKERS = ("challenges.cloudflare.com", "cf-turnstile", "Just a moment")


async def still_challenged(tab) -> bool:
    html = await tab.page_source()
    return any(marker in html for marker in CHALLENGE_MARKERS)


async def main() -> None:
    async with Chrome() as browser:
        tab = await browser.start()
        async with tab.expect_cloudflare_turnstile(time_to_wait_captcha=8):
            await tab.go_to("https://example.org/gated")

        await tab.wait_for_network_idle(idle_time=1.0, timeout=20)
        if await still_challenged(tab):
            raise RuntimeError("challenge still present after the handler returned")


asyncio.run(main())

Nodriver clicks even when there is no widget. template_location reads (min_v, max_v, min_l, max_l) out of cv2.minMaxLoc(match) and then uses max_l unconditionally. max_v, the correlation score, is read and discarded. Template matching always has a maximum, so on a page with no Turnstile widget the function returns the coordinates of whatever looked most like the template and verify_cf clicks there. On a dense page that is a stray link or a form field. Gate the call on the widget actually being present:

import asyncio

import nodriver as uc


async def verify_if_present(tab, timeout: float = 6.0) -> bool:
    """Only reach for verify_cf once the challenge frame is really on the page."""
    try:
        await tab.wait_for(text="verify you are human", timeout=timeout)
    except asyncio.TimeoutError:
        return False
    await tab.verify_cf()
    return True


async def main() -> None:
    browser = await uc.start()
    tab = await browser.get("https://example.org/gated")
    clicked = await verify_if_present(tab)
    print("clicked checkbox" if clicked else "no challenge widget found")
    browser.stop()


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

Nodriver's README documents a method name that does not exist. The README section is headed tab.cf_verify(). The implementation in nodriver/core/tab.py is async def verify_cf(...). The string cf_verify appears zero times in tab.py, connection.py, browser.py, element.py, util.py or __init__.py. Copy the README and you get an AttributeError, which is a long way to travel for a typo.

Nodriver writes fixed filenames into the working directory. template_location calls save_screenshot("screen.jpg") and opens cf_template.png with mode w+b, both relative paths, then unlinks them in a finally block. Two tabs or two processes verifying concurrently from the same working directory will overwrite each other's screenshot between the write and the cv2.imread. If you run concurrency, give each worker its own CWD, or pass a per-worker template_image path and accept that the screenshot name is still shared.

Why the wording is not pedantry

Three reasons the distinction between "clicked the checkbox" and "solved the captcha" is worth defending in writing.

The first is that the words end up in documents you do not control. Site terms of service describe circumventing a technological access-control measure very differently from describing automated access. Which category your pipeline falls into is a question for your own counsel, and nothing here is legal advice. The point is narrower and entirely practical: if your internal runbook, your pull request description or your sales page says "bypasses Cloudflare," that is the sentence that gets quoted back at you, and it describes something your code does not do. Pydoll's maintainer clearly understood this, which is why the note under the demo GIF exists.

The second is that an SLA built on the wrong verb fails silently. "We handle Turnstile" implies a capability you own. "We click the checkbox and Cloudflare usually accepts it, because our egress IPs have clean reputation and our fingerprints are coherent" implies a dependency you have to maintain. The first sentence produces no alerting. The second tells you what to monitor when success rates slide: IP reputation and fingerprint coherence, not the library.

The third is procurement. A captcha-solving service is a distinct product with distinct terms, and buying one is a decision with a paper trail. Treating a browser driver as if it were one skips that decision. If you genuinely need puzzle challenges answered, that is a separate purchase with separate questions, which we survey in best captcha solving services, alongside the usually cheaper alternative of better proxies instead of solvers.

API design: same protocol, opposite philosophies

Both libraries speak raw CDP over a WebSocket with no WebDriver binary in the path. They disagree about almost everything above that.

Pydoll 3.0.0 is the larger, more opinionated surface. It ships both a blocking and an async API generated from one source, so from pydoll import Chrome gives you asyncio and from pydoll.sync import Chrome gives you the same names without await. It is type-checked with mypy. It has a Pydantic-based extraction layer where you declare a model and call tab.extract_all(). It has tab.request for HTTP calls that inherit the live browser session. And it ships a Playwright compatibility layer: pydoll.playwright.sync_api and pydoll.playwright.async_api cover Browser, BrowserContext, Page, Frame, Locator, ElementHandle, Keyboard, Mouse, routes, dialogs and downloads, with expect() assertions, fixtures, tracing and the test runner explicitly out of scope. The README's claim is that "an existing Playwright script runs on it by changing one import," and the escape hatch for anything the layer does not cover is page.tab, the pydoll Tab underneath.

Nodriver is deliberately small. Its README states the goal as keeping things "short and simple, so you can quickly open an editor or interactive session, type or paste a few lines and off you go," with "most method parameters having best practice defaults." There is no sync API. There is no extraction layer. Element lookup is the centrepiece, and it has one genuinely clever behaviour: tab.find(text) does not return the first text match but ranks candidates "by closest matching text length (shortest wins)," so tab.find('accept all') returns the cookie button rather than a script tag in the head that happens to contain the phrase. It also ships tab.xpath(), tab.open_external_debugger() for inspecting a tab without dropping your connection, and tab.get_local_storage().

The same small job, written both ways:

# pydoll, async flavour
import asyncio

from pydoll import Chrome


async def grab_titles(url: str) -> list[str]:
    async with Chrome() as browser:
        tab = await browser.start()
        await tab.go_to(url)
        await tab.wait_for_network_idle(idle_time=0.5, timeout=20)
        cards = await tab.find(class_name="product-title", find_all=True)
        return [await card.text for card in cards]


print(asyncio.run(grab_titles("https://example.org/catalogue")))
# nodriver
import nodriver as uc


async def grab_titles(url: str) -> list[str]:
    browser = await uc.start()
    tab = await browser.get(url)
    await tab.select("div.product-title", timeout=20)
    cards = await tab.select_all("div.product-title")
    titles = [card.text for card in cards]
    browser.stop()
    return titles


print(uc.loop().run_until_complete(grab_titles("https://example.org/catalogue")))

Two details in those snippets matter more than they look. Pydoll's element text is awaited; nodriver's Element.text is a property on an already-materialised node. And nodriver has no equivalent of wait_for_network_idle, which is the subject of the next section.

Waits and event handling

This is where the libraries diverge most sharply, and where the gap will cost you the most hours.

Pydoll ships an expectation family. wait_for_url, wait_for_script, wait_for_absence, and wait_for_network_idle(idle_time=0.5, timeout=30) cover the usual conditions. On top of those sit async context managers that arm a listener before you trigger the action: expect_navigation, expect_request, expect_response, expect_download, expect_file_chooser and expect_cloudflare_turnstile. The pattern removes the classic race where you click, then start listening, and the response has already gone past.

Nodriver's wait surface is three methods, and one of them is not what its name suggests. tab.wait_for(selector="", text="", timeout=10) blocks until an element appears and raises TimeoutError otherwise, which is the useful one. tab.sleep(t=1) sleeps. And tab.wait(t=0.5), which reads like "wait for the page to settle," has this body:

async def wait(self, t: Union[int, float] = 0.5):
    return await self.sleep(t)

The lifecycle-event implementation sits directly below it, commented out, listing exactly the events it would have listened for: FrameStoppedLoading, FrameDetached, FrameNavigated, LifecycleEvent, LoadEventFired. So await tab.wait() is a fixed sleep wearing a condition's name. Nodriver also has no network-idle wait at all; the strings network_idle and networkidle appear zero times in tab.py. You build settle detection yourself, usually by polling a selector.

Event registration differs in a way that bites during refactors. Nodriver's add_handler(event_type: type, callback) keys on the parsed CDP event class and deduplicates by object identity: if callback not in self.handlers[event_type]. Removal is remove_handler(event_type, callback) and needs the same callable object you passed in, so an inline lambda or a freshly bound method can never be removed. Pydoll's await tab.on(event_name, callback, temporary=False) takes a string event name, returns an integer callback_id, and you remove it by that id, with temporary=True for one-shot handlers.

Capabilitypydoll 3.0.0nodriver 0.50.6
Element wait`find(..., timeout=)`, `wait_for_absence``wait_for`, `select(timeout=)`
URL wait`wait_for_url(pattern, timeout=30)`not present
Script predicate wait`wait_for_script(js, timeout=30)`not present
Network idle`wait_for_network_idle(idle_time=0.5, timeout=30)`not present
Arm-before-action`expect_navigation/request/response/download`not present
`wait()` semanticsno such methodalias for `sleep()`, lifecycle code commented out
Event handler keystring event nameparsed CDP event class
Handler removalby returned integer idby the identical callable object
One-shot handlers`temporary=True`hand-rolled

Licensing and dependency weight

The single largest practical difference between these two projects has nothing to do with captchas.

Pydoll is MIT. Nodriver is AGPL-3.0, reported identically by the GitHub API and by the license field on PyPI. For a team embedding a scraper into a hosted service, the AGPL's network-use clause is a question to put to counsel before the code ships, not after. This is not legal advice and the answer depends on how you deploy, but it is the first thing to check and the easiest to miss, because pip install asks no questions. If AGPL is a blocker for your deployment and you still want this family of tool, the fork landscape matters; we covered the fork question in zendriver vs nodriver, and the lineage from Selenium in nodriver vs undetected-chromedriver.

Install weight also differs. Pydoll requires Python 3.10 or newer and pulls aiofiles, aiohttp, pydantic 2.x, typing_extensions and websockets pinned >=14,<17. Nodriver requires Python 3.9 or newer and pulls mss, websockets>=14 and deprecated. Nodriver's tree is smaller, with one caveat already noted: verify_cf() needs opencv-python, which is not in its dependency list, so a fresh install has a documented feature that cannot run until you install a computer-vision library by hand.

Both drive an existing Chrome, Chromium, Edge or Brave install rather than downloading a browser. Neither needs Node.

Maintenance read from both repositories today

Every number below was read from the GitHub and PyPI APIs on 9 October 2026 and is accurate as of that date.

Pydoll: 7,108 stars, 410 forks, 21 open issues, MIT, repository created 27 October 2024, last push 9 October 2026 at 03:26 UTC. The latest release is 3.0.0, tagged 29 September 2026. pydoll-python has 65 releases on PyPI, the first being 1.2.0 on 8 March 2025, and the recent cadence is dense: 2.24.0 on 13 August, 2.25.0 on 22 August, 2.26.0 on 26 August, 2.27.0 on 16 September, 3.0.0 on 29 September 2026. The commit stream shows merged pull requests, named contributors and automated version bumps from a github-actions bot, which is the signature of an actual release process.

Pydoll's README also carries a maintainer notice that belongs in any honest maintenance assessment:

"A word from the maintainer. Pydoll is maintained by one person, and I'm a bit stretched at the moment, so new releases and replies to issues may take longer than usual. The project is not dead and it is not going anywhere; development continues at a calmer pace for now."

The same notice sets a conditional roadmap: "once the project reaches 10k stars, I plan to ship Firefox support."

Nodriver: 4,823 stars, 442 forks, 16 open issues, AGPL-3.0, repository created 20 February 2024, last push 8 October 2026 at 15:08 UTC. Two facts about its release process are worth knowing before you pin a version. The repository has zero GitHub releases and zero git tags, so there are no release notes and no tag to diff against; versioning exists only as PyPI uploads. And the cadence is bursty rather than steady: 46 PyPI releases in total, with 0.48.1 on 9 November 2025 followed by nothing until 0.50.1 on 11 May 2026, then 0.50.3 on 13 May 2026 followed by nothing until 0.50.4 on 5 October 2026. Two quiet stretches of roughly six and five months, then three releases in four days, ending at 0.50.6 on 8 October 2026.

Both projects are effectively one person. Pydoll says so in the README; nodriver's commit log shows it. Neither is abandoned: both were pushed to within the last 48 hours. The difference is process, not pulse. Pydoll has tags, release notes, a bot-driven bump and merged outside contributions. Nodriver has a maintainer who pushes directly and publishes to PyPI when something is fixed, with commit messages like "buxfig" and "bugfix server_worker after protocol update."

Plan for the single-maintainer reality in both cases: pin exact versions, keep a smoke test that runs your real target and not a demo site, and budget time for a Chrome release to break something.

Proxy wiring, where they genuinely diverge

Both accept a --proxy-server= flag, so unauthenticated proxies work identically. Authenticated ones do not.

Pydoll handles proxy credentials for you, and the implementation is tidier than most. ProxyManager scans options.arguments for --proxy-server=, parses embedded user:pass out of the value, and rewrites the argument in place without them, so the credentials never appear on the browser's command line where any local process listing would show them. It hands the pair back to the browser class, which calls _configure_proxy, enables the Fetch domain with handle_auth_requests=True, and answers Fetch.authRequired with Fetch.continueRequestWithAuth. You pass a URL and it works:

import asyncio

from pydoll import Chrome
from pydoll.browser.options import ChromiumOptions


async def main() -> None:
    options = ChromiumOptions()
    # Credentials are stripped from the argv and replayed over CDP instead.
    options.add_argument("--proxy-server=http://USER:PASS@gateway.sparkproxy.io:11000")
    options.webrtc_leak_protection = True

    async with Chrome(options=options) as browser:
        tab = await browser.start()
        await tab.go_to("https://www.sparkproxy.io/")
        print(await tab.title())


asyncio.run(main())

Nodriver has no equivalent. The strings auth_required and continue_with_auth appear nowhere in tab.py, connection.py, browser.py or config.py. You get the flag and nothing else, which means either you drive the Fetch domain yourself or you use an IP allowlist so no credentials are needed:

import nodriver as uc


async def main() -> None:
    # No built-in proxy auth, so this egress has to be IP-whitelisted.
    browser = await uc.start(
        browser_args=["--proxy-server=http://gateway.sparkproxy.io:11000"],
    )
    tab = await browser.get("https://www.sparkproxy.io/")
    print(await tab.evaluate("document.title"))
    browser.stop()


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

SparkProxy supports both authentication modes, so the choice is yours rather than the library's: IP whitelisting for nodriver, or user:pass for pydoll, over HTTP, HTTPS or SOCKS5. Plans start from $75/month in USD, include unlimited bandwidth, and bill on the plan and concurrency rather than per gigabyte, which matters here because a Chrome instance clicking through a challenge downloads a lot of bytes you would otherwise be metered on. The pool is 1M+ premium IPs across 80+ countries with random rotation or five-minute auto-rotation, on 1 Gbps ports, and there is a 24-hour free trial with no credit card. Rotation mechanics for either library are the same problem we walk through in how to rotate proxies in Python.

Full specification sheet

pydollnodriver
Package`pydoll-python``nodriver`
Version read today3.0.0 (29 Sep 2026)0.50.6 (8 Oct 2026)
Repository`autoscrape-labs/pydoll``ultrafunkamsterdam/nodriver`
Stars / forks / open issues7,108 / 410 / 214,823 / 442 / 16
Created27 Oct 202420 Feb 2024
Last push9 Oct 2026 03:26 UTC8 Oct 2026 15:08 UTC
LicenceMITAGPL-3.0
GitHub releases / tags6+ releases, tags present0 releases, 0 tags
PyPI releases6546
Python>=3.10,<4>=3.9
Runtime depsaiofiles, aiohttp, pydantic, typing_extensions, websocketsmss, websockets, deprecated
Sync APIyes (`pydoll.sync`)no
Playwright compat layeryes, browser-automation surface onlyno
Structured extractionPydantic models, `extract_all()`no
Turnstile helper`expect_cloudflare_turnstile()``verify_cf()`
Turnstile mechanismshadow-DOM traversal over CDPOpenCV template match on a screenshot
Documented Turnstile limitsyes, in four placesnone
reCAPTCHA / hCaptchaexplicitly unsupportedno helper
Fingerprint injection`apply_fingerprint()`, bring your own profilenot provided
Network-idle waityesno
Proxy auth (`user:pass`)automatic via Fetch domainnot implemented
BrowsersChrome, EdgeChrome, Chromium, Edge, Brave

Choosing one, and when to run neither

Pick pydoll if you are writing something that has to be maintained by more than one person. The typed surface, the sync option, the expectation-based waits, the automatic proxy auth and the honest documentation all reduce the number of things a new teammate has to discover by reading source. The Playwright compatibility layer is also the cheapest migration path that exists in this space if you already have Playwright scripts, with the caveat that the test-runner half of Playwright is out of scope.

Pick nodriver if you want the smallest possible thing that drives Chrome and you are comfortable reading source when behaviour surprises you. Its element lookup is better than pydoll's for text-driven interaction, open_external_debugger() is genuinely useful during development, and the AGPL is irrelevant for internal tooling. Budget for building your own settle detection and your own proxy auth.

Pick neither if the job is fetch-and-parse at volume. A browser costs hundreds of megabytes of RAM per instance to render a page you are going to throw away after extracting six fields, and no amount of checkbox clicking fixes a flagged IP. A managed endpoint moves the browser, the rendering and the retry logic off your machine. With SparkProxy's Scraping API that is one call against https://scrape.sparkproxy.io/api/v1 with an X-API-Key header, and the account comes with 1,000 free credits to test on:

import base64
import os

import requests

BASE = "https://scrape.sparkproxy.io/api/v1"


def fetch(url: str, render: bool = True) -> tuple[int, str]:
    params = {
        "url": url,
        # render_js defaults to true and costs 5 credits; false is a 1-credit plain fetch.
        "render_js": "true" if render else "false",
        "proxy_type": "premium",
        "country_code": "us",
        # json_response is the ONLY source of the envelope, and body arrives base64.
        "json_response": "true",
    }
    reply = requests.get(
        BASE,
        params=params,
        headers={"X-API-Key": os.environ["SPARKPROXY_API_KEY"]},
        timeout=120,
    )
    reply.raise_for_status()
    envelope = reply.json()
    html = base64.b64decode(envelope["body"]).decode("utf-8", errors="replace")
    print("credits:", envelope["credits_used"], "ms:", envelope["duration_ms"])
    return envelope["status_code"], html


status, html = fetch("https://example.org/gated")
print(status, len(html))

Two things about that snippet are easy to get wrong, and both have shipped as bugs before. format is content conversion only, so html|md|mdx|json|screenshot|pdf changes what the body contains and never gives you the envelope; json_response=true is what produces job_id, status_code, duration_ms, credits_used and body. And that body is base64, so decode before you match any marker against it. If you want fields rather than a document, extract_rules replaces the response with an extracted object, and you do not stack it with json_response. Where the line between a browser and a managed endpoint falls economically is laid out in web scraping API vs self-managed proxies, and the async patterns for driving either at concurrency are in async web scraping in Python.

The short version: the captcha helper is not the deciding factor between these two libraries, and any comparison that leads with it is selling you the least durable feature. Choose on licence, on waits, and on whose source you would rather read at 2am.

Frequently asked questions

FAQ

No. Pydoll's own documentation says "Treat this as automating the click, not defeating the captcha," and that it "does not solve image or puzzle challenges." It finds the Turnstile widget's shadow root, clicks the checkbox inside it, and Cloudflare decides based on your IP reputation and fingerprint.

Yes, more than most comparisons admit. Nodriver's README documents a Cloudflare checkbox helper that uses an OpenCV template match on a screenshot. Note that the README calls it tab.cf_verify() while the actual method is verify_cf(), and opencv-python is not a declared dependency.

Neither wins on the captcha path, because both just click a checkbox. Pydoll is the safer pick overall for its documented limits, network-idle waits and automatic proxy authentication. Your IP reputation and fingerprint coherence decide the outcome far more than the library does.

It can be, and it is worth checking before you ship rather than after. Nodriver is AGPL-3.0 while pydoll is MIT, and the AGPL's network-use clause is the specific part to raise with your own counsel if you embed a scraper in a hosted service.

Partly. Pydoll ships pydoll.playwright.sync_api and pydoll.playwright.async_api, covering Browser, BrowserContext, Page, Frame, Locator, ElementHandle, Keyboard, Mouse, routes, dialogs and downloads. The expect() assertions, fixtures, tracing and the pytest runner are explicitly out of scope.

Both were pushed to within 48 hours of 9 October 2026. Pydoll released 3.0.0 on 29 September 2026 and carries a maintainer notice that it is a one-person project running at "a calmer pace." Nodriver shipped 0.50.6 on 8 October 2026 but publishes no GitHub releases or tags, and had quiet gaps of roughly six and five months during the preceding year.

Special Discount ยท 20% off

Get 20% off your first month

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

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

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates the proxy and scraping infrastructure behind SparkProxy: residential, ISP, datacenter and mobile pools spanning 1M+ IPs in 80+ countries, plus the SparkProxy Scraping API. We spend our days watching real scrapers hit real anti-bot systems at volume, which is why posts like this one quote source files and repository timestamps instead of benchmark claims we cannot reproduce. Every figure above was read from the projects' own GitHub repositories, PyPI metadata and documentation on 9 October 2026, and the URLs are in the text so you can check them against what the projects say today.

Keep reading

Related articles

Insomniac Browser Alternatives, Scored by Layer

Insomniac Browser alternatives scored by which identity layer each one really covers, with prices verified in October 2026 and a measured three-profile test.

SparkProxyยทComparisons