🎉 Premium Proxies · 24-Hour Free TrialClaim Now
Comparisons

Zendriver vs Nodriver: Which Fork Should You Bet On?

Zendriver vs nodriver decided on fork risk, not features: commit dates, release tags, a measured code diff, and what migrating back actually costs you.

S SparkProxy 5 25 min read
Share

Picking between zendriver vs nodriver is not a feature comparison. They are the same program, forked, and the real question is which copy will still be getting fixes in eighteen months when Chrome 160 breaks something in your pipeline. This post answers that from repository evidence read on 9 October 2026: commit dates, release tags, a line-level diff of the shared modules, open-issue ages, and the exact API breaks you would hit migrating in either direction. Detection rates are deliberately out of scope, because we already took that apart in undetected-chromedriver vs Patchright vs Camoufox.

Why the fork exists, briefly

zendriver was created in October 2024 as a fork of nodriver. Its README gives three reasons: new features, collecting bugfixes that were never merged upstream, and community engagement, with a note that submitting issues and pull requests to the original repo is difficult. We quoted those lines in full in nodriver vs undetected-chromedriver, so there is no point repeating them here. What matters for a betting decision is whether two years of that stated intent produced anything measurable, and whether the split has hardened into two incompatible codebases or stayed a thin patch set.

The lineage runs undetected-chromedriver, then nodriver, then zendriver, all from the same original author's work. undetected-chromedriver itself is instructive as a worked example of how this ends: 12,863 stars, 1,141 open issues, last push 5 July 2025, and still not archived. A repository can go fifteen months without a commit and never put up a sign saying so. That is the failure mode fork selection exists to avoid, and the reason none of the numbers below come from a project's own description of itself.

nodriverzendriver
Repositoryultrafunkamsterdam/nodrivercdpdriver/zendriver
Created20 Feb 202419 Oct 2024
LicenceAGPL-3.0AGPL-3.0
Stars / forks4,823 / 4421,455 / 108
Watchers9920
Latest PyPI version0.50.6, 8 Oct 20260.17.1, 2 Oct 2026
Last commit to main8 Oct 20262 Oct 2026
Published GitHub releases045 since Oct 2024, 9 in the last 12 months
Python floor>=3.9>=3.10
Runtime dependencies36
Files in repo438166
Test suite in repononepytest, 12 core test modules
Docs siteGitHub Pages, Sphinxzendriver.dev, MkDocs

Both are alive. Anyone who tells you one of these is abandoned in October 2026 has not checked, and that is the first trap: on the day we looked, nodriver had shipped a release more recently than zendriver had.

Twelve months of commits, side by side

Latest-commit dates are the worst possible maintenance signal, because one commit erases a year of silence. Here is the shape of the last twelve months instead, pulled from the commits API with since=2025-10-09.

Monthnodriver commitszendriver commits
Oct 202584
Nov 2025110
Dec 202500
Jan 202600
Feb 202601
Mar 202604
Apr 202604
May 202660
Jun 202602
Jul 202602
Aug 202605
Sep 2026023
Oct 2026 (to the 9th)33
**Total****18****58**

nodriver works in bursts: eight commits in late October 2025, one in November, nothing at all for five months, six on two days in May 2026, then nothing for four more months until the protocol refresh on 5 October 2026. Every gap is longer than a Chrome stable release cycle. zendriver has its own quiet stretches, including a dead December and January, but it has shipped something in nine of the last twelve months.

The authorship split matters more than the totals. Of nodriver's 18 commits, 16 are the owner's and 2 are from one outside contributor. All-time, the contributors API lists exactly three people for nodriver, with 146, 2 and 2 commits. zendriver's window has 19 distinct authors, and all-time it lists 35 contributors, led by the maintainer at 223 commits. Its number two contributor, at 46 commits, is nodriver's author, inherited with the forked history.

