Here is the uncomfortable version, up front: the fingerprint you are sweating over is the part of the fight you are structurally set up to lose.
Spoofing the TLS handshake, matching JA3, reordering headers to look like Chrome, swapping in a stealth browser — that whole industry sells you a single move in a game where the other player gets to change the rules every Tuesday and you have to re-learn the board every time. You are playing an arcade machine. The high score resets. The defender's doesn't.
I am not arguing this from a whiteboard. I have run 2,190 scraper runs across published actors, and the single most-used one — a Trustpilot review scraper — has 962 runs in production. That is operation, not a lab. And the pattern I keep seeing in the logs is not "the runs with a more human fingerprint last longer." It is: the runs that behave like a decent client last longer. The ones that get throttled, captcha-walled, or quietly served junk are the ones that hammer, ignore what the server is telling them, and re-download the same bytes forever.
So this post is about pointing the mirror the other way. Not "how do I look less like a bot." That is a detection-evasion treadmill, and it is not a thing I want to teach. The honest, boring, durable move is: fix how your run behaves. Be a client the server has no reason to fight.
TL;DR
- Fingerprint spoofing (JA3 / TLS / header order / stealth browser) is a move in a game the defender updates faster than you do. On a long horizon you lose it.
- A defender sees the aggregate of your traffic by IP and ASN. You see one request at a time. That asymmetry is why behavior beats disguise.
- Across 2,190 production runs, the runs that survived correlated with behavior — backoff, honoring
Retry-After, conditional GET — far more than with how "human" the TLS handshake looked. I did not measure the exact split, so I am not going to invent a percentage. - There is a
request_behavior_probe.pybelow. It audits your own outgoing conduct against a live endpoint and prints a four-line scorecard. Real output included. - Fingerprint work earns its keep in a narrow case (aggressive enterprise WAF on a high-value target). For most jobs it is premature optimization. I have wasted time there myself.
What I actually mean by "behavior"
Quick boundary, because this is easy to confuse with a different argument. There is a separate, real question of request frequency: don't pound someone's origin into the ground, respect their capacity. That is a politeness-and-ethics question and I have written about it elsewhere.
This post is a different axis: how a site decides you are a bot in the first place, and why the disguise approach folds.
Behavior here means the shape of the conversation your client has with a server. Not the bytes in your TLS hello. Things like:
- When the server says
429 Too Many Requestsand hands you aRetry-After, do you wait that long? Or retry in 200ms because your loop doesn't read the header? - After a few failures, do your retries grow apart, or stay bunched at a fixed interval like a metronome?
- When you fetch the same URL again, do you send
If-None-Matchso the server can answer304 Not Modifiedand skip the body? Or do you re-download four kilobytes you already have, every single time? - Do you reuse one session, or open a fresh connection per request like something that has never heard of keep-alive?
None of that is about looking human. It is about looking competent. And here is the thing that took me too long to internalize: a metronome that fires every 200ms through a 429 wall is a far louder bot-signal than a slightly-wrong cipher order. One is a statistical anomaly buried in noise. The other is a flashing sign that reads "automated, and rude about it."
Why you lose the spoofing arcade (the asymmetry)
The reason this isn't a fair fight has nothing to do with how clever your spoofing is. It is about what each side can see.
You see one request. You craft it lovingly. Perfect JA3, header casing that matches a real Chrome build, a residential exit IP. From your seat it looks flawless.
The defender does not look at one request. They look at the aggregate: every request from your IP, from your /24, from your ASN, over a window. And in aggregate, a thousand "perfect human" requests that arrive in a suspiciously even rhythm, never carry a cookie they were given, never send a conditional GET, and all retry instantly after a 429 — that is a population that does not move like a population of humans. The disguise is per-request. The detection is per-distribution. You cannot paint a distribution one pixel at a time.
That is also why it is a losing arcade specifically, not just a hard one. The defender ships a rule once and it covers everyone. You have to re-spoof for every site, every WAF vendor update, every browser version bump that changes the "real" fingerprint you are imitating. Their cost is amortized across all attackers. Yours is paid per target, repeatedly. The economics run against you on a long enough timeline, and production is a long timeline.
A paper that says the same thing from the academic side
I do not want this to rest only on my logs, so here is an outside data point I actually went and read. Roundtable's research write-up, "CAPTCHAs can still detect AI agents" (the version that hit Hacker News around 2026-05-29; the page itself carries no clear date, so I am anchoring to the submission), makes a point that maps cleanly onto the asymmetry above.
Their framing, verbatim from the figure caption: "output equivalence does not equal process equivalence." An agent can produce the same answer as a human — solve the CAPTCHA, click the right tiles — while arriving there through a measurably different process. They report being able to separate agents from humans via, again verbatim, "sequential click patterns, direction changes, and overselection behavior."
That is the academic restatement of the practitioner's gripe. Spoofing optimizes the output: pass the check, look like Chrome on the wire. It does almost nothing for the process: the rhythm, the sequence, the way your client reacts to being told "no." If a research group can pull humans apart from agents on click process even when the output matches, a WAF can pull your scraper apart from a browser on request process even when your fingerprint matches. Different layer, identical logic.
I will note the obvious limit: their study is about interactive CAPTCHA-solving agents, not headless HTTP scrapers, and I am borrowing the concept, not the measurement. But the concept travels well.
A probe for your own behavior (not a spoofer)
So I wrote a small thing that does the opposite of a stealth library. It does not hide you. It grades you. You point it at an endpoint and it drives a realistic little sequence (trip a 429, do a conditional GET) and logs what your client did, then prints a scorecard.
Stdlib only, no keys, no paid anything. Default target is httpbin.org because it exposes /status/429, a /cache endpoint with a real ETag, and answers 304 to If-None-Match. The full file is request_behavior_probe.py; the core is the part that drives the conditional-GET path and the part that grades it.
def _drive_conditional_path(base, opener, c):
"""Fetch once to learn the validator, then re-fetch with If-None-Match."""
r1 = opener.open(base + "/cache", timeout=12)
body1 = r1.read()
etag = r1.headers.get("ETag")
req = urllib.request.Request(base + "/cache")
if etag:
req.add_header("If-None-Match", etag)
c.sent_validator = True
try:
r2 = opener.open(req, timeout=12)
if r2.status == 304:
c.got_304 = True
c.bytes_skipped_by_304 = len(body1)
r2.read()
except urllib.error.HTTPError as e:
if e.code == 304: # urllib raises on 304 by default
c.got_304 = True
c.bytes_skipped_by_304 = len(body1)
The retry path is the same idea on the 429 side: catch the 429, read Retry-After if present, otherwise apply an exponential fallback (1s, 2s, 4s…), and record the gaps between attempts.
Here is the actual output from running it just now against httpbin, copy-pasted, not reconstructed:
probing: https://httpbin.org
behavior scorecard (4/4 good-citizen checks)
------------------------------------------------------------
[PASS] backs off on 429 429 carried no Retry-After; applied exponential backoff
[PASS] spaces out retries gaps [2.58, 3.67]s — growing, not hammering
[PASS] sends conditional GET re-sent the ETag as If-None-Match
[PASS] accepts 304 (saves bandwidth) server answered 304, skipped 277 bytes
------------------------------------------------------------
reused one session for all requests: yes
Two things worth reading closely. First, httpbin's /status/429 does not send a Retry-After header, so the probe correctly reports that and falls back to exponential backoff instead of pretending it got a hint. That is the realistic case: plenty of servers throw a 429 with no instructions, and your job is to back off anyway. Second, the 304 genuinely skipped 277 bytes. Trivial here. Multiply by a recurring crawl over hundreds of thousands of pages that mostly haven't changed, and it stops being trivial.
Run it against your own scraper's target, or against your scraper's code path, and you get a one-screen read on whether you are a client worth tolerating.
Where the probe lies to you (the limits)
I would rather tell you this than have you find out in prod.
The spaces out retries check is the weak one. When you run against a live remote, the gap it measures includes the server's own response time, not just the sleep your code chose. So it tells you "not obviously hammering," not "your backoff is correctly sized." Read it as a smoke alarm, not a stopwatch. If you want the precise number, instrument your own sleep call.
The probe also says nothing about the things that genuinely are fingerprint-level: it will not tell you your TLS stack screams "Python," it will not catch a missing cookie jar across redirects, it will not notice that you parallelized to 200 workers against one host. It audits four habits. Four good habits will not make you invisible — that is not the goal. They make you cheap to allow, which on most sites is the same as being allowed.
And the headline correlation — behavior survives better than fingerprint — is an observation from my logs, not a controlled experiment. I did not isolate variables. I did not run an A/B with matched fingerprints. I am telling you the shape I see across 2,190 runs, with the honest caveat that I never sat down and computed "X% of blocks were behavioral." If someone hands me a clean dataset that says I am wrong, I will update.
When fingerprint work actually earns its keep
Because I do not want to oversell the contrarian take: there is a real case for it. An aggressive enterprise WAF, the kind sitting in front of a high-value, actively-defended target where the operator has paid money specifically to keep scrapers out, will in fact fingerprint your TLS and reject you on the handshake before behavior ever matters. In that world, matching the fingerprint is table stakes just to get a conversation.
But that is the minority of jobs. For most of what people actually scrape, reaching for curl-cffi or a stealth browser on day one is solving a problem you don't have yet. I have done exactly this — burned an afternoon getting a TLS profile "right" on a target that, it turned out, only ever threw 429s at me because my retry loop was a metronome. Backoff would have fixed it in four lines. The fingerprint work fixed nothing, because the fingerprint was never the tell.
That is the self-own that made me write this. The expensive disguise was theater. The cheap behavior change was the whole fix.
What to do Monday
Not a checklist for its own sake — just the order I would actually go in:
- Read your
Retry-Afterhandling. If your retry loop doesn't even look at the header, that is the first thing a server's logs hold against you. Fix that before you touch a single TLS setting. - Make your backoff grow. Fixed-interval retries are the metronome signal. Exponential with a little jitter is both kinder and quieter.
- Send conditional GETs on anything you re-fetch.
If-None-Match/If-Modified-Since. Free bandwidth, fewer requests, and it makes your traffic look like a cache-aware client instead of a firehose. - Reuse one session. Connection churn is its own little tell, and it is pointless cost.
- Then, if and only if you are still blocked, ask whether the target genuinely fingerprints. Most don't. Don't pay for the disguise until the cheap fixes have failed.
The spoofing arcade will always have a new machine to feed quarters into. The behavior fixes are the same five things on every site, forever, and they are on your side of the asymmetry — the side where your effort compounds instead of resetting.
Written by Alexey Spinov. I run production scrapers and write down what the logs actually show: 2,190 runs and counting. This post was drafted with AI assistance and edited, fact-checked, and run by me; the probe output above is from a real run, not a mock.
Follow for the numbers from the next batch of runs. And tell me the loudest non-fingerprint bot-tell you have ever shipped by accident — mine was a 200ms metronome through a 429 wall. I read every comment.
Top comments (0)