Skip to content

Why must a worker report the same values as the page?

A page is not one JavaScript realm. When you call new Worker(...) or new SharedWorker(...), the browser constructs a fresh global scope with its own WorkerNavigator object, built by the engine from the same underlying values the main thread reads. Nothing from the page world is carried across: a worker has no window, no document, and — critically — no memory of anything a script did to the page's navigator.

That is the whole mechanism. An Object.defineProperty override is a property of one realm's global object. It cannot reach a realm the engine builds later, from scratch, in a separate context. So a userland spoof that rewrites navigator.platform or hardwareConcurrency on the page leaves the worker answering honestly — and a detector reading both sides sees one browser reporting two different values for the same immutable fact. It doesn't need to know which one is true; the disagreement alone is information no single reading could have produced.

The audit makes this concrete. It races a dedicated Worker and a SharedWorker in parallel — both spawned from blob: URLs running one identical probe body, each with a 2500 ms timeout — and diffs four fields against the window: userAgent, platform, hardwareConcurrency and deviceMemory. It also compares a computed engine id — the RangeError message length from (-1).toFixed(-1), plus the length of the Array constructor's source with its name stripped out — which catches a worker realm running a different JS engine than the window's. (Whether that engine matches the one the User-Agent claims is a separate check.) Any divergence is worker-realms-agree, severity critical. A separate worker-gpu-agrees check reads UNMASKED_RENDERER_WEBGL from an OffscreenCanvas(32, 32) inside each worker and compares the renderer string — renderer only, not vendor — at severity warn, since the renderer is a softer, more environment-dependent signal. See WebGL fingerprinting for why that string matters.

One nuance matters for anyone building this kind of check: language and languages are deliberately excluded from the diff. The probe reads them, but the comparison ignores them, because Chrome has a long-standing legitimate divergence between the window and worker on those fields — asserting on it would fail stock, unmodified browsers. Knowing when not to assert is what separates a signal from noise. Likewise, if no worker scope answers at all, the check reports not-applicable (pass: null) rather than a pass: nothing answered and clean are different epistemic states.

The practical implication is that patching only the main thread does not hide a value — it converts a plain reading into a contradiction, which is strictly more legible than the original value was. Chasing it by also injecting into every worker is fragile, because each new realm (nested workers, iframes, service workers) is another place the patch must reach. The durable answer is to set identity below JavaScript, at the browser layer, where every realm reads from one source. Clearcote is one open-source implementation of that idea. For the full treatment of realms and why they're the sharpest coherence test available, read cross-realm coherence, or how anti-bot systems detect automation for the wider picture.