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.

Correction, 27 August 2026. The paragraph above closes by saying that false positive was unexplained again. That was honest when it was written and it is no longer true. The mechanism was reconstructed from this resolver's own durable enforcement record two days later, and it turns out to be the same timing-regularity check this article is about - firing on the one traffic shape the article had ruled out.

Six evaluations of that client landed inside the same second. Five scored a neutral 0.5 on an empty evidence set, meaning no signal at all had been gathered on them. The sixth scored 0.82 on a single feature: a coefficient of variation over the client's most recent request intervals - the regularity measure this whole article is spent on. That one uncorroborated signal was enough to deny the client the relief its earned history would otherwise have bought, and two bans followed 69 seconds apart, at 70 and then 80 requests. The score was identical to sixteen decimal places across both, which is its own tell: nothing the client did in between moved it.

The rate was never the trigger. The client sat comfortably inside the rate ceiling that path allows, and was banned anyway.

So the retraction above stands - it was not a tab restore - but the conclusion drawn from it does not. A sustained, evenly paced stream is exactly what a scale-free regularity measure flags, because low variation is the thing it is built to find. This article assumed a tight burst was the shape that could trip such a check. The opposite shape trips it just as well, and the resolver's own records had been saying so for eight days before anyone read them.

One further limit worth stating plainly, because it is the reason this took two extra days. The burst measured in this article was produced in a lab: headless Chrome, on one host, ten runs. Only the 226-client baseline it is compared against is real traffic. A synthetic burst was never going to explain a real ban, and the evidence that did explain it was already on disk. Across the durable record this signal has been a leading contributor to 138 enforcement decisions over eleven addresses in six weeks, and on 27 of them it was the only one.

The general lesson below is unchanged and now better supported. The counting choice decides what a timing check sees - and so does the question, which nobody writes down either, of which traffic shapes the check was ever tried against.

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 a median of 0.63 ms - and unusually for a latency figure, that one does reproduce: two runs of 40 both gave a spread of 0.09 ms. 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. 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. A scraper working through a name that answers consistently, or hammering one cached name, inherits almost no jitter and looks exactly as machine-like as our reserved-name loop did.

We tried twice to state which names those are, and both attempts were wrong in the same way. Measuring fresh random labels across seven zones gave a tidy-looking spread of standard deviations, and it does not reproduce. Five independent draws of 30 lookups each, on the same zone, seconds apart:

Zone Standard deviation, 5 draws Median, same 5 draws
vk.com72.1 / 3.9 / 9.0 / 74.3 / 2.746.0 - 47.5
wikipedia.org19.6 / 4.1 / 2.8 / 42.5 / 17.735.0 - 35.7
microsoft.com1.8 / 1.1 / 3.6 / 2.2 / 2.416.1 - 24.9

Within that run the medians barely move - two of the three hold to three percent - while the standard deviations swing by up to a factor of 27 on the same zone. An earlier single draw of vk.com on this same resolver gave 0.9 ms, against 72.1 ms here, and whether that zone would disturb a client arriving every 71 ms comes out as yes in two draws of five and no in the other three. The verdict flips minute to minute on identical traffic.

The third row is the exception, and it is a different fault. The microsoft.com medians read 24.57, 24.61, 24.66, 24.88 and then 16.09 - the first four inside 1.3 percent, the last a third below them. That last value was not a stray. Re-measured hours later the same zone answered in 12.92 to 14.92 ms across six blocks, steady inside each one; two minutes after that, 25.03 to 27.18; two minutes after that, 12.79 to 13.57. The median had not wobbled, it was moving between roughly 13 ms and roughly 25 ms, in both directions, on a timescale of minutes. A doubling is about what one extra round trip looks like, which is suggestive and no more than that.

