performance.now() is deliberately clamped to block Spectre-style timing attacks. How the clamp works, why isolation restores precision, and what it costs.
In August 2026, Cloudflare published a write-up revisiting Spectre attacks on Workers with an uncomfortable finding: during CPU-only execution, Date.now() and performance.now() are frozen solid — no continuously advancing clock at all — yet a plain WebSocket to an external server serving timestamps was enough to reliably recover sub-millisecond timing resolution anyway. A local defense, sidestepped by borrowing a clock from the network. Browsers hit this exact wall years earlier, for the same reason, and they never fully solved it either. This is how performance.now() got blurred, what "blurred" actually means in numbers, and why the clock your JavaScript reads is itself something a page can fingerprint you with.
Key Takeaways
performance.now()is deliberately coarsened, not buggy — it's a defense against Spectre-class cache-timing side channels that need a precise clock to work.- The W3C High Resolution Time spec defines a two-tier resolution model: about 100 microseconds in an ordinary page, about 5 microseconds when the page is cross-origin isolated.
- Cross-origin isolation is the same COOP/COEP gate that unlocks
SharedArrayBuffer— the two protections travel together, because a shared buffer polled in a tight loop is itself an alternative clock. - The clock's behavior is now a fingerprinting surface in its own right: which resolution tier you get, how much jitter is layered on top, and whether an anti-fingerprinting mode is coarsening things further all differ by browser and by configuration.
- The same restriction that blocks cache attacks puts a real floor under legitimate in-browser measurement — Cloudflare's Workers finding and a browser's clamped
performance.now()are the same trade-off, worn by different layers of the stack.
Why Browsers Clamp Time At All
performance.now() didn't start out blurred. The original design goal, per its MDN reference, was sub-millisecond precision: a monotonic clock, immune to system-clock adjustments, good enough to time individual function calls. For several years that's exactly what it delivered.
Then came Spectre. Speculative-execution attacks work by timing memory accesses: a cache hit takes nanoseconds, a cache miss takes tens of nanoseconds more, and that tiny gap is enough to leak data an attacker was never supposed to see — one bit at a time, across thousands of measurements. The attack doesn't need to read memory directly. It needs a clock precise enough to tell a hit from a miss. Take away that clock, or make it noisy enough, and the same code that leaked data cleanly now leaks noise.
That's the rationale the hr-time-3 spec spells out directly. Its security section names "cache attacks, statistical fingerprinting and micro-architectural attacks" as the concern, because a malicious site can use high-resolution timing of ordinary browser operations to single out a particular user or surface same-process data it was never handed. The spec's answer is a defined "coarsen time" algorithm — every browser implementing the spec has to reduce a raw high-resolution timestamp's accuracy, and may add jitter and throttle repeated calls on top of that. Browser vendors shipped this within weeks of Spectre's 2018 disclosure, coarsening performance.now() overnight from sub-microsecond ticks to steps measured in tens or hundreds of microseconds — a full millisecond in some engines — and it has stayed clamped ever since.
The Two-Tier Model: What Cross-Origin Isolation Buys Back
Clamping every page to the same coarse resolution forever would have broken real measurement use cases — WebAssembly audio processing, video codecs, and scientific computing in the browser all lean on performance.now() for timing that actually matters. The spec's compromise is a second, finer tier, unlocked only for pages that opt into isolation.
A document becomes cross-origin isolated by serving two response headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
That pair severs the page's browsing context from cross-origin openers and popups, and restricts what cross-origin resources it can embed without their explicit opt-in. In exchange, window.crossOriginIsolated flips to true, and per the coarsen-time algorithm's own resolution steps, the clamp relaxes: roughly 100 microseconds of resolution in an ordinary page, roughly 5 microseconds once isolation is active. Neither number is a hard universal constant — the spec phrases both as a minimum resolution "or higher," so individual engines are free to clamp more aggressively, and often do.
The same isolation flag also gates SharedArrayBuffer. That's not a coincidence bundled in for convenience: a shared, mutable buffer that two threads can poll in a tight loop is itself a timing primitive, one Spectre researchers demonstrated using to reconstruct a high-resolution clock even after performance.now() was clamped. Isolating the page closes both doors — the direct clock and the improvised one — at the same time, which is why they're gated by the identical header pair rather than two separate opt-ins.
Date.now() sits outside this whole system. It was already capped at one-millisecond resolution before any of this, tracks wall-clock time rather than a monotonic counter, and doesn't get more precise under isolation — the coarsen-time algorithm and the isolation bonus both apply specifically to the high-resolution timers, not the original Date clock.
The Fingerprinting Angle: The Clock Itself Is a Signal
Once resolution depends on isolation state, engine, and configuration, the clock stops being neutral infrastructure and becomes one more thing a page can read about you. A script can measure its own performance.now() jitter — call it in a tight loop and look at the gaps between successive readings — and infer which resolution tier it's getting, which hints at whether the page is isolated the way it expects to be, and which engine's particular rounding behavior produced that pattern.
Some browsers push this further on purpose. Firefox's privacy.resistFingerprinting preference, always on in Tor Browser, deliberately rounds timers to coarser steps than the spec strictly requires, on top of the baseline Spectre mitigation — the same uniformity philosophy we covered in detail for RFP generally: make every protected browser report the same blunt values, so no individual instance's timer jitter stands out from the crowd. A page that fingerprints based on sub-millisecond timing noise gets far less signal from an RFP-protected browser than from a stock one, which is itself a detectable difference.
This is the same territory covered in our broader browser fingerprinting guide: a signal doesn't have to be a unique identifier to be useful. Timer resolution and jitter are low-entropy on their own — they mostly just say "Chromium" or "Firefox with RFP on" rather than picking one visitor out of a crowd — but stacked with dozens of other signals, that's exactly the kind of contribution the entropy accounting in our piece on fingerprint entropy and anonymity sets is built to quantify: small individually, non-zero in aggregate.
If you'd rather see that stack than read about it, BrowserInsight's fingerprint check runs entirely in your own browser and lists the attributes a page can read from you side by side — canvas, WebGL, fonts, hardware hints — which is the honest way to judge how much any one low-entropy signal like timer behavior is really contributing to your own profile.
The Detection Angle: Timing Catches What Emulation Can't Fake
Clamped or not, performance.now() is still precise enough to catch something else entirely: work that didn't actually happen the way it claims to have happened. Real GPU rendering, real JIT compilation, and real hardware decoding all carry a characteristic cost — not a single measurement, but a distribution of durations across many repeated operations, shaped by real silicon warming caches and scheduling work across real cores. A software rasterizer standing in for a GPU, or a browser running inside heavy instrumentation, tends to reproduce the result of that work convincingly while failing to reproduce its timing distribution — too uniform, too fast, or too slow in ways that don't match any real device.
This is one of the trusted-versus-untrusted comparisons that CreepJS's lie-detection model relies on: run the same computation on the main thread and inside a Worker, or compare a claimed capability against the timing signature that capability should produce, and flag the cases where they disagree. It's also part of why headless and automated browsers get caught even after their obvious tells — a missing navigator.webdriver flag, a suspicious user agent — have been patched over. Faking the shape of a value is achievable. Faking the timing distribution that produces it under a coarse, jittered clock is a much harder target to hit, precisely because the clock doing the catching is the same clamped, noisy one Spectre mitigations left behind.
The Cost: Precision You Don't Get Back
Cloudflare's Workers finding and the browser's performance.now() clamp are the same trade-off at different points in the stack: freeze or coarsen the clock, close a side channel, and accept that legitimate measurement gets noisier as a side effect. Cloudflare's researchers showed that a network round-trip to an external clock can restore precision an attacker wants back. A page running in your browser doesn't get that same escape hatch for its own internal measurements — every duration a script times with performance.now(), including the ones behind in-browser network diagnostics, inherits the coarsen-time floor and whatever jitter is layered on top of it.
That floor is small in absolute terms — around 100 microseconds outside isolation, closer to 5 inside it — which matters little for anything measured in whole seconds. But it's one more source of run-to-run noise stacked on top of the real variability already inherent to measuring a live network path, the kind our piece on why speed test results vary walks through from the network side. The clock isn't the dominant source of that jitter. It's just never fully quiet, by design, and it never will be — the same design that keeps a page from reading your cache one bit at a time.
Frequently Asked Questions
Why did browsers make performance.now() less precise?
To close a Spectre-class side channel. Cache-timing attacks need a clock precise enough to distinguish a cache hit from a cache miss, which takes only nanoseconds. Coarsening the clock and adding jitter, as the W3C spec's "coarsen time" algorithm requires, makes that distinction unreliable without breaking the timer for ordinary use.
Does this mean I can't measure performance accurately in the browser?
Not for anything on a normal human timescale. The clamp costs you somewhere around 100 microseconds of resolution outside isolation, or about 5 microseconds inside it — negligible for timing a network request or a render pass, but real enough to notice if you're trying to time individual memory accesses, which is exactly the point.
How can I tell if a page is cross-origin isolated?
Check window.crossOriginIsolated in the console — it's true only if the page served both a Cross-Origin-Opener-Policy: same-origin header and a Cross-Origin-Embedder-Policy header, per MDN. Most ordinary pages don't bother, since isolation restricts what cross-origin content they can embed.
Does Firefox's resistFingerprinting change timer precision further?
Yes. On top of the baseline Spectre-driven clamp every browser applies, RFP rounds timers to coarser, more uniform steps specifically so that timer-jitter fingerprinting yields less signal — the same uniformity approach we cover in our RFP deep dive.