So the bus factor is one in both cases, and anyone picking zendriver because "it has a community" should look at the distribution rather than the headcount: 32 of 58 recent commits are one person's, and 12 of the other 18 authors contributed exactly one commit each. The difference is not that zendriver has escaped single-maintainer risk. It is that zendriver has a second channel, 26 outside commits in twelve months, for fixes that would otherwise die in a queue.

Release discipline is the sharper split. zendriver has 46 git tags and 45 published GitHub releases behind them, from v0.1.0 on 28 October 2024 to v0.17.1 on 2 October 2026, and keeps a CHANGELOG.md written to the Keep a Changelog format where each entry names the bug and usually the contributor who found it. nodriver has published zero GitHub releases in its entire history. Its 46 PyPI uploads are the only version record, and reconstructing what changed means reading commit messages such as buxfig and 0.5.1. If your dependency policy requires a human-readable changelog before a bump, nodriver cannot satisfy it and zendriver can.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

What has actually diverged in the code

Feature tables hide the thing that decides migration cost, so we measured it. We pulled the seven shared core modules from both default branches on 9 October 2026, nodriver at 0.50.6 and zendriver at 0.17.1, and diffed them file by file. These are raw added and removed line counts from diff, not a semantic patch count, so treat them as a divergence magnitude rather than a change log.

Shared modulenodriver lineszendriver lineslines addedlines removed
`browser.py`907989520438
`config.py`320457240103
`connection.py`5641,003902463
`element.py`1,2051,209402398
`tab.py`2,0821,9739651,074
`util.py`5,063369944,788
`_contradict.py`1421241836
**Total****10,283****6,124****3,141****7,300**

Two years after the fork, there is no file left that you could call lightly patched. connection.py nearly doubled: zendriver's version defines Transaction as a generic asyncio.Future subclass and adds EventTransaction, a Listener class, a CantTouchThis metaclass and a SettingClassVarNotAllowedException, none of which exist upstream. element.py lands within four lines of upstream's length and still shows 402 lines added and 398 removed, which is the signature of a rewrite rather than a patch.

zendriver also split five new modules out of core, with no upstream counterpart:

zendriver-only moduleLinesWhat it holds
`keys.py`595`KeyEvents`, `SpecialKeys`, `KeyPressEvent`, `KeyModifiers`, exported at package top level
`proxy.py`341`UpstreamProxy` and `ProxyForwarder` for credentialed proxies
`cloudflare.py`269the Turnstile challenge helpers
`expect.py`236request and response expectation helpers
`intercept.py`171request interception

That is 1,612 lines of code that simply has no address in nodriver. It also explains the dependency gap: emoji and grapheme are there for text input handling, and asyncio-atexit for shutdown, which is why zendriver carries six runtime dependencies against nodriver's three.

The Cloudflare helper is two different programs

The util.py row above, 5,063 lines upstream against 369 in the fork, looks like a packaging artifact. It is not. It is the single largest behavioural divergence between the two projects, and it sits on the feature most people install either library for.

nodriver solves the Turnstile checkbox by image template matching. nodriver/core/util.py contains a function get_cf_template() returning a bytearray, and the literal it returns runs from roughly line 437 to line 4,610 of that file. Its docstring says the quiet part out loud: "this returns a template image (of a checkbox)... didn't bother the hassle to include image files in my package." tab.verify_cf() calls tab.template_location(), finds that bitmap in the current viewport, and clicks the centre. The docstring notes the bundled template is English only, and that other languages need you to crop your own 111x71 image. It also raises an exception outright if expert mode is enabled.

zendriver deleted all of that and reads the DOM instead. cloudflare.py waits for the interactive challenge, resolves the shadow-root host and the challenge iframe, asks CDP for the box model with cdp.dom.get_box_model, and clicks computed coordinates, raising TimeoutError if the challenge never appears.