We then tried to characterise that movement properly and could not. Eighteen blocks over five minutes, sampling microsoft.com and wikipedia.org alternately in the same loop so neither could be favoured by timing, put fourteen of the eighteen microsoft readings near 24 ms with isolated single blocks at 13.24, 12.91 and 42.84. Both levels are real - two earlier runs of ours sat entirely in the low one - but the tidy picture of a value dwelling in one state and then the other is not what that run shows. We then tried twice to establish whether those readings fall into two groups, and the more interesting result is that neither attempt could answer it. The first asked how many readings sit in the middle third of the observed range: 78 percent, where a single spread of the same width would put 22 to 61 percent. That looks like a clean negative and it is worthless. A two-level split in this data would show as a gap somewhere around 15 to 23 ms, and the middle third here runs from 22.9 to 32.9 - a single slow reading at 42.84 stretched the range and slid the band off the region where the answer lives. The test reported confidently about a stretch of the number line that was never in question.

The second asked a question that does not care where a gap sits: take the largest space between consecutive sorted readings, as a multiple of the average spacing, and compare it against a single unimodal spread of the same size and width. That returns 6.71 times the mean spacing against a null whose 95th percentile is 6.25, which reads as a real gap at p equals 0.028. And it is still not an answer, because the gap it found runs from 31.02 to 42.84 - it is that one slow reading being separated from everything else, at the top of the range. The space between the low readings and the main cluster, which is the one a two-level story would need, is 5.65 ms and is not even the largest. Drop the single most extreme reading at each end and run the same test again and the significance evaporates: 4.77 times mean spacing against a null whose 95th percentile is 5.76, p equals 0.148. The whole result was one slow answer. Had we read the verdict without the location we would have swung from "no gap" to "a significant gap" and been wrong both times. The honest answer is that five minutes of sampling cannot tell us whether a second level was present in that window, and two tests returning confident verdicts about other things does not change it.

A third test asked whether the order carries information. Shuffling the same eighteen values into random orders 2,000 times gives a lag-1 autocorrelation of +0.030 against a null running from -0.386 to +0.234, a two-sided p of 0.859. Nobody, us included, should read a story into that sequence. A test that reports a number is not the same as a test that answers your question, and the way to tell the difference is to make it show you where its answer came from. With eighteen readings against a null that wide the test has very little power, so this is a limit on what we can show rather than a finding about what is there - which is the point.

Both levels still exist: earlier runs that evening sat entirely near 13 ms and others entirely near 25 ms. What does not exist is our ability to say anything about how the value moves between them from five minutes of looking. wikipedia.org, measured in the very same seconds, showed no two-level structure at all, which at least says the behaviour belongs to the name being looked up rather than to the resolver or the network as a whole.

So there are two instabilities here and they are not the same thing. A swinging standard deviation is one slow answer inside a distribution that has not changed. A moving median is the distribution itself relocating, because the path to the answer changed - delegation records expiring and being refetched, an anycast node swap, an upstream switch. We have not isolated which, and a single session cannot tell you. We can say it is not a warm-up effect: after a 45-second idle, first-block medians came back within two percent of the blocks that followed on all three zones, so the idea that a client's opening burst is measured under a slower regime does not survive testing.

Which forces a harder rule than the one we started with. Our first attempt was that a median may be quoted against another median from the same run, with a date attached. That is not enough, and the reason is uncomfortable: every stable-looking reading we took today was a run short enough to fit inside one of these episodes. Six blocks near 13 ms, eight blocks near 13 ms, four blocks near 25 ms - each read as a solid measurement, and each was a window shorter than the timescale of the thing it was measuring. Dating such a figure only records when you happened to sample it.

So the numbers in the table above are evidence that resolution latency is unstable. They are not measurements of what any name costs, and we would not quote one as such. Do not carry an absolute resolver latency anywhere - not between sessions, not out of this article. The one latency comparison that has survived everything we threw at it today is coarse and has no date on it: an answer already held costs about 0.6 ms, an answer that must be fetched costs tens of milliseconds, and across every zone, dwell and excursion we measured the fetched figure ranged from about 13 ms to about 110 ms. Two orders of magnitude, and nothing came close to closing it. Every structural claim in this article rests on that gap and none rests on a specific number.

The practical consequence for anyone scoring request timing is that this disturbance is episodic rather than a fixed property of a name. A detector's visibility into the same client asking for the same things flickers on a timescale of minutes, which is harder to live with than a steady bias, because a steady bias can at least be calibrated against.

