Back to guides
Post-mortem

Ten Browser Tabs Look More Robotic Than a Bot

Restoring a browser session puts 61 DNS queries on the wire in 145 milliseconds, spaced 16 ms apart. Measured against 226 real clients on this resolver, that burst is more regular than the most machine-like moment 164 of 167 of them produced in 76 minutes.

24 August 2026 · measured on dnsdoh.art

The sentence that turned out to be wrong

A design note on this resolver's defence layer contained this claim, written to explain why a real user had been cut off mid-session: ten tabs restoring fire a tight cluster of lookups with near-identical gaps, so a timing check scores them as automated.

It was written from reasoning, not from a capture. So before it went any further, we captured it. The sentence is wrong at the granularity this resolver actually counts, and right at a different one, and nothing in the system recorded which of the two it was using. That gap is the finding, and it is not specific to us. Any detector that scores request timing has the same choice buried in it, usually undocumented.

Two corrections follow from the measurement, and both are ours. This resolver counts every query as its own event, so a tab restore does not trip its timing check - the reassuring half. And the cut-off that prompted the explanation was not a tab restore at all: the durable record shows 70 requests in five seconds and 80 more a minute later, a sustained stream rather than a burst, with an unchanged feature vector across both. The tidy explanation was wrong about the mechanism and wrong about the event. That user's false positive is unexplained again, which is a worse position than we thought we were in yesterday and a more honest one.

What was measured. Headless Chrome 149, a fresh profile every run, ten tabs opened back to back, DNS captured at the wire with tcpdump and, in a second arm, read back from the resolver's own query log. Ten runs per arm. The baseline is 74,882 real queries from 226 client addresses in a 76-minute window, with this machine's own traffic excluded from it.

What ten tabs actually put on the wire

A ten-tab restore is not ten DNS lookups. Across ten runs the median was 61 queries covering 31 distinct hostnames: the ten sites, plus the fonts, analytics, image hosts and CDNs their pages pull in.

Every hostname is asked for twice. A modern browser requests an address record and an HTTPS record for the same name, the record type defined in RFC 9460 that lets a site advertise HTTP/3 support and encrypted-hello keys before the first connection. Across 100 measured pairs the HTTPS query went first 100 times out of 100, and the two queries left a median of 0.094 ms apart - 82 of 100 pairs under a millisecond. For practical purposes they are simultaneous.

The ten tab hostnames themselves resolved inside a median of 145 ms, one every 16.1 ms. That cadence is the browser working through a list, and it is remarkably stable: 16.1 ms over plaintext UDP, 16.8 ms over DoH, 16.1 ms with 25 tabs instead of 10.

One tab restore, 145 ms, counted two ways Each bar is the gap to the next lookup, both charts to the same scale Counting every query address record + HTTPS record = 2 events per hostname, 19 gaps near-zero, 16 ms, near-zero, 16 ms - the pair hides the cadence CV 1.048 - reads as irregular, reads as human Counting one lookup per hostname the pair collapsed into a single event, 9 gaps CV 0.223 - reads as evenly paced, reads as automated dashed line = mean gap: 7.6 ms above, 15.9 ms below

The same 145 milliseconds of traffic. Only the counting rule changes.

How a timing check decides you are a machine

The common test is the coefficient of variation of the gaps between a client's recent requests: the standard deviation divided by the mean. It is attractive because it is cheap, needs no content inspection, and survives encryption - it works on arrival times alone, so it is one of the few signals still available once the payload is a DoH request body.

The reasoning behind it is that a person generates ragged intervals and a script generates even ones. A low value means evenly paced, which is taken to mean automated. It has a known weakness worth stating plainly: there is no duration term in it. A two-second burst and a six-hour siege produce the same number, because the measure only describes shape.

Our scripted controls behave exactly as the reasoning predicts. A loop that issues its next query as soon as the previous answer arrives scored a windowed CV of 0.108; one pacing itself at a fixed 40 ms scored 0.001. Eight runs each, every run tightly regular. The test is not useless. It finds bots.

The same burst, two verdicts

Here is the problem. Take the ten tab hostnames from one restore and score them both ways. Ten runs, medians and full ranges:

