We ran a small stealth browser benchmark on 3 October 2026: plain Playwright as the control, then Patchright, nodriver, Camoufox and Zendriver, all against public bot-detection test pages, once from a Hetzner datacenter IP and once through a US residential proxy. The browser decided every automation verdict. Plain Playwright was flagged by deviceandbrowserinfo, BrowserScan, Fingerprint’s playground and Pixelscan in every run from both IPs. Run headful, the four stealth tools passed the first three in every run that produced a verdict, and Pixelscan flagged 2 of their 39 headful runs (2 more were n/a). The residential IP only moved the checks that look at the network, and not always in the right direction.
That's 54 recorded runs from one host and one proxy country, on pages built for testing, not a ranking of anyone's production anti-bot. Below: the method, the raw verdicts, what the data does not support, and the code to rerun it.
Stealth browser benchmark: the short answer
If the browser leaks automation, a residential IP doesn't save it. If the browser is clean, the IP mostly affects network-side checks: proxy detection, timezone consistency and WebRTC. On these pages that cut both ways:
- Automation verdicts didn't change with the IP. Playwright got
isBot: trueon deviceandbrowserinfo, “WebDriver: Robot” on BrowserScan and “bad bot (type: webdriver)” on Fingerprint’s playground in all 10 of its headful runs, from both networks. Patchright, nodriver, Zendriver and Camoufox got none of those flags in any headful run. - Pixelscan cared about the datacenter IP. It printed “Proxy detected” in 20 of 20 runs from the Hetzner IP, even though those runs used no proxy at all. Through the residential proxy it printed that in 3 of 34 runs.
- Fingerprint’s playground cared about the residential IP. Its proxy signal flagged 33 of 34 residential runs, often naming a proxy network (“residential proxy from SOAX”, “from INFATICA”, and so on). It didn't flag the Hetzner IP for the Chrome-based tools. For the three Chrome-based stealth tools, suspect scores were 0–6 from the datacenter and 8–24 through the sticky residential proxy.
- Three of the five tools exposed the server’s real IP over WebRTC through the proxy. Playwright, Patchright and nodriver did in every proxied run. Zendriver blocks WebRTC by default and Camoufox spoofs it.
- Headless mode mattered more than the IP. In headless mode, Patchright and nodriver got flagged on the same pages they passed headful.
What we ran: versions, network conditions, targets
Everything ran in one Debian 12 container with Xvfb, one browser at a time, on a 4-core Linux host. Each tool used the launch config its own docs recommend and nothing more: no custom user agent, no fingerprint injection, no mouse movement, no resource blocking. For Patchright that's the README’s “Best Practice” config: a persistent context, real Chrome, headful, no_viewport=True.
| Tool | Package | Browser | Launch |
|---|---|---|---|
| Playwright (control) | playwright 1.62.0 | Playwright Chromium 151.0.7922.34 | chromium.launch(headless=False), defaults |
| Patchright | patchright 1.63.0 | Google Chrome 154.0.8037.97 | launch_persistent_context(channel="chrome", headless=False, no_viewport=True) |
| nodriver | nodriver 0.50.3 | Google Chrome 154.0.8037.97 | start(headless=False, sandbox=False) |
| Zendriver | zendriver 0.17.1 | Google Chrome 154.0.8037.97 | start(headless=False, sandbox=False) |
| Camoufox | camoufox 0.5.6 | Camoufox 152.0.4-beta.31 | AsyncCamoufox(headless=False, os="linux", geoip=True) |
Two compromises, stated up front. The control is Playwright 1.62.0, not the current 1.63.0, because camoufox 0.5.6 requires playwright<1.63 and all tools shared one environment. And Camoufox ran with os="linux" and only its Linux fonts, because the host disk was nearly full and we skipped extracting the macOS and Windows font bundles. Camoufox’s default would pick a random OS.
Network conditions:
- Datacenter: the host’s own IP, AS24940 Hetzner Online GmbH, Helsinki (per ipinfo.io).
- Residential, sticky: a US residential exit with a fresh sticky session per run. In every proxied run, all tools connected through the same small local relay. It adds proxy auth and counts bytes, because nodriver and Zendriver can't pass proxy credentials in a Chrome flag.
- Three side conditions: a rotating residential pass with a new exit IP on every connection, a warm-up pass (explained below), and one headless run per tool from the datacenter IP.
Targets, visited in this order in a fresh browser per run, each in a new tab: bot.sannysoft.com, BrowserScan bot detection, deviceandbrowserinfo “Are you a bot?”, CreepJS, Fingerprint’s playground, nowsecure.nl (behind Cloudflare; plain curl gets a 403 with cf-mitigated: challenge), and Pixelscan’s bot check and fingerprint check. Fingerprint’s bot-detection product page was dropped because it only shows a static JSON sample, not a live verdict.
Measurements: whether the page loaded, the verdict exactly as the page printed it, and the time until that verdict appeared (polled every 0.5 s, 45 s timeout). We saved the page text and a full-page screenshot for every visit. A verdict that couldn't be parsed is recorded as “unparsed” and never guessed. Four headful visits ended that way, three page timeouts and one mid-update capture. They're counted as n/a in the tables.
Runs: 3 per tool per condition for datacenter, rotating and sticky residential, 1 per tool for headless and warm-up. 54 runs in total, between 06:50 and 08:02 UTC.
Results: browser-level automation checks
The table counts runs: “3/3” means the page flagged the browser in all three. Every cell comes from the parsed results file.
| Tool | Network | sannysoft failed rows | BrowserScan flagged | deviceandbrowserinfo isBot | CreepJS headless | Fingerprint “Bot” | Pixelscan bot banner |
|---|---|---|---|---|---|---|---|
| Playwright (control) | Datacenter (Hetzner) | WebDriver | 3/3 | 3/3 | 33% | 3/3: You are a bad bot (type: webdriver) | 3/3 |
| Playwright (control) | Residential US, sticky | WebDriver | 3/3 | 3/3 | 33% | 3/3: You are a bad bot (type: webdriver) | 3/3 |
| Patchright | Datacenter (Hetzner) | none | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
| Patchright | Residential US, sticky | none | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
| nodriver | Datacenter (Hetzner) | none | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
| nodriver | Residential US, sticky | none | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/2 (+1 n/a) |
| Zendriver | Datacenter (Hetzner) | none | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
| Zendriver | Residential US, sticky | none | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
| Camoufox | Datacenter (Hetzner) | Chrome | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
| Camoufox | Residential US, sticky | Chrome | 0/3 | 0/3 | 0% | Not detected (3/3) | 0/3 |
A few details the table hides:
- sannysoft’s only failed row for Camoufox is “Chrome (New): missing”. That's expected for a Firefox-based browser, not an automation leak.
- CreepJS also prints a “% like headless” score. It was 50% for Playwright, 44% for the three Chrome-based stealth tools and 6% for Camoufox, in every headful run. Its “% stealth” score was 0% for everything. CreepJS doesn't label any of these numbers pass or fail.
- Pixelscan’s bot check flipped twice. Patchright got “Bot Behavior Detected” (CDP tile) in the single warm-up run, and Zendriver once in the rotating pass. Both are visible in the screenshots. Two more Pixelscan visits are n/a. One timed out. In the other, our text capture caught the page mid-update, and the screenshot taken a moment later shows “You're Definitely a Human”.
- nowsecure.nl showed its page content in every headful run, with no challenge left on screen, plain Playwright from the datacenter IP included. Headful, it didn't separate the tools at all.
For why these particular checks catch stock Playwright (the navigator.webdriver flag, CDP Runtime.enable side effects, launch flags), see our Patchright deep-dive and browser fingerprinting explainer.
Results: IP-aware signals, datacenter vs residential
Only two of the pages score the network. Fingerprint’s playground shows VPN, proxy, IP blocklist and a suspect score. Pixelscan’s fingerprint check shows proxy and location consistency. deviceandbrowserinfo says outright that its test “does not use IP reputation or user behavior”. Its verdicts not moving with the IP is consistent with that.
| Tool | Network | Fingerprint proxy flag | Fingerprint VPN flag | Fingerprint suspect score | Pixelscan “Proxy detected” | Pixelscan location check | WebRTC leaks the server IP behind the proxy |
|---|---|---|---|---|---|---|---|
| Playwright (control) | Datacenter (Hetzner) | 0/3 | 0/3 | 19 | 3/3 | Timezone spoofed | n/a (no proxy; real IP is expected) |
| Playwright (control) | Residential US, sticky | 2/3 | 0/3 | 25–27 | 0/3 | Timezone spoofed | 3/3 |
| Patchright | Datacenter (Hetzner) | 0/3 | 0/3 | 0–6 | 3/3 | Timezone spoofed | n/a (no proxy; real IP is expected) |
| Patchright | Residential US, sticky | 3/3 | 0/3 | 8–20 | 0/3 | Timezone spoofed | 3/3 |
| nodriver | Datacenter (Hetzner) | 0/3 | 0/3 | 0–6 | 3/3 | Timezone spoofed | n/a (no proxy; real IP is expected) |
| nodriver | Residential US, sticky | 3/3 | 2/3 | 10–23 | 0/3 | Timezone spoofed | 3/3 |
| Zendriver | Datacenter (Hetzner) | 0/3 | 0/3 | 0–6 | 3/3 | Timezone spoofed | blocked (3/3) |
| Zendriver | Residential US, sticky | 3/3 | 0/3 | 8–24 | 0/3 | Timezone spoofed | blocked (3/3) |
| Camoufox | Datacenter (Hetzner) | 3/3 | 3/3 | 17 | 3/3 | Timezone spoofed | n/a (no proxy; real IP is expected) |
| Camoufox | Residential US, sticky | 3/3 | 0/3 | 4–14 | 1/3 | matched (US city shown) | 0/3 |
Where the residential IP helped
Pixelscan stopped printing “Proxy detected”. It printed it in every datacenter run, even though no proxy was involved, which suggests it reads the hosting ASN as a proxy. Through the residential pool it printed it in 1 of 15 sticky runs. Camoufox also got a matching location. Its geoip=True option sets timezone, locale, geolocation and the WebRTC IP from the exit IP, so Pixelscan showed a US city instead of “Timezone spoofed”.
Where it hurt or did nothing
Fingerprint’s proxy signal recognised the residential exits as proxy IPs in 33 of 34 proxied runs, across all three proxy modes, and attached provider names to many of them. We read that as Fingerprint having intelligence on residential pools. It doesn't mean every IP leaked something, and it says nothing about what a given site would do with that signal. For the Chrome-based stealth tools the suspect score went up through the proxy, not down. Playwright’s went from 19 to 25–27, and the one warm-up run scored 47.
Part of that is likely self-inflicted. The Chrome-based tools kept the container’s UTC timezone, so a US IP with a UTC clock read as “Timezone spoofed” on Pixelscan in every proxied run. Camoufox shows the same mechanism in the other direction. From the Helsinki datacenter IP, its GeoIP lookup placed the exit in Europe/Berlin while ipinfo said Helsinki. Fingerprint then flagged a VPN (“timezone mismatch”) and a low-confidence datacenter proxy in all three of its datacenter runs, while the Chrome tools on the same IP got neither flag. An IP and a timezone that disagree get noticed either way.
WebRTC leaked the server behind the proxy
CreepJS and Pixelscan both printed the host’s Hetzner address as the WebRTC IP in every proxied Playwright, Patchright and nodriver run. The HTTP traffic went through the residential exit, but the STUN request over UDP didn't. Zendriver passes --webrtc-ip-handling-policy=disable_non_proxied_udp and --force-webrtc-ip-handling-policy by default, and CreepJS reported WebRTC as blocked in all of its runs. Camoufox replaced the address with the proxy exit. A blocked WebRTC is itself a signal some sites may score. We didn't test whether any of these pages penalise it, and Pixelscan didn't. See how anti-bot systems detect proxies for the other mismatches they look for.
Headless mode was a bigger tell than the IP
We ran one headless run per tool from the datacenter IP. This sample is smaller than the rest, but the direction is clear:
| Tool (headless, datacenter IP, 1 run) | sannysoft failed rows | deviceandbrowserinfo isBot | CreepJS headless | Fingerprint “Bot” | nowsecure.nl |
|---|---|---|---|---|---|
| Playwright (control) | CHR_MEMORY:FAIL, HEADCHR_UA:FAIL, WebDriver | 1/1 | 100% | 1/1: You are a bad bot (type: headless_chrome) | stuck on “Just a moment...” after 45 s |
| Patchright | CHR_MEMORY:FAIL, HEADCHR_UA:FAIL | 1/1 | 67% | 1/1: You are a bad bot (type: headless_chrome) | stuck on “Just a moment...” after 45 s |
| nodriver | CHR_MEMORY:FAIL, HEADCHR_UA:FAIL | 1/1 | 67% | 1/1: You are a bad bot (type: headless_chrome) | stuck on “Just a moment...” after 45 s |
| Zendriver | none | 1/1 | 33% | Not detected (1/1) | stuck on “Just a moment...” after 45 s |
| Camoufox | Chrome | 0/1 | 0% | Not detected (1/1) | page loaded |
Patchright and nodriver went from clean to flagged on deviceandbrowserinfo and Fingerprint (“bad bot, type: headless_chrome”) just by switching to headless. sannysoft failed its HEADCHR_UA and CHR_MEMORY rows. Every Chrome-based tool, Zendriver included, stalled on Cloudflare’s “Just a moment...” page at nowsecure.nl. Headful, the same tools, plain Playwright included, got straight through. Zendriver avoided Fingerprint’s bot flag but was still flagged by deviceandbrowserinfo. Camoufox looked the same headless as headful on all of these pages.
Two operational findings: sticky sessions and background traffic
Parallel first connections broke sticky sessions
With a sticky session ID, Chrome-based runs often showed one IP on the first page and a different one a few pages later. That happened in 11 of 12 sticky runs. Camoufox stayed on one IP in 3 of 3, probably because its GeoIP lookup makes a single request before the browser starts. We reproduced it with curl: six parallel requests on a brand-new session ID returned 3 and 5 distinct IPs in two trials. Sequential requests afterwards stayed on one. Adding one serial warm-up request before launching the browser fixed it in 4 of 4 runs. Our first proxy pass also used a session parameter the gateway doesn't recognise, so every connection silently got a new IP. That pass is reported above as the rotating condition. The lesson from both: check stickiness with two sequential requests before trusting it.
Some real-Chrome setups download in the background through your proxy
On metered residential traffic, Playwright used about 7 MB per run of these 9 pages, Patchright about 8 MB (12 MB in one run) and Camoufox 9–11 MB. Most nodriver and Zendriver runs used 40–61 MB. In the runs where we logged bytes per host, most of that came from optimizationguide-pa.googleapis.com, a Chrome background download. Two runs used only about 9 and 15 MB, so it doesn't happen every time. Patchright also runs real Chrome on a fresh profile but didn't show this, so it depends on how the tool launches Chrome. If you pay per GB, count bytes per host before you scale.
What this benchmark does not show
- It isn't a production anti-bot test. These pages mostly check browser-side artefacts. Commercial systems add TLS and HTTP/2 fingerprints, behaviour, IP history and per-site models. A clean result here is a necessary bar, not a sufficient one. Our TLS fingerprinting guide covers one layer these pages barely touch.
- It's a small sample: 3 runs per cell, one day, one datacenter host in Finland, one proxy country. Single flips, like Pixelscan’s, can't be separated from noise.
- There was no interaction. No clicks, scrolling, forms or mouse paths, so behavioural detection wasn't exercised.
- Some checks keep history. Fingerprint’s playground shows per-device history (“1 IP, 2 Linked IDs in the past 24 hours”), so repeat visits may have affected later scores.
- Defaults were not tuned. The Chrome-based tools kept UTC and en-US, Camoufox was limited to Linux, and nothing was hardened beyond each README’s default. A tuned setup might score differently. That's what we'd test next, not something we measured.
- Your targets will differ. Whether to use a datacenter or residential IP depends on what you collect and where. Residential vs datacenter for scraping covers the cost side. Check robots.txt, the site’s terms and personal-data rules before running anything like this against a real site.
Reproduce it
The harness is about 260 lines of Python, an 80-line relay and a parser that only reads saved page text. Below is the Patchright setup that ran clean in our data, with the two fixes from above built in: a warm-up request that pins the sticky session before Chrome opens parallel connections, and Zendriver’s WebRTC flags. We ran it once against deviceandbrowserinfo through a US residential exit ("isBot": false); that check isn't part of the 54 runs.
import asyncio, os, tempfile, urllib.parse, urllib.request, uuid
from patchright.async_api import async_playwright
USER = os.environ["PROXY_USER"] # your proxy username
PASS = os.environ["PROXY_PASS"] # your proxy password, never hard-code it
GATE = "gate.proxyhat.com:8080"
def sticky_username() -> str:
# one fresh session per browser launch; -sid- keeps the exit IP between connections
return f"{USER}-country-US-sid-{uuid.uuid4().hex[:10]}-ttl-30m"
def warm_up(username: str) -> str:
# one serial request so the session is pinned before Chrome opens parallel connections
url = f"http://{urllib.parse.quote(username)}:{urllib.parse.quote(PASS)}@{GATE}"
opener = urllib.request.build_opener(urllib.request.ProxyHandler({"http": url, "https": url}))
return opener.open("https://api.ipify.org", timeout=30).read().decode()
async def main():
username = sticky_username()
print("session pinned to", warm_up(username))
async with async_playwright() as p:
ctx = await p.chromium.launch_persistent_context(
user_data_dir=tempfile.mkdtemp(),
channel="chrome", # real Google Chrome, as the Patchright README recommends
headless=False, # run under Xvfb on a server
no_viewport=True,
proxy={"server": f"http://{GATE}", "username": username, "password": PASS},
args=["--webrtc-ip-handling-policy=disable_non_proxied_udp",
"--force-webrtc-ip-handling-policy"],
)
page = await ctx.new_page()
await page.goto("https://deviceandbrowserinfo.com/are_you_a_bot")
await page.wait_for_function("document.body.innerText.includes('\"isBot\"')", timeout=45000)
print(await page.evaluate("document.body.innerText.match(/\"isBot\": (true|false)/)[0]"))
await ctx.close()
asyncio.run(main())
The username suffix is ProxyHat syntax (-country-US for targeting, -sid-…-ttl-30m for a sticky session). Swap in your provider’s format. To check an exit before a long run, the free proxy checker shows what a site sees. To score a cell the way we did:
- Start a fresh browser per run, visit each page in a new tab, and wait until the page’s own verdict text is on screen. Don't use a fixed sleep. We gave CreepJS 6 extra seconds after its scores appeared, because it keeps computing.
- Save
document.body.innerTextand a full-page screenshot before closing the tab. - Parse only what the page printed, for example the
"isBot"JSON on deviceandbrowserinfo, theNormal/Robotpairs on BrowserScan, and “N% headless” on CreepJS. Anything else is “unparsed”. - Record the exit IP at the start and compare it with the IP the last page shows. If they differ, your session wasn't sticky.
- Count proxy bytes per run, so background traffic shows up before the invoice does.
What to do with this
Fix the browser first: stock Playwright was caught from both IPs, the four stealth tools weren't caught headful from either. Run headful (Xvfb on servers) unless you've measured that headless passes your targets. Then make the network story consistent: match the timezone to the exit IP, block or proxy WebRTC, and confirm your sticky session really is sticky. Add a residential proxy only after that, and expect proxy-aware vendors to recognise many residential IPs anyway. The IP decides if you start out labelled as datacenter traffic. The browser decides if you get flagged as a bot.