Neither approach is endorsed here as more or less detectable, and we have not measured that. The point is that their failure modes are disjoint. A visual change to Cloudflare's widget, a different locale, a scaled viewport or an unusual device pixel ratio breaks a bitmap match. A markup change breaks a selector walk. zendriver's 0.17.0 notes record exactly that second failure happening and being fixed: verify_cf() never clicked the checkbox on Turnstile markup where the response input was rendered as a document-level sibling of the shadow-DOM host rather than nested inside it, so the scoped lookup always found nothing.

The signatures are mutually exclusive, which is the part that bites during a migration:

async def solve_nodriver(tab):          # nodriver 0.50.6
    await tab.verify_cf(template_image="de_checkbox.png", flash=True)


async def solve_zendriver(tab):         # zendriver 0.17.1
    await tab.verify_cf(click_delay=5, timeout=15, challenge_selector=None)

template_image and flash do not exist in zendriver. timeout, click_delay, challenge_selector and flash_corners do not exist in nodriver, and neither does tab.template_location(). A bare await tab.verify_cf() is the only call that works on both. If you need the mechanics of this challenge rather than the API shape, see how to bypass Cloudflare Turnstile.

Where upstream is still ahead

The release-discipline evidence all points one way, so it is worth being deliberate about the places it does not.

nodriver regenerated its CDP bindings on 5 October 2026, with a commit titled updated protocol followed the same day by bugfix server_worker after protocol update. Counting generated domain modules on both default branches today gives 62 for nodriver and 58 for zendriver. Four domains exist only upstream:

CDP domain modulenodriver 0.50.6zendriver 0.17.1
`cdp/ads.py`yesabsent
`cdp/database.py`yesabsent
`cdp/digital_credentials.py`yesabsent
`cdp/find_in_page.py`yesabsent

Nothing exists in zendriver's cdp/ that is missing upstream. If your work needs a domain from that list, or a command or event added to any domain in the last protocol refresh, the fork cannot run it and the typed bindings will not be there to import. Both projects generate these from Chrome's protocol JSON, so this gap closes whenever zendriver reruns its generator, but on the day you choose it is real.

The second honest point in nodriver's favour is the Python floor. nodriver declares >=3.9 and classifiers down to 3.7; zendriver declares >=3.10. On a locked RHEL or Debian box with a system Python 3.9, the fork is simply not installable and the argument ends there.

Third, nodriver has a BrowserContext class exported at package top level and a new_tab parameter on create_context(). zendriver has neither. More on what that costs below.

The patch flow runs one way

The question that decides long-term fork risk is whether the split ever heals. Here the evidence is unambiguous, and it comes from both projects at once.

zendriver pulls upstream in, on purpose and in bulk. Its 0.17.0 notes describe the release as including "ports for all nodriver features and bugfixes that have been added upstream since Zendriver was forked". So the fork treats upstream as a source to be harvested, and as of late September 2026 it claims to have caught up completely.

Nothing travels the other way. nodriver's all-time contributor list has three entries and zendriver's maintainer is not one of them. Meanwhile zendriver's contributor list has nodriver's author at number two, by inheritance. The graph is a one-way street with a historical stub.

What makes this more than a governance footnote is that upstream's own bug queue now trails the fork. Three examples, all open on nodriver today:

nodriver PROpenedStill openFork status
#34, `KeyError 'sameParty'` in cookie handling31 Mar 2026192 dayszendriver shipped a fix for the same missing-field crash in 0.15.3, released 12 Mar 2026
#40, relative drag coordinates and held mouse button8 Oct 20261 dayzendriver reworked mouse button and pressure state in 0.17.0, released 27 Sep 2026
#39, `scroll_bottom_reached` with boolean results1 Oct 20268 dayszendriver added `Tab.is_scrolled_to_bottom()` in 0.17.0

The first row is the striking one. The fork released a fix for that cookie crash nineteen days before the corresponding patch was even proposed upstream, and the upstream proposal is still sitting there six months later. We are not claiming nodriver is still broken in each case, since its May and October commits touched cookies and the protocol and may have fixed some of these independently; an open PR proves the patch was not merged, not that the bug survives. What it does prove is the direction of travel. Fixes appear in the fork first, and the upstream queue is where they go to wait.

