My TLS Fingerprint Benchmark Was Measuring Keep-Alive

September 18, 2026 (Today)

Support me on Patreon to write more tutorials like this!

profile picture
Tony Wang

On a Tuesday afternoon I found a TLS fingerprint that beat Cloudflare's managed challenge 94% of the time. Plain Chrome impersonation managed 50%. Brave managed 22%. The result held across five rounds and three different pages, and it held on rounds where the other profiles were failing 0/8 against the same proxy pool at the same moment.

Twenty-four minutes after I committed that finding, I retracted it.

The fingerprint hadn't done anything. My benchmark was measuring HTTP keep-alive: every "batch of N runs" was one proxy node sampled once and then confirmed N times over a pooled connection. Once each attempt got its own connection, the 94% winner and plain Chrome tied exactly.

This isn't a post about fingerprints. It's about a mistake that fits any benchmark run through a rotating proxy, and I made it with a tool I wrote.

TL;DR

  • I ran all 18 browser-impersonation profiles in my HTTP client against a Cloudflare-challenged site through a rotating proxy pool. Opera impersonation won 30/32 (94%); Chrome won 11/22 (50%).
  • The probe reused one pooled connection for all runs of a profile. The pool rotates per connection, so N runs meant one exit node, not N.
  • Rerun with one OS process per attempt: Opera 9/15, Chrome 9/15. The fingerprint effect vanished, and so did two other "findings" that had looked just as clean.
  • The first round had already shown the answer. Two profiles that are byte-identical in the code got opposite results, 3/3 and 0/3. I wrote that down correctly, then let more pooled data talk me out of it.
  • If your proxy rotates per connection, your unit of sampling is the connection. Count connections, not requests.

The setup

The target is a site that puts every page behind Cloudflare's managed JS challenge. I scrape it through a real browser, and that works, but browsers are expensive: the render fleet is small and shared, and it had already cost me one outage on this site. So I wanted to know whether a plain HTTP client with a browser TLS fingerprint could get through instead.

My client has 18 impersonation profiles: Chrome, Opera, Brave, Android and mobile Chrome, a few forced-HTTP/1.1 variants, and some "honest" profiles that don't impersonate at all. The exit is a rotating proxy pool. One detail about it matters for everything below. It's a load-balancer in front of free public proxy nodes, around 70% of which are dead at any moment, and it picks a new node per connection, round-robin, skipping dead ones. It doesn't rotate per request.

I port-forwarded the pool's service to my laptop, pointed the probe at it, and ran every profile three times against two pages. I kept the rounds short on purpose, because hammering this site through the pool had gotten the pool's IPs rate-limited before.

Round one already had the answer

The first table was a mess:

profilepage A (3 runs)page B (3 runs)
browser (Chrome)3/30/3
browser_proxied0/33/3
browser_opera3/33/3
browser_brave0/30/3
browser_mobile0/33/3
proxied (no impersonation)1/33/3

browser and browser_proxied are the same code path. In the client they share a case arm: same proxy handling, same Chrome impersonation, no difference at all. On the same run one went 3/3 and the other 0/3.

I read this correctly at the time. My note says the only variable was which proxy node got picked, that the outcome was "dominated by per-peer luck, not by TLS/HTTP2 fingerprint choice," and that Opera's clean 6/6 was too small a sample to trust.

Look at the shape of the table, though. Every cell is 3/3 or 0/3 except one. If the three runs were independent draws from a pool where most nodes are flaky, you'd expect lots of 2/3s and 1/3s. All-or-nothing cells mean the three runs weren't three draws. I didn't catch that yet.

Then more data talked me out of it

Opera's 6/6 was the obvious thing to follow up, so I did, with more runs and a third page:

profilepage A (8)page B (8)page C (10)cumulative
browser_opera8/87/89/1030/32 (94%)
browser (Chrome)8/80/811/22 (50%)
browser_brave7/80/80/107/32 (22%)
browser_mobile5/88/14 (57%)

This is where I talked myself out of the right answer. I explained away pool noise with an argument that sounded rigorous: Opera kept passing on rounds where Chrome and Brave were failing 0/8 on the same pool at the same time, so a healthy pool couldn't be the explanation. Opera was "the one profile that keeps working even when the pool visibly isn't cooperating with the others." I went as far as a mechanism: Opera's JA3 and HTTP/2 SETTINGS order probably sat outside whatever Cloudflare was keying on.

The flaw is that there was no shared pool state to compare against. Each profile had its own pooled connection, pinned to its own node. "Chrome failing 0/8 while Opera passes 8/8 at the same moment" was two nodes, one good and one bad. The 94% was mostly a handful of lucky draws, each counted eight or ten times.

The tell I finally noticed

I extended the comparison to nine more endpoint types, four runs per profile each, and the table came back even cleaner:

endpointbrowser_operabrowser
set4/44/4
card0/40/4
person4/40/4
team4/44/4
birthdays0/44/4
releases4/40/4

