Benchmarking stealth: score the range, not the number
- #python
- #statistics
- #matplotlib
- #reproducibility
- #pydantic
- #schema-versioning
- #browser-automation
- #stealth
- #building-in-public
stealthbench is my benchmark for browser-automation stealth: it scores stealth setups (configs) against open, self-hosted bot detectors on my own localhost, and commits every result so anyone can rerun it. In update 1 I added a stealth Firefox and printed a table: one number per config, 82, 94, like a fact.
Then I ran the bench again and got different numbers. Not wildly different, but different. That is the whole problem with reporting a single figure for something that doesn’t repeat: you can’t tell a score that means “this config is like this” from a score that means “this config was like this, once.” So this week I stopped trusting one run. I bumped the trials from three to ten, and I drew the spread on the chart.
The bar chart was hiding the wobble
Here’s the new chart. Bar height is the mean tells-panel score over ten trials, colour is the BotD verdict (red caught, green passed), and the whisker is the min-to-max range across those ten runs.
The whiskers are the point. Two of them barely exist and two of them are real, and that split is the thing the old single-number table couldn’t say.
selenium-stealth and camoufox land on 94 and 82 every single time. Ten runs, same answer, no whisker. vanilla (stock Selenium) swings from 82 to 88, and undetected-chromedriver runs from 94 all the way to a clean 100 on its best trials. In the update-1 table those two looked like solid numbers. They aren’t. undetected-chromedriver’s “95%” is really “somewhere between 94 and 100 depending on the run,” and if I’d quoted the trial where it hit 100 I’d have been quietly lying with a real number.
Steady on purpose, or steady by luck
I didn’t expect the split to line up so cleanly, but once it was on the chart it made sense. The two configs that hold still are the two that rewrite the fingerprint deterministically: selenium-stealth applies a fixed set of patches, and camoufox spoofs its fingerprint the same way on every launch, the same fingerprint-spoofing tricks I broke down in an earlier post. Same inputs, same output, so the tells panel sees the same thing each run.
The two that wobble don’t do that. vanilla is stock Selenium with nothing hiding the automation, so what the panel catches shifts with whatever the browser happens to expose that run. undetected-chromedriver patches things at runtime, and runtime is exactly where timing and load leak in. The wobble isn’t noise I need to average away. It’s the honest shape of a config that behaves a little differently every time you start it, and that is worth knowing before you trust it in production.
What “± 3” means here, and what it doesn’t
The table under the chart now carries the same spread in text, mean ± stdev [min–max]:
| Config | Tells % | BotD | CreepJS lies |
|---|---|---|---|
| vanilla | 85 ± 3 [82–88] | caught (selenium) | 0 |
| selenium-stealth | 94 ± 0 [94–94] | caught (selenium) | 2 |
| undetected-chromedriver | 95 ± 2 [94–100] | passed | 0 |
| camoufox | 82 ± 0 [82–82] | passed | 0 |
One thing I was careful about: that ± 3 is descriptive, not a confidence interval. It’s the actual min, max, mean, and standard deviation of the ten numbers I saw, nothing more. Ten runs from one machine on one afternoon is not a sample I can make a real inferential claim from, and putting a “95% CI” on it would overstate what those ten runs support. So the report says spread and stops.
The implementation uses statistics.pstdev (population) instead of stdev (sample). Sample stdev divides by n - 1, which blows up on a single value; population stdev divides by n and gives a clean 0.0. That matters because a --trials 1 run should print ± 0, not crash, and test_spread_single_trial_has_zero_stdev pins exactly that.
Keeping the old numbers readable
Adding spread and version metadata meant changing the shape of the results file, and update 1’s whole pitch was that every number traces to a committed results/*.json. That promise breaks the day new code can’t open an old file. So the rule for the v2 schema was: every new field is optional or defaulted.
class RunMetadata(BaseModel):
schema_version: int = SCHEMA_VERSION # now 2
single_ip_caveat: bool = True
components: dict[str, str] = {} # driver versions; absent in v1, so it defaults
A v1 snapshot has no components key, so it parses because the field defaults to empty. I pinned the oldest snapshot in the repo as a regression fixture, results/20260706T155231.json, from back when the bench was three Chrome configs and no Camoufox, and a test refuses to let any future change stop it from loading. The components map, while I was there, records the versions that actually ran (chrome 149, playwright 1.59.0, camoufox 0.4.11, and so on), read from the environment with importlib.metadata.version(...). Numbers only: driver versions and a browser major, no IP and no fingerprint. And because that reads package metadata by name rather than importing the SDK, it doesn’t touch the provider seam from update 1.
The last thing I added walks every committed snapshot and plots tells-percentage over time, so update 1’s “today’s pass can be caught next week” is something I can watch now instead of just assert. It’s young: three snapshots over two days is a couple of points, not a trend line yet. When a config is missing from an older run I draw a gap, never a fabricated zero. The layer is in; it fills itself one committed run at a time.
What’s next
The measurement side is in decent shape now: the scores show how much they move, the schema still reads its old snapshots, and the trend line fills in as I commit runs. The thin part is the detector side. I want more signals to score against, and a headful CI run so the snapshots regenerate on a schedule instead of when I remember to.
The question this update left me with, for anyone who benchmarks something non-deterministic: how many runs do you do before you believe a spread? I did ten because it was enough to separate the steady configs from the wobbly ones on this panel, but I don’t have a principled number, just a chart that stopped surprising me. The repo is at github.com/bessavagner/stealthbench if you want to see the runs behind the bars.
I'm building this in the open, one update at a time.
Keep reading
- Every falsy value went missing: adding nodriver to the stealth benchUpdate 3 of the stealthbench build log. stealthbench scores browser-automation stealth setups against self-hosted bot detectors on your own localhost and commits every result. This update widens the matrix: two more detectors (a Sannysoft-style panel and rebrowser-bot-detector) and a fifth config, nodriver. nodriver is async and the bench's BrowserHandle contract is sync, so the new handle drives each call to completion on a private event loop. Wiring it in surfaced a real bug: nodriver's tab.evaluate(return_by_value=True) drops falsy results (its unwrap tests `if remote_object.value:`, so 0/false/'' vanish) and leaves plain objects wrapped in a RemoteObject, so detector signals came back as None or garbage. The fix is to stop fighting its unwrap: wrap the script in JSON.stringify and json.loads the returned string, which round-trips every value faithfully. With that fixed, the two new panels mostly corroborate the tells ranking (undetected-chromedriver and nodriver ace both, vanilla and selenium-stealth get dinged), and camoufox runs only 9 of rebrowser's 10 tests because one is Chrome-specific.July 18, 2026
- One seam, a whole new browser: adding a stealth Firefox to the bench with zero detector changesUpdate 1 of the stealthbench build log. stealthbench is a reproducible benchmark that puts numbers on browser-automation stealth: it scores stealth configs (how you set up an automated browser) against self-hosted detectors (how a site decides you're a bot), on your own localhost, and commits every result. This update adds the fleet's first non-Chrome arm, a stealth Firefox (Camoufox driven through Playwright), and does it the way the architecture was built to allow: a new Config plus a new BrowserHandle adapter, with zero changes under detectors/. The BrowserHandle Protocol is the Ports and Adapters seam; a test (test_seam.py) fails the build if a browser SDK is imported outside configs/, and the sprint's proof is an empty `git status src/stealthbench/detectors/`. The one real roadblock was Firefox's Xray wrapper hiding the detector pages' window globals, fixed by reaching through window.wrappedJSObject in the evaluate translation. Camoufox lands at 82% on the tells panel and passes BotD and CreepJS; the three tells it 'fails' are Chrome-specific, so a genuine Firefox doesn't match them.July 10, 2026
- The LLM judge that can't fail your build: an offline evaluator, advisory by defaultUpdate 5 of the ReplayGate build log: adding an LLM-as-judge for the dimensions a deterministic predicate can't score (goal completion, relevance, tone), and then spending most of the sprint making sure it can never fail a build on its own. The judge is a real Claude call via forced tool_use, but its verdicts are recorded and replayed offline like every other call, keyed by the conversation and dimensions. `--judge` is advisory (prints scores, never touches the exit code); `--judge-gate` is a separate opt-in with its own exit code 4. Writing the post caught a bug in it: with no recorded verdict the gate found nothing below threshold and passed the build without running, so it now refuses with exit 2 rather than failing open. Plus the golden test that pinned the deterministic gate byte-for-byte before any of this was written, and why I flipped my own roadmap to build the judge before the WhatsApp channel.July 8, 2026
Get the next update by email
Build-in-public updates and new posts, delivered as a digest. Double opt-in · no spam · unsubscribe anytime · handled by Buttondown.