A clock with a step
performance.now() returns milliseconds since the page's time origin from a monotonic clock. The High Resolution Time specification tells browsers to coarsen it to at least 100 microseconds, or 5 when the page is cross-origin isolated (served with COOP and COEP headers), and allows jitter on top. Engines are free to be coarser, and they are, in different ways. Any page can measure the result: read the clock in a tight loop and keep the smallest positive difference.
| Engine and context | Smallest step |
|---|---|
| Chrome 156, normal page and its worker | 0.09999847412109375 ms (100 microseconds) |
| Chrome 156, cross-origin isolated | 0.0049991607666015625 ms (5 microseconds) |
| Firefox default | 1 ms |
| iOS Safari 26, cross-origin frame | 1 ms |
Date.now(), all of the above | 1 ms |
Chromium rounds each reading to the start or end of its interval using a pseudorandom threshold, so occasionally two readings differ by 0.2 ms, but the smallest step stays at 0.1.
Float artefacts in Chrome's readings
Chrome's readings are not clean decimals: a run looks like 57, 57.10000038146973, 57.20000076293945. On Linux, Chrome reads the absolute monotonic clock, converts it and the time origin to milliseconds as doubles, and subtracts. The absolute value is large, so its doubles are coarsely spaced, and that spacing survives the subtraction. On a test machine with 134 days of uptime every reading fell on a 2-19 ms grid, which is why its measured step was 0.09999847412109375 rather than 0.1.
Two consequences follow. A clock emulated in JavaScript that returns clean multiples of 0.1 does not match Chrome's arithmetic. And the exact artefacts vary with how long the machine has been up, a weak hint that is an inference from the arithmetic rather than a tested population claim.
Why timing probes read the clock first
Challenge widgets report several durations: a WebAssembly benchmark, a proof of work with a fixed time budget, WebSocket round trips used for latency checks, and a file flush time. All are measured with performance.now(), and one widely deployed widget measures the clock step first, with 5,000 reading pairs in a worker.
Knowing the step, a server can check that it fits the claimed browser and context, that every reported duration is quantised the way that clock quantises, and that the speeds are plausible for the device. A Safari User-Agent with Chromium-quantised durations fails the second check even if every speed looks right. This complements clock coherence detection, which checks that the browser's clocks agree with each other.
The step comes from the engine, so the only reliable way to match it is to run the engine the session claims. That is the case for a managed web data API running real browsers rather than one engine dressed as another.