Every cell except one was 4/4 or 0/4. That was the same shape as round one, and this time I saw it. Neither profile disables keep-alives. Within one process, the four "runs" against a URL reuse one pooled TCP connection, which means one node. They're one sample confirmed four times.

It also produced two new "findings" that looked just as solid as the Opera one:

  • The card page blocks both profiles, 0/4 each. So maybe Cloudflare applies a stricter rule to that page.
  • The birthdays page reverses the pattern, with Chrome 4/4 and Opera 0/4. So maybe fingerprint preference is page-specific.

I wrote both up as outliers worth investigating. Neither was real.

Rerunning it with independent samples

The fix was to make each attempt its own OS process: its own client, its own TCP connection through the tunnel, and so its own node from the round-robin. That's five separate invocations per profile per URL, with -runs 1 each, against the card page, a second card ID, and the birthdays page:

targetbrowser_operabrowser
card (original)4/53/5
card (different ID)3/54/5
birthdays2/52/5
total9/15 (60%)9/15 (60%)

Every row is mixed now. The card page isn't specially blocked: each profile gets through 7 times in 10 across the two card IDs. The birthdays reversal is gone too, since both profiles pass 2 of 5. A few of the failures were plain transport timeouts that never reached Cloudflare at all, which is what you'd expect from a pool that is mostly dead nodes.

Fifteen samples is still small. Treat 9/15 as "no difference I can detect," not "identical." As a back-of-envelope binomial interval I computed for this post, a true rate anywhere from about 36% to 80% could produce it. The note's own takeaway is the defensible one: any of these fingerprints gets through about half the time per independent attempt, and which one you pick doesn't measurably matter. Proxy-node luck governs it, not the fingerprint.

Why it looked so convincing

It's worth being precise about why this fooled me for three rounds, because none of the individual steps were careless.

The samples were correlated, and the counts hid it. The statistics name is pseudo-replication: measuring one experimental unit many times and counting each measurement as a unit. Thirty-two attempts felt like a lot. By the note's own later recount, Opera's 32 attempts were five independent draws across rounds, and it passed on balance in all five. That's the entire effective sample.

Five for five isn't remarkable when you're picking a winner. If every profile really gets through 60% of the time, one profile going 5/5 is about an 8% event (0.6⁵; my arithmetic, assuming independent draws). That's unusual for one pre-chosen profile, but I didn't pre-choose Opera. I picked it because it went 6/6 in round one. Seven profiles got two pooled rounds there, and at 60% per draw the chance that at least one of them goes 2/2 is about 96%. The benchmark was always going to produce a winner. I just followed it.

The "same pool, same moment" control didn't control anything. It only works if both arms draw from the same state. With per-connection rotation and pooled connections, each arm had its own private state. It looked like a controlled comparison without being one, and that made it more persuasive than no control at all.

The tool promised independence it didn't deliver. When I went to fix the probe afterwards, I found two gaps. The default candidate set only disabled keep-alives on proxied tiers when a retry count was set, and the default retry count was zero. And naming a profile explicitly (-profiles browser_opera) never disabled keep-alives at all, even though the retry option's own doc comment promised a "fresh connection" that "rides past flagged/captive peers." Both are fixed now: any retry disables keep-alives, whichever profile you name.

What I changed

In the tool. The probe now disables keep-alives whenever it retries, whichever profile you pick. The one exception is an explicitly named profile with zero retries, which keeps keep-alives on so you can still deliberately test a profile's session behaviour.

In the client. There's now an Opera profile with keep-alives off, browser_opera_fresh, and the service's plain-HTTP path uses it with three serial attempts. I want to be straight about that choice. My own note calls it "a preference choice, not a data-driven one." Opera didn't win. It just didn't lose, and we went with it. What the data does support is the part the service had used all along: several attempts on fresh connections, not a better profile.

In how I read tables. If a pass/fail matrix from a noisy source comes back as all 0/N and N/N, the runs aren't independent, and the first thing to check is what's being reused between them: a connection, a session, a cookie jar, a DNS cache, a sticky routing key. Real noise gives you 2/3s.

The general version

None of this is specific to TLS fingerprints or to Cloudflare. It applies whenever:

  1. the thing you're varying (fingerprint, header set, user agent, parser) is cheap to vary per request, and
  2. the thing that dominates the outcome (exit IP, backend node, cache shard) is chosen per connection or per session, and
  3. your HTTP client pools connections by default. In Go, Python requests sessions, and Node's undici, it does.

Under those three conditions a benchmark with N runs per arm has roughly one sample per arm per process. It will produce clean, confident, reproducible-looking winners, and they'll look even more convincing when you put two arms side by side "at the same moment."

Before comparing arms, make sure each attempt gets its own connection: disable keep-alives, use a new client per attempt, or run a separate process per attempt as I finally did. Then count connections, not requests.

I'd also do one more thing I skipped. Run the null test first: two arms that are identical. If identical code produces 3/3 vs 0/3, your benchmark is measuring something other than the code. I had that test in round one by accident. It was right, and I overrode it.