For a maintainer choosing a side, that reverses the usual default. Betting on upstream normally means betting on the place fixes land first. Here it does not.

Reading issue counts without being fooled

Issue and PR counts are the most-quoted fork-health numbers and the easiest to misread. Both repos demonstrate a different way of being misleading.

Tracker metric, 9 Oct 2026nodriverzendriver
Open issues857
Open pull requests80
Oldest open issue393 days696 days
Median open-issue age376 days490 days
Open issues older than one year546
Open issues with zero comments5 of 817 of 57
Newest open issue31 Mar 202615 Jul 2026
Oldest open PR765 daysn/a
Median open-PR age192 daysn/a

Read naively, nodriver wins: eight open issues against 57. Read properly, nodriver's tracker is small because almost nothing enters it and little leaves, and its PR queue is the tell. Pull requests #7 and #8 have been open since 4 September 2024, 765 days, with zero comments.

zendriver's zero open PRs are not pure merge velocity either, and this is the measurement trap worth internalising. Its .github/workflows/close-inactive-issues-prs.yml runs actions/stale@v9 nightly with days-before-pr-stale: 30 and days-before-pr-close: 7, and that PR rule carries no label filter. Any pull request idle for 37 days closes itself. The issue half of the same job is scoped by only-issue-labels: "question", which is why the issue backlog is allowed to reach 46 items over a year old while the PR count sits at zero. An empty PR queue in that repo partly reflects a cron job, not a reviewer.

The honest summary is that zendriver's tracker is open, noisy and badly groomed, while nodriver's is quiet because the door is heavy. Neither is a well-run tracker. If you are comparing forks on this axis, read the stale-bot configuration before you read the counts, and check when the newest open issue was filed: a tracker with nothing recent in it is telling you something about who is allowed to file, not about code quality.

What a migration costs, in both directions

The import name is the cheap part. import nodriver as uc becomes import zendriver as zd, and simple scripts that only call start(), browser.get() and tab.select() port with a find and replace. Past that, four breaks are real and none of them is a rename.

One, Browser is no longer a Connection. nodriver declares class Browser(Connection), so the browser object inherits send() and CDP commands can be issued straight at it. zendriver declares a standalone class Browser holding a self.connection, with no attribute passthrough. Any line that sends a CDP command to the browser object breaks, and it breaks with an AttributeError at runtime rather than at import.

import nodriver as uc
import zendriver as zd


async def targets_nodriver(browser):
    # Browser inherits from Connection, so send() is on the browser itself
    return await browser.send(uc.cdp.target.get_targets())


async def targets_zendriver(browser):
    # the same call has to go through the connection the browser holds
    return await browser.connection.send(zd.cdp.target.get_targets())

Two, create_context() signatures differ in shape, not just defaults. Both projects grew per-context proxy support, independently, and landed on different APIs. nodriver takes positional arguments and has a new_tab flag; zendriver makes everything after url keyword-only and dropped new_tab. The default URL differs too, chrome://welcome upstream against about:blank in the fork, which changes what the first navigation event looks like.

PROXY = "http://user:pass@gateway.sparkproxy.io:11000"


async def context_nodriver(browser):
    # 0.50.6: positional args accepted, new_tab exists, default url is chrome://welcome
    return await browser.create_context(
        "https://www.sparkproxy.io/pricing", True, True, True, proxy_server=PROXY
    )


async def context_zendriver(browser):
    # 0.17.1: everything after url is keyword-only, no new_tab, default url is about:blank
    return await browser.create_context(
        "https://www.sparkproxy.io/pricing", new_window=True, proxy_server=PROXY
    )

Three, the top-level exports are not the same set. nodriver exports BrowserContext and ProtocolException from the package root. zendriver's __all__ contains neither, and adds cdict, Connection, KeyEvents, SpecialKeys, KeyPressEvent and KeyModifiers. So except nodriver.ProtocolException has to become except zendriver.core.connection.ProtocolException, and nodriver.BrowserContext has no fork equivalent at all.