Counting rule Events CV, median Range over 10 runs
Every query is an event201.0480.998 - 1.248
One event per hostname100.2230.149 - 0.556

A factor of 4.7 in apparent regularity, from a decision about whether two queries for one hostname are one event or two. The paired HTTPS query is doing the hiding: a near-zero gap sitting next to a 16 ms gap wrecks the variance, and the browser looks ragged. Collapse the pair and the underlying metronome shows through, because a metronome is what it is.

We tested that directly by turning the HTTPS record off. With Chrome's HTTPS-record lookups disabled, the same ten tabs produce ten events instead of twenty, and counting every query now gives a CV of 0.222 - the paired number, arrived at from the other direction. A browser that asks for HTTPS records looks 4.7 times more human than one that does not, and no user chose either setting.

Measured against real people

A number in isolation means nothing, so the burst needs a population to sit in. We took a 76-minute window of this resolver's query log - 74,882 queries from 226 client addresses, with this machine's own measurement traffic and synthetic probes removed - and scored every client that made at least 20 queries.

Traffic Sample CV
Scripted loop, fixed pacing8 runs0.001
Scripted loop, answer-paced8 runs0.108
Ten-tab restore, per hostname10 runs0.223
Real clients, most regular window each167 clients1.001
Real clients, whole session170 clients2.16

The fourth row is the one that matters. For each real client we found its single most machine-like stretch in 76 minutes - the most evenly spaced run of 20 consecutive intervals it produced all hour - and took the median of those best-case values. It is 1.001.

A ten-tab restore, at 0.223, is more regular than the most regular moment 164 of 167 real clients managed in the entire window. It sits closer to a scripted loop than to the people it belongs to. That is not a threshold being set badly. Anywhere you draw a line that catches the two scripted controls, the tab restore is on the wrong side of it or close enough to make the choice arbitrary.

Rarity is what makes this expensive. A signal that fires on 2 percent of clients looks precise, and precision is what makes it trusted. On this resolver a timing-regularity signal appears among the top contributors in 138 of 90,340 recorded enforcement decisions since 24 June 2026, 113 of them on decisions proposing something other than allow. We have not established what those 138 clients were doing, and after this week we no longer assume. A rare signal that is wrong when it fires is worse than a noisy one, because nobody goes looking for it.

Three things we expected that did not happen

These were real hypotheses, measured and discarded. They are the useful part.

Encrypting DNS would change the shape. Chrome's DoH client sends queries through one HTTP/2 connection, so we expected serialisation to smooth the pacing and make the burst look more machine-made. It does not. Ten DoH runs gave a per-query CV of 6.21 against 6.18 for plaintext, and a per-hostname CV of 0.220 against 0.223. The two arms are the same measurement. Whatever this is, it is a property of the browser, not the transport.

The DoH handshake would leave a long lead-in. One pilot run showed the first tab resolving 886 ms before the rest, while Chrome brought up the DoH connection. It looked like a clean finding. Over ten runs the median lead-in is 22 ms. The 886 ms run was an outlier and would have been published as a fact if we had stopped at one run.

More tabs would look more robotic. The opposite. A 25-tab restore scored a per-hostname CV of 0.351 against 0.223 for ten tabs - less regular, not more, because a longer list gives the browser room to queue, stall and reorder. The effect is strongest for an ordinary restore of a handful of tabs. If you wanted to look most like a machine, you would restore ten tabs, not fifty.

What this evidence cannot show

The measurement is one client on one machine. Ten tabs opened back to back through the browser's automation interface is a model of a session restore, not a session restore, and a real one carries cache state that ours deliberately did not.

The comparison arm where sites open one at a time, with pauses drawn uniformly between 1.5 and 11 seconds, scored 0.42 - but that number is a property of the distribution we picked, not evidence about how people browse. It is in the harness for shape, and it is not cited as a human baseline. The 167-client figures are the human baseline, and they come from other people's traffic.

The scripted controls have a limitation we only found by measuring the resolver afterwards. They query names under a reserved top-level domain from RFC 2606, which this resolver refuses locally in 0.63 ms with a standard deviation of 0.09 - so the loop was paced by an almost perfectly constant reply, and that is a large part of why it scored 0.108. Read those two rows as a floor for what a tight local loop looks like, not as a portrait of bots in general.