None of that is noise to be averaged away, it is the result. A median converges quickly and a standard deviation of a heavy-tailed latency does not, so at 30 samples the first is an estimate and the second is barely more than the largest value you happened to catch. The general form of the mistake is worth more than the DNS detail: a statistic looks well behaved when your observation window is shorter than the timescale of whatever you are observing. We made that error three times in one evening with three different statistics, and did not recognise the later ones as the same shape as the first until the numbers stopped agreeing.

A fourth error that evening was a different kind, and it is the one most worth carrying away. Trying to test whether those latency blocks fell into two groups, we split them at their own median and compared the two halves, which produced a ratio of 1.06 - a weak result, we thought, but a result. It is not. That procedure cannot return "no structure": it forces half the points into each bin and then reports the difference between the halves, so it exceeds 1.0 on anything that is not perfectly flat. Run it on structureless data and it comes back above 1.0 every time: 6,000 trials across three kinds of noise, 6,000 results above 1.0, none below. On gaussian noise it typically returns 1.085 - higher than the 1.06 we got from real data. Our result was not weak evidence of two groups. Measured against what the instrument does on noise, it was evidence against them.

There is a second trap folded into the first, and it caught us on the way to writing this paragraph. The null is not one number: the same procedure returns a typical 1.171 on uniform noise, 1.085 on gaussian, and 1.016 on values that barely vary. So "1.06 is only just above 1.0, therefore weak evidence" is not a judgement anyone can make by looking at it - whether 1.06 is high or low depends entirely on the shape of the data it came from. If a test cannot produce a null answer, its positive answer means nothing, and the null has to be generated from noise shaped like your own data rather than assumed. That costs two thousand trials and a few seconds, and it holds well outside DNS.

One more admission, because it shaped which of these errors survived long enough to be printed. Two claims in this article had to be corrected after publication, and both of them were results that agreed with something we had already committed to in print. Nobody re-runs a measurement that tells them they were right. Every error caught here was caught by someone with no stake in the conclusion asking what a specific number meant - so the findings that most deserve re-measuring are not the surprising ones, they are the ones that came out the way you wanted.

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 or locally refused answer comes back in a median of 0.6 ms, while a name that has to be resolved from scratch takes tens of milliseconds - medians ranging from about 13 ms to about 110 ms across the zones and moments we sampled. Recording completions therefore adds variance to the measured stream rather than smoothing it, sometimes by a very large amount.

We first wrote that this makes the practice harmless here - that it can only hide a real bot, never invent one. That was too strong, and simulating it properly breaks it. Feed a browser-shaped arrival stream, ragged at a coefficient of variation of 1.014 and never once scoring as regular, into a single server at 13 ms with a standard deviation of 2.2, and the completions come out at 0.155, scoring as machine-paced in every window. When arrivals outrun capacity the queue serialises them and they leave at the service rate, which is steady no matter how ragged the input was. The queue's rhythm replaces the client's.

What limits the damage is that this needs the path to be saturated and its service time steady. Repeat the same simulation with the slow-lookup timing we actually measured - 70 ms with a standard deviation near 50 - and even at eight times overload the completions come out at 0.645 and score as regular in no window at all. The smoothed output inherits the service variance, and ours is enormous. Give the queue two servers instead of one and the effect vanishes entirely at 0.758.

So the honest form is narrower than either of our first attempts: completion timestamps add irregularity, except where the serving path is saturated and its service time is steady, and there they can manufacture a false positive outright. That is a simulation of a queue, not a measurement of this resolver. And the binding condition is the serialisation rather than the volume: our own runs show two servers already lifting the figure back to 0.758, and adding more lifts it further still. Sheer rate does not get you there if the path can work on several requests at once - parallel service superposes toward randomness, not toward a metronome. It takes a path that genuinely handles one request at a time, at a steady cost, saturated.

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. The measurements were captured on 24 August 2026: the tab-restore arms with tcpdump and headless Chrome on this host, and the client baseline from a frozen 76-minute snapshot of this resolver's own query log with the author's traffic excluded. The enforcement figures in the correction were read from the durable observation ledger on 26 August. The design-note sentence corrected at the top is his own.