VSync Jitter as a Noise Source in Web Browsers (JavaScript)

It is challenging to implement dual-clock jitter based noise source in modern systems, given that timing-related APIs are all backed by TSC or TSC-like clocks underneath nowadays, especially in web browsers, where our access to hardware is heavily restricted. But I think I’ve just found a way to implement it. We can exploit the browser’s VSync jitter by measuring the time between browser content repaints. The two JavaScript functions we can use to implement this are performance.now() and requestAnimationFrame().

The performance.now() function returns a high-resolution, monotonic timestamp in milliseconds, with microsecond precision in the decimal part on some browsers. It is based on the OS-provided timer, which is usually backed by a TSC or TSC-like clock. It is well-suited for precise interval measurements in web browsers.

The requestAnimationFrame(), on the other hand, is used for synchronizing code execution with the display’s refresh cycle. When a callback function is scheduled with requestAnimationFrame(), the browser invokes it just before the next repaint of the browser content. On a standard 60 Hz monitor, requestAnimationFrame() will call the specified callback function ~60 times per second when scheduled repeatedly. Internally, this scheduling is tied to the display’s vertical synchronization (VSync) interrupt, which means its timing is controlled by the display’s hardware clock.

To extract noise, we can measure the elapsed time between consecutive requestAnimationFrame() callbacks using performance.now(). Since requestAnimationFrame() callback timing is tied to the display’s hardware clock, while performance.now() advances on a separate clock, there are tiny variations (jitter) that can be used as a noise source.

Basic Implementation

let samples = [];
let previousTimestamp = null;

function vSyncTick(_) {
    let currentTimestamp = performance.now();
    if (previousTimestamp !== null) {
        let timeDifference = currentTimestamp - previousTimestamp;
        samples.push(timeDifference);
        previousTimestamp = null;
    } else {
        previousTimestamp = currentTimestamp;
    }
    requestAnimationFrame(vSyncTick);
}
requestAnimationFrame(vSyncTick);

Note: Notice that I sample at every other frame (resetting previousTimestamp to null every time timeDifference is measured and saved instead of setting it to currentTimestamp). This is for eliminating the strong negative lag-1 autocorrelation that I previously encountered when I first implemented a sensor timing jitter based noise generator. It is caused by the compensatory behavior of the interval samples produced using that method. If one interval is long, its next interval is likely short, and vice versa.

Analysis

These data are generated using Chrome 139 and Firefox 141 running on Windows 10 connected to a 60 Hz DELL S2721H monitor.

I observed that Chrome’s performance.now() has a resolution of 100 microseconds, while Firefox’s performance.now() has a resolution of 20 microseconds.

vsync-jitter-chrome-multipliedby10.csv (4.0 KB)
vsync-jitter-firefox-multipliedby50.csv (5.0 KB)

Time Series Plot & Histogram

Chrome

Firefox

Autocorrelation

Chrome

Firefox

FFT

Chrome

Firefox


Unlike the noise from sensor timing jitter I implemented and shared before which is mostly stable, the noise in this one often surges and drops sharply, creating a lot of outliers. The FFTs also have more pronounced peaks. While many randomness extraction methods won’t mind them, I don’t know if these characteristics can have an impact on MMI.

1 Like

I found this website visualizing VSync jitter in real time

https://www.vsynctester.com/

Higher Resolution performance.now() in Browsers using Cross-Origin Isolation

In 2018, browsers intentionally lowered the performance.now() resolution and possibly added artificial jitter to prevent a malicious script from abusing it to trick the CPU into accessing memory it shouldn’t. (see Meltdown & Spectre attack)

But high resolution performance.now() capability can be brought back by sending the following cross-origin isolation headers from the server:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Updated Analysis (with cross-origin isolation and more samples)

Chrome resolution: 5 microseconds

Firefox resolution: 20 microseconds (I’ve already used cross-origin isolation headers in my previous test)

vsync-jitter-chrome-8192samples-multipliedby200.csv (40.0 KB)
vsync-jitter-firefox-8192samples-multipliedby50.csv (32.0 KB)

Time Series Plot & Histogram

Chrome

Firefox

Autocorrelation (Lag 1-1000)