Four, verify_cf() keyword arguments are mutually exclusive, as shown earlier, and tab.template_location() exists only upstream.

Migrating back, from the fork to upstream, is the harder direction and the one nobody plans for. Going that way you lose the 1,612 lines in the five fork-only modules, so every KeyEvents or SpecialKeys import, every expect.py helper, every intercept.py call and every typed proxy object has to be rewritten against upstream equivalents that in some cases do not exist. You also lose py.typed: zendriver ships a type marker and runs mypy in strict mode, nodriver ships neither, so a type-checked codebase starts failing the moment it imports the untyped package.

The practical move, if you are not sure which way you will go, is to stop importing either package directly:

# browser_backend.py: one import site, so a switch is a one-line change
from __future__ import annotations

import os
from typing import Any

BACKEND = os.environ.get("CDP_BACKEND", "zendriver")

if BACKEND == "zendriver":
    import zendriver as cdp_driver
else:
    import nodriver as cdp_driver


async def new_browser(**kwargs: Any) -> Any:
    """start() has the same name in both packages, so keep the difference here."""
    return await cdp_driver.start(**kwargs)


async def send_to_browser(browser: Any, command: Any) -> Any:
    """Browser is a Connection upstream and holds one in the fork."""
    conn = getattr(browser, "connection", None)
    if conn is not None:
        return await conn.send(command)
    return await browser.send(command)

That shim is twenty lines and it makes the bet reversible, which is worth more than picking correctly.

Defaults that change behaviour silently

Two configs that accept similar arguments can still launch different browsers. nodriver's Config.__init__ takes nine parameters. zendriver's takes fifteen. The six extra options are not the interesting part; the changed defaults are, because they alter what a target site observes without appearing anywhere in your code.

`Config` optionnodriver 0.50.6zendriver 0.17.1
`lang``"en-US"` by default`None` by default
`disable_webrtc`no such option`True` by default
`disable_webgl`no such option`False`
`user_agent`no such option`None`, sets `--user-agent` when given
`browser`no such option`"chrome" \"brave" \"msedge" \"auto"`
`browser_connection_timeout`no such option`0.25` seconds
`browser_connection_max_tries`no such option`10`

The disable_webrtc default is the one to know about if you run a proxy. zendriver sets it to True out of the box, and when it is on, config.py appends --webrtc-ip-handling-policy=disable_non_proxied_udp and --force-webrtc-ip-handling-policy to the Chrome command line. nodriver has no WebRTC option anywhere in its config module, so it launches Chrome with stock WebRTC behaviour and your pages keep whatever UDP path Chrome chooses.

For a browser behind an HTTP or SOCKS5 proxy that carries only TCP, those two flags are the difference between WebRTC being confined to the proxied path and WebRTC being free to use a direct route. Switching from zendriver to nodriver therefore silently turns that off. If you move that way, add the flags back yourself:

import nodriver as uc

cfg = uc.Config(headless=False, lang="en-US")
# zendriver sets these two by default via disable_webrtc=True; nodriver has no equivalent option
cfg.add_argument("--webrtc-ip-handling-policy=disable_non_proxied_udp")
cfg.add_argument("--force-webrtc-ip-handling-policy")

lang cuts the other way. nodriver defaults it to "en-US" and always passes --lang; zendriver defaults it to None and passes nothing unless you ask. Moving a scraper from nodriver to zendriver without setting lang drops a flag that was previously always present, and the browser falls back to the host locale. On a rented box in Frankfurt scraping a US site, that is a mismatch you did not write and will not see in your diff. Both projects also refuse add_argument("--lang=…") and make you use the attribute, so the flag has exactly one legitimate source.

Neither default is wrong. The lesson generalises: when you evaluate a fork, diff its config module against upstream's and read the default values, not just the parameter names. Argument-level divergence is invisible in the API surface and visible to every site you hit. The same discipline applies to user-agent rotation, where a header you never set is still a header you are sending.

