Chrome ships a stable major roughly every four weeks and installs it without asking. The practical consequence is that the version distribution of real traffic is a spike: most of it is the current major, a decent tail is the previous one, and the amount that is three majors back is small and structurally weird. Enterprise fleets on extended stable, managed devices with update policies, and machines that have not been rebooted in a season.
And browsers that were pinned on purpose.
That is the whole signal. It costs a detector one regular expression, it needs no reference database beyond a release calendar, and it is read before anything expensive runs.
Why anti-detect builds lag structurally
A browser fork that patches the engine cannot follow Chrome’s cadence for free. Every major rebase means resolving the patch set against a moving codebase, rebuilding on every target platform, re-running the acceptance suite, and re-publishing. Four weeks is a tight loop for that. So forks lag, and the lag is not random - it is roughly the length of the rebase cycle, which is a property of the tooling rather than of any individual user.
The result is a population whose version distribution does not look like the population it is trying to blend into. Not because any single browser is implausible, but because the aggregate is shifted.
This is the anonymity-set argument in its most concrete form. A Chrome 150 in a month when stable is 152 is entirely believable as one browser. It is also a smaller bucket to be in, and every other signal is now being read within that smaller bucket.
The version is asserted in more places than you think
Getting the version right means getting it right everywhere, and there are at least six places it appears:
| surface | shape |
|---|---|
navigator.userAgent |
Chrome/152.0.0.0 - major only since the version was frozen |
navigator.appVersion |
the same string, minus the prefix |
UA-CH brands |
major only, with a randomised Not;A=Brand entry |
UA-CH fullVersionList |
the full four-part version, 152.0.<build>.<patch> |
Sec-CH-UA request header |
the network-layer copy of brands |
Sec-CH-UA-Full-Version-List |
the network-layer copy of fullVersionList |
The high-entropy hints are the interesting ones. navigator.userAgent has reported 0.0 for the last three components since the UA reduction, so the string alone carries only the major. fullVersionList carries the build and patch numbers, which is a much narrower value: not “Chrome 152” but one exact four-part build, which shipped on one particular day.
Two failure modes follow. The first is disagreement between them, where the UA says one major and fullVersionList says another - a straight contradiction, and one that happens whenever a persona edits the easy string and forgets the struct. The second is a fullVersionList whose build number never existed, or existed for a version other than the major it is paired with. A detector with a release table can check that; the release tables are public.
The engine has to match the number
Beyond consistency between the version fields, there is consistency between the version and the engine behind it. A build claiming 152 has to behave like 152, and the behaviours are observable:
- Which interfaces exist. Every major adds some and removes some, and
typeofis free. - The plugin face model, which changed shape at Chrome 94 and has been fixed since.
- ICU data: how many time zones and collations the build carries changes as ICU is updated.
- Feature-detection results for whatever shipped or was removed in the intervening versions.
This is why “just edit the User-Agent to the current major” is not a fix. It converts a version-lag signal into a version-inconsistency signal, which is worse: lag is unusual, and a browser whose claimed version does not match its own API surface is a lie. The version number is a claim about the engine, and the engine is right there to be asked.
The operational failure: the number in your dashboard is not the number on the wire
The most expensive version bug is not lag. It is not knowing what you are running.
Anyone running more than a handful of browser profiles ends up with two records of the version: the one a control panel displays, and the one the browser puts on the wire. They drift, and there are two independent reasons why.
The first is a data model with two sources of truth for one fact, where the writable one is not the authoritative one. A profile’s stored state decides which build actually launches; a separate field records what someone intended it to be. Editing the second changes a label and migrates nothing, so the panel reads current while the browser is two majors back and says so in every request it makes. The fix is straightforward once it is named: display the authoritative value, and make editing the other one either perform the migration or refuse.
The second is subtler and catches careful fleets. Automatic updates within a major do not cross majors. A mechanism that keeps profiles current inside one version keeps them on that version forever, quietly and successfully, while stable moves on past it. The update worked; it just was not the update that was needed.
The cost is not only the lag. The expensive part shows up during an unrelated incident, when the version becomes a suspect and hours go into that hypothesis before anyone confirms what was actually running. Instrumentation you do not trust is worse than none, because it produces confident wrong turns.
Checking it
The scanner-side check is deliberately conservative. Estimate the current stable major from a known reference release and the four-week cadence, compare against the claimed major, and flag only a lag of three or more. That threshold is not arbitrary: one major behind is a browser that has not restarted this week, two is a slow enterprise ring, three or more is a build that is not updating at all.
The reference point is a constant that has to be maintained. A check like this quietly becomes wrong if nobody touches it for a year, which is a property worth stating in the code rather than discovering in production.
On the operator side, the checks are different and more useful:
- Read the version from the browser, not from your own records.
navigator.userAgentandSec-CH-UA-Full-Version-Listat launch, logged. - Alert on drift between what a profile is supposed to run and what it ran.
- Treat a cross-major migration as an explicit operation with a visible result, because the automatic path does not do it.
The underlying point generalises past versions. Any fingerprint dimension where your control plane and the browser can disagree will eventually disagree, and you will find out during an incident, while forming a theory about something else entirely.