Skip to content
Anti-Bot

What Is Timer Precision Fingerprinting? The Step of performance.now()

Pim

Pim · Scrappey Research

October 28, 2026 3 min read

What Is Timer Precision Fingerprinting? The Step of performance.now() — conceptual illustration
On this page

Timer precision fingerprinting measures the smallest step between two performance.now() readings and uses it to identify the browser engine and context. Since the Spectre attacks of 2018, browsers coarsen their high-resolution clock, but each engine does it differently: Chrome uses 100 microseconds in a normal page and 5 in a cross-origin-isolated one, Firefox 1 millisecond by default. Every duration a page measures inherits that step, so a session whose timings are quantised like one engine while its User-Agent names another contradicts itself.

Quick facts

Chrome 1560.1 ms step (0.09999847... ms); 0.005 ms when cross-origin isolated
Firefox1 ms by default; 16.67 ms with resist-fingerprinting
SafariContext-dependent: whole ms in a cross-origin frame, 0.02 ms elsewhere in captures
WorkersA dedicated worker gets the same clamp as its page
Measured withThousands of back-to-back reading pairs, keeping the smallest positive difference

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 contextSmallest step
Chrome 156, normal page and its worker0.09999847412109375 ms (100 microseconds)
Chrome 156, cross-origin isolated0.0049991607666015625 ms (5 microseconds)
Firefox default1 ms
iOS Safari 26, cross-origin frame1 ms
Date.now(), all of the above1 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.

Code example

javascript
// Measure the performance.now() step in a worker, the way a widget does.
// Run in the browser console.
const src = `
  let min = Infinity;
  for (let i = 0; i < 5000; i++) {
    const a = performance.now();
    let b = performance.now();
    while (b === a) b = performance.now();   // wait for the next tick
    if (b - a < min) min = b - a;
  }
  postMessage(min);`;
const w = new Worker(URL.createObjectURL(new Blob([src])));
w.onmessage = (e) => {
  console.log('smallest step (ms):', e.data);
  // Chrome: ~0.1 (or ~0.005 if crossOriginIsolated), Firefox: 1
  console.log('crossOriginIsolated:', self.crossOriginIsolated);
  w.terminate();
};

Related terms

Concept map

Concept map

How Timer Precision Fingerprinting connects

The terms most directly tied to this one. Hover a node to see its neighbours, click to preview, drag to rearrange.

0 terms · 0 connections
You are here · Anti-Bot
Building map…

Frequently asked questions

Why does Chrome round performance.now() to 100 microseconds?

To limit timing side-channel attacks such as Spectre. The High Resolution Time specification asks browsers to coarsen the clock to at least 100 microseconds, or 5 microseconds when the page is cross-origin isolated, and Chrome uses exactly those values.

Why does performance.now() return values like 57.10000038146973?

Because Chrome subtracts two large doubles: the absolute monotonic clock reading and the time origin. The spacing of large doubles survives the subtraction as small artefacts, and the exact pattern depends on the platform and how long the machine has been running.

Can a page tell Firefox from Chrome by the timer alone?

Usually, yes. Firefox steps by 1 ms by default and Chrome by 0.1 ms in a normal page, so a few thousand readings separate them. A session that claims one browser and shows the other's step is contradicting itself.

Last updated: 2026-10-28 · Facts last verified: 2026-10-28