Fork-selection criteria you can reuse

Strip out the names and this is a repeatable audit. Nine questions, in the order that actually discriminates, and all nine answerable in about ten minutes from the API.

  1. Commit distribution over twelve months, never the latest commit date. Count per month. A repo with 18 commits in three bursts and one with 58 across nine months can have identical last-commit dates.
  2. Are there published release tags with notes? A changelog is a maintenance artifact that costs real effort, so its presence predicts future effort better than any star count.
  3. Who are the top three contributors and what share do they hold? Both projects here have a bus factor of one. The fork's advantage is a second intake channel, not a second maintainer.
  4. Is divergence additive or structural? New modules beside untouched shared ones are cheap to leave. Rewritten shared internals, like a connection.py that doubles in size, mean the fork is now its own project.
  5. Diff the public exports both ways. __all__ on each side, as sets. Anything only upstream is a migration-back cost; anything only in the fork is lock-in.
  6. Diff the config defaults, not the parameter list. This is where silent behaviour changes hide.
  7. Which side has fresher generated code? For CDP wrappers, count the generated domain modules. The fork is not automatically the current one.
  8. Read the stale-bot config before the issue counts. An auto-closer with no label filter on PRs makes an empty queue meaningless.
  9. Does anything merge upstream? Check whether the fork maintainer appears in upstream's contributor list. If not, assume the split is permanent and price the migration both ways.

Applied to this pair, the answer is that zendriver is the better bet for new work on Python 3.10 or newer, on release discipline, test coverage, changelog quality and the fact that fixes land there first. nodriver is the right call on a Python 3.9 floor, if you need one of the four CDP domains the fork lacks, or if your code leans on BrowserContext or the image-template Cloudflare path. Write the shim either way. These two have been diverging for two years and the lines moved in that time, 3,141 added and 7,300 removed across seven files, are the real cost of changing your mind later.

Wiring either one to a proxy pool

Both libraries drive a real Chrome, so both inherit Chrome's limitation of one proxy per browser process unless you use browser contexts, and neither can pass proxy credentials through --proxy-server. Each solves that with a local forwarder: zendriver in proxy.py with UpstreamProxy and ProxyForwarder, nodriver in a ProxyForwarder class at the end of util.py. The API is create_context() on both, with the signature differences covered above.

A SparkProxy residential endpoint wires in like this. Plans start from $75/month as packs rather than per-gigabyte, with unlimited bandwidth included, so the billing unit is the plan and its concurrency rather than the traffic a render burns. That matters for browser automation, where a single page load can pull several megabytes of assets you did not ask for.

import asyncio

import zendriver as zd

PROXY = "http://USER:PASS@gateway.sparkproxy.io:11000"


async def main() -> None:
    cfg = zd.Config(headless=False, lang="en-US", disable_webrtc=True)
    browser = await zd.start(config=cfg)
    try:
        # one context per exit IP; each gets its own proxy
        tab = await browser.create_context("about:blank", proxy_server=PROXY)
        await tab.get("https://www.sparkproxy.io/")
        await tab.wait_for(selector="h1", timeout=20)
        print(await tab.evaluate("document.title"))
    finally:
        await browser.stop()


asyncio.run(main())

Rotation strategy is unchanged by the fork question: 1M+ premium IPs across 80+ countries, random rotation or five-minute auto-rotation, HTTP, HTTPS and SOCKS5, IP whitelist or user:pass auth, 1 Gbps port speed. The patterns for spreading work across contexts are the same ones in how to rotate proxies in Python, and the async structure is the same as in async web scraping in Python.

There is a third option that makes the whole zendriver vs nodriver question moot for a lot of jobs, and it belongs in an honest comparison: do not maintain a stealth browser at all. The SparkProxy Scraping API renders the page server-side, so the fork you bet on becomes our problem rather than yours.

import base64

import requests

API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_KEY"}