The mechanism is broader than the reserved name, and our first attempt to state it was itself too strong. A closed-loop client is paced by however long the answers take, so what matters is not whether the name is cached but how stable that time is. Fresh random labels across seven real zones, 30 lookups each, gave standard deviations of 0.9, 2.9, 8.6, 9.1, 9.5, 16.0 and 58.7 ms. Only the last of those is large enough to disturb a client arriving every 71 ms; the other six leave its cadence essentially intact. A scraper working through a stable zone, or hammering one cached name, inherits almost no jitter and looks exactly as machine-like as our reserved-name loop did.

Those per-zone figures should not be read as properties of the zones. An independent run on the same resolver put one of them at a standard deviation of 115 ms where ours measured 0.9, which is a difference of two orders of magnitude on the same name between two measurements minutes apart. Thirty lookups characterise a middle, not a tail. What survives both runs is the shape: most lookups are stable, some are not, and a detector's sensitivity therefore rides on which names a client happens to ask for - which has nothing to do with whether that client is a bot.

Most importantly: measuring a client tells you what the client emits, not what any given detector counts. We can show that the same burst yields 1.048 or 0.223 depending on the rule. We cannot tell you which rule your detector uses, and in our own case reading the code was the only way to find out. That is the actual recommendation in this article.

If you run something that scores request timing

  • Write down the granularity. One event per query, per hostname, per connection or per bucketed interval are four different detectors. Ours was never a documented choice; it was whatever the counter happened to increment.
  • Add a duration term. Regularity alone cannot distinguish 145 milliseconds from six hours, and the difference between those two is the whole difference between a tab restore and an attack.
  • Compare a client to itself. A population constant has to separate a burst from a siege using one number for everybody. A per-client baseline does not.
  • Check what your rare signals cost. Pull the records where a signal was decisive and read them individually. 138 records is an afternoon.

And a caution we walked into ourselves: timestamp arrivals, not completions. If you record the moment you finish answering, then for any client that issues its next request when your answer arrives you are measuring your own service time divided by its in-flight window, not that client's cadence. Our answer-paced control scored 0.108, and what that number really describes is how consistently the answers came back, not how regularly the client asked. A server with steady latency can read its own steadiness back as proof the client is a machine, which is the same trap as treating your own output as evidence about a client.

This resolver records completions, so we measured whether it has that problem. It does not, and the reason is worth borrowing. Service time here is bimodal: a cached answer takes 0.63 ms with a standard deviation of 0.09, while a name that has to be resolved from scratch took 80.1 ms and 66.8 ms across two runs, with standard deviations near 50. Recording completions therefore adds variance to the measured stream rather than smoothing it. It cannot manufacture a false positive here; it can only hide a real bot behind our own slow lookups. The caution stands generally - it is a live risk for any server whose latency is genuinely steady - but on this stack it errs in the safe direction, and that is a measurement, not a hope.

Reproduce it

Capture your own restore and score it both ways. No resolver access is needed - this reads your own client's traffic:

# capture while you restore your session, then stop with ctrl-c
tcpdump -i any -n -w restore.pcap 'udp port 53'

# every outbound query: time, name, type (1 = A, 65 = HTTPS)
tshark -r restore.pcap -Y 'dns.flags.response==0' -T fields \
  -e frame.time_epoch -e dns.qry.name -e dns.qry.type

Then score the gaps twice - once over every row, once over the first row per hostname. The coefficient of variation is the standard deviation of the gaps divided by their mean. If the two numbers differ by several times, you have found the same ambiguity we did.

One run proves nothing. Every figure here is a median over ten runs with its range given, and two of our three discarded hypotheses looked correct after a single run. If a result matters, repeat it eight times before believing it.

Author · Creator of dnsdoh.art

Ozy-666 builds and operates dnsdoh.art, an encrypted DNS resolver serving DoH, DoH3, DoT and DoQ. Every figure in this article was captured on 24 August 2026: the tab-restore arms with tcpdump and headless Chrome on this host, the client baseline from a frozen 76-minute snapshot of this resolver's own query log with the author's traffic excluded, and the enforcement counts re-read from the durable observation ledger. The design-note sentence corrected at the top is his own.