Chrome

Firefox

FFT

Chrome

Firefox


There is a pattern in both browsers. Autocorrelation analysis shows periodic correlation at lag 30, 60, 90, and so on. It seems that the periodicity is related to the sampling rate, which is 30 Hz (half of the monitor refresh rate since I sample at every other frame, see post above). FFT analysis shows high peaks at 0.5 Hz and its harmonics.

Listening to the Noise

I turned the samples into PCM and sped up the noise ~267x using Audacity so I can hear it (8000 Hz sampling rate). Here’s the result

Chrome

Firefox

Firefox’s noise periodicity can be seen clearly here

Thanks for this, I finally got around to looking into in-browser noise sources. Two things came out of it that might be useful here.

Chromium’s performance.now() clamping (third_party/blink/renderer/core/timing/time_clamper.cc) rounds each timestamp down to the resolution (100 µs, or 5 µs with cross-origin isolation) and then rounds it up one step if it lies above a threshold computed as MurmurHash3(clamped_time ^ secret_), with secret_ = base::RandUint64() per instance. So the low bits of any interval measured with performance.now() in Chrome are partly the output of a keyed hash, not of the timing itself. Coarse features (e.g. interval above/below the running median) are mostly unaffected when the physical jitter spans many resolution steps; the lowest bits aren’t. I haven’t checked Firefox’s implementation.

I tried a different approach: a browser noise source that avoids the browser’s timers altogether.With cross-origin isolation (COOP same-origin, COEP require-corp), SharedArrayBuffer is available, so we can build our own clock:

  • a Web Worker spins Atomics.store(counter, 0, ++i) in a SharedArrayBuffer as fast as it can (no timer, no PRNG);
  • a second worker repeatedly does a fixed memory walk (256 accesses with a data-dependent stride over an 8 MB buffer) and reads the counter before and after each one;
  • the tick count per walk varies with cache misses, interrupts, scheduling and frequency changes: CPU execution-time jitter, the same principle as Stephan Müller’s jitterentropy.

Conditioning is deterministic and non-expanding only: either the XOR of the low 8 bits of each delta (1 bit per sample), or the low 4 bits as they are (4 bits per sample). No hashing, no PRNG.

Results (headless Chromium 150, Linux, 14 cores):

Sample rate ~57,000–65,000 samples/s
Deltas mean ~1,800 ticks, sd ~750; raw lag-1 autocorrelation ~0.5 (slow drift, probably frequency scaling), so only low bits are used
Min-entropy (most-common-value, SP 800-90B 6.3.1 bound) 7.6–7.8 bits/sample for delta mod 256, 3.96 for mod 16
Throughput ~60 kbit/s (XOR8) or ~240 kbit/s (low 4 bits)
ent3000 tests XOR8, 150 KB: 15/15, score 0.33. Low 4 bits, 200 KB: 15/15, score 0.42, depth 20.6+ bits; 600 KB: 14/15 (ChiBit p = 0.003), score 1.45 (pass), depth 22.2+ bits

Smaller workloads (16–128 accesses, 1–4 MB) run up to 600k samples/s but give coarse deltas whose low bits are visibly biased; the workload has to be heavy enough that each sample spans thousands of counter ticks.

Caveats: occasional zero deltas, so the first few thousand samples after start-up should be discarded (JIT warm-up). The min-entropy figures are simple estimates, not a full non-IID assessment. Part of the jitter is OS non-determinism rather than pure physical noise.

Next, I want to try an AudioWorklet (sound-card clock against the counter clock) as a second, independent source, to combine the two by majority vote.

AudioWorklet followup: A worklet reads a SharedArrayBuffer counter on every 128-frame quantum: the sound-card clock against a CPU clock, like your VSync idea but without the browser’s timer. The catch is that browsers render audio in batches (here 512 frames), so only the batch starts carry the sound-card clock. The deltas within a batch are mostly CPU jitter again. In Waterfox with a real sound card: 98.7 batch starts/s, min-entropy ~7 bits (mod 256), about 365 bits/s from the low 4 bits, and 15/15 on small samples (5.5 KB). Headless browsers have no real audio device, so test in a normal window.