def fetch(url: str) -> tuple[int, str]:
    """render_js defaults to true and costs 5 credits; json_response gives the envelope."""
    r = requests.get(
        API,
        headers=HEADERS,
        params={
            "url": url,
            "render_js": "true",
            "proxy_type": "premium",
            "country_code": "us",
            "json_response": "true",
        },
        timeout=120,
    )
    r.raise_for_status()
    env = r.json()
    # the envelope body is base64; decode before matching anything in it
    html = base64.b64decode(env["body"]).decode("utf-8", "replace")
    print(env["job_id"], env["status_code"], env["duration_ms"], env["credits_used"])
    return env["status_code"], html


if __name__ == "__main__":
    code, html = fetch("https://www.sparkproxy.io/")
    print(code, len(html))

Two things to get right there, because both are easy to get wrong. json_response=true is the only source of that envelope, with job_id, status_code, duration_ms, credits_used and body; format is content conversion only and takes html, md, mdx, json, screenshot or pdf. And the envelope body is base64, so decode it before you match a marker against it. Pricing is per feature: render_js=true is 5 credits and is the default, render_js=false is a plain fetch at 1 credit, country_code adds 5, js_scenario adds 5 and needs render_js=true with at most 50 steps. Batch mode requires render_js=false and the whole batch costs 1 credit. There are 1,000 free credits to test with, and a 24-hour free trial on the proxy plans with no card.

Frequently asked questions

FAQ

For scripts that only call start(), browser.get() and element lookups, yes, after changing the import. It stops being drop-in at four places: Browser no longer inherits from Connection so browser.send() must become browser.connection.send(), create_context() is keyword-only and has no new_tab, BrowserContext and ProtocolException are not exported from the package root, and verify_cf() takes different keyword arguments.

On the twelve months to 9 October 2026, zendriver had 58 commits across nine months, 19 distinct authors and 9 tagged releases with a written changelog. nodriver had 18 commits in three bursts, 16 of them from one person, and zero published release tags in its history. Both shipped a release in the first week of October 2026, so neither is abandoned.

Yes, but it is the more expensive direction. You give up the 1,612 lines in zendriver's five fork-only modules, keys.py, proxy.py, cloudflare.py, expect.py and intercept.py, so any import from those needs rewriting. You also lose the py.typed marker, which breaks strict type checking, and you lose zendriver's disable_webrtc default unless you add the two Chrome flags back by hand.

No. zendriver ports upstream changes into itself, and its 0.17.0 notes claim it had caught up on all upstream features and bugfixes as of late September 2026. Nothing goes the other way: nodriver's all-time contributor list has three entries and zendriver's maintainer is not among them.

Because nodriver regenerated its bindings from Chrome's protocol on 5 October 2026 and zendriver has not yet rerun its generator. Counted on 9 October 2026, nodriver has 62 generated domain modules and zendriver 58, with ads, database, digital_credentials and find_in_page upstream only. The gap closes whenever the fork regenerates.

Not in one virtualenv for the same job, since both drive Chrome over CDP and you gain nothing but two Chrome launch paths to debug. Pinning both behind a one-module shim is worth it, because the import name is the only thing a backend switch should touch, and it keeps your choice reversible while these two keep diverging.

Special Discount · 20% off

Get 20% off your first month

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

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

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter proxies, residential proxies, and Scraping API. We run our own headless rendering fleet, so fork risk in the CDP ecosystem is an operational question for us before it is a blog topic. Every number here was read on 9 October 2026 from primary sources: the GitHub REST API for commits, contributors, releases, issues and trees, the PyPI JSON API for versions and upload dates, and the two projects' own pyproject.toml, CHANGELOG.md, README.md and core/ source files on their default branches. The line-level divergence table is our own diff of the seven shared core modules at nodriver 0.50.6 against zendriver 0.17.1, run that day. We did not measure detection rates, block rates or speed for either library, and we do not repeat anyone else's. Corrections welcome at support@sparkproxy.io.

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