AudioWorklet DSP Timing: The Fingerprint Surface Beyond OfflineAudioContext

13 min read

Imagine a set of browser profiles on one machine. Each has its own canvas output, its own WebGL strings, a font list that matches its claimed operating system and its own OfflineAudioContext hash. On the value level, every fingerprint differs.

Now look at timing. Profiles running on the same physical machine share the same audio hardware, and the AudioWorklet processing thread, the part of the Web Audio API that talks to the sound device, produces callback timing that reflects that hardware. The waveform hash is one signal. The rhythm of the process() callback is another.

This matters if you build or defend a site and want to understand what fingerprinting scripts can read, if you run QA across several profiles on one machine, or if you care about privacy and want to know what a page can learn about your hardware.

See your own values first. We built a public checker at justbrowser.app/fingerprint-checker, client-side with no signup. It compares your WebRTC candidates against your public IP and checks whether the IP's location has a UTC offset that agrees with your browser's timezone. Worth running before you read the rest of this, so the signals below have your numbers attached to them.

What You'll Learn

By the end of this:

  1. How AudioWorklet timing differs from classic OfflineAudioContext fingerprinting
  2. Why processing-thread latency reflects hardware
  3. How to measure timing on your own profiles
  4. What can and cannot be controlled from JavaScript

Fair warning: this goes into Web Audio API internals, real-time scheduling and DSP callback mechanics. For background on the waveform-hash side of audio fingerprinting, the audio fingerprinting deep-dive covers the foundation. This post is about the timing surface on top of it.

Prerequisites

  • A multi-account browser with configured profiles (JustBrowser, Multilogin, or similar), or just a normal browser for the measurement
  • Chrome DevTools familiarity (Performance tab, Sources panel)
  • Basic understanding of the Web Audio API
  • Optional: a second machine to compare timing signatures

If you have never touched AudioWorklet before, the relevant parts are explained as they come up.

Step 1: Understand the Architecture

ScriptProcessorNode (deprecated, but still works) ran audio processing on the main thread. Every time JavaScript processed audio, it interrupted whatever else the page was doing, which meant high latency and glitchy audio.

AudioWorklet replaced it in Chrome in 2018. The key difference: AudioWorklet runs in a separate, dedicated audio thread with its own execution context, its own JavaScript scope, and direct access to the audio hardware callback.

Here is the basic structure:

// main.js - runs on main thread
const audioContext = new AudioContext();

await audioContext.audioWorklet.addModule('processor.js');

const workletNode = new AudioWorkletNode(audioContext, 'timing-processor');
workletNode.connect(audioContext.destination);
// processor.js - runs in audio thread
class TimingProcessor extends AudioWorkletProcessor {
  process(inputs, outputs, parameters) {
    // Called once per 128-frame block (~2.9ms of audio at 44.1kHz)
    // WHEN these calls arrive is shaped by the audio hardware
    return true;
  }
}

registerProcessor('timing-processor', TimingProcessor);

That process() callback fires with real-time priority. The browser schedules it based on the audio hardware's buffer requirements, so its timing is shaped by the sound device's interrupt timing, the driver's buffer implementation and the CPU's real-time scheduling behaviour.

Different hardware means different timing patterns, and that is what makes it a fingerprinting surface.

Step 2: Measure Timing Jitter

Create a test page that measures process() callback timing. One detail matters: currentTime inside the worklet is the audio clock, which advances by exactly one 128-frame block per call, so it cannot show jitter. Use a wall-clock reading instead. Date.now() is available in the worklet scope; its millisecond resolution is coarse, but enough to see the pattern.

// processor.js
class JitterMeasurer extends AudioWorkletProcessor {
  constructor() {
    super();
    this.lastTime = null;
    this.samples = [];
  }

  process(inputs, outputs, parameters) {
    const now = Date.now(); // wall clock, in milliseconds (currentTime is the audio clock and never jitters)

    if (this.lastTime !== null) {
      const delta = now - this.lastTime;
      this.samples.push(delta);

      if (this.samples.length === 1000) {
        // Send timing data back to main thread
        this.port.postMessage({
          samples: this.samples,
          mean: this.samples.reduce((a, b) => a + b) / this.samples.length,
          variance: this.calculateVariance()
        });
        this.samples = [];
      }
    }

    this.lastTime = now;
    return true;
  }

  calculateVariance() {
    const mean = this.samples.reduce((a, b) => a + b) / this.samples.length;
    return this.samples.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / this.samples.length;
  }
}

registerProcessor('jitter-measurer', JitterMeasurer);

Run this on two different machines. What you can look at:

  • Mean callback interval: over a long run, about 2.9 ms at 44.1 kHz (128 / 44100) or about 2.7 ms at 48 kHz, because the browser has to keep up with the audio clock.
  • Variance: the part that differs between setups. Hardware with stable interrupt timing shows low variance; some USB audio interfaces show more.
  • Distribution shape: some hardware produces roughly even spacing, some produces bimodal patterns, for instance when the audio device asks for several 128-frame blocks at once and the calls arrive in bursts.

Different machines can produce distinct jitter signatures, which is exactly why scripts that collect this data treat it as a hardware signal.

Step 3: Understand Why Sites Collect This

Classic OfflineAudioContext fingerprinting renders a silent audio signal through an oscillator and compressor chain and hashes the output samples. The hash varies by audio hardware and driver implementation, and it persists across sessions.

Timing is a different channel. A browser can return a controlled waveform from the OfflineAudioContext render while the AudioWorklet thread still runs on the real hardware. Fingerprinting vendors can measure both. For a fraud team or a site owner, the useful part is a consistency check: does the waveform hash fit the timing behaviour the device shows? For a privacy-minded user, the point is that a page can learn something about your hardware from rhythm alone, with no permission prompt.

Timing also shows that several profiles share one machine, even when their other values differ. That is relevant to anyone running QA or ad verification across many profiles on one computer: the profiles are separate browsers with their own cookies, storage, proxy and identity parameters, but they still run on one set of hardware, and a page that measures timing can see that. It does not change what a platform's rules allow. Client accounts managed with permission under each platform's multi-account rules, ad verification, OSINT research and QA are all fine uses; this vector just tells you what a page can observe.

Step 4: Test Your Current Setup

Open a profile in your browser. Create these two files and serve them locally (or use a simple HTTP server):

<!-- index.html -->
<!DOCTYPE html>
<html>
<head><title>AudioWorklet Timing Test</title></head>
<body>
  <button onclick="startTest()">Start Timing Test</button>
  <pre id="output"></pre>
  <script>
    async function startTest() {
      const ctx = new AudioContext();
      await ctx.audioWorklet.addModule('worklet.js');

      const node = new AudioWorkletNode(ctx, 'timing-test');
      node.port.onmessage = (e) => {
        document.getElementById('output').textContent = JSON.stringify(e.data, null, 2);
      };
      node.connect(ctx.destination);
    }
  </script>
</body>
</html>
// worklet.js
class TimingTest extends AudioWorkletProcessor {
  constructor() {
    super();
    this.times = [];
    this.startTime = null;
  }

  process() {
    if (!this.startTime) this.startTime = Date.now();

    this.times.push(Date.now()); // wall clock in ms; the audio clock (currentTime) never jitters

    if (this.times.length >= 2000) {
      const deltas = [];
      for (let i = 1; i < this.times.length; i++) {
        deltas.push(this.times[i] - this.times[i-1]);
      }

      const mean = deltas.reduce((a,b) => a+b) / deltas.length;
      const variance = deltas.reduce((s,v) => s + (v-mean)**2, 0) / deltas.length;
      const min = Math.min(...deltas);
      const max = Math.max(...deltas);

      this.port.postMessage({
        samples: deltas.length,
        mean_ms: mean,
        variance: variance,
        jitter_range_ms: max - min,
        signature: variance.toFixed(10)
      });

      this.times = [];
    }
    return true;
  }
}
registerProcessor('timing-test', TimingTest);

Run this test in three different profiles on the same computer and write down the variance and jitter_range_ms values. If they land close together, that is expected: the profiles share one set of audio hardware, and timing follows the host.

Now run the same test on a different physical machine. You should see different variance values. That is the hardware showing through.

Step 5: Why JavaScript Cannot Change This Signal

Adding random delays in process(): A privacy script might add Math.random() jitter to each callback. But process() runs in a real-time audio thread, so delays cause audible glitches, and the result is an unusual timing pattern rather than a common one.

Blocking the audio thread: Briefly blocking the thread to normalise timing breaks real audio playback and introduces artifacts. It also causes sites that use WebRTC or media playback to malfunction.

Disabling AudioWorklet entirely: You could make AudioWorklet fail to load, but legitimate sites use it for audio processing, so they break, and the absence is itself information about the browser.

JavaScript injection into worklet scope: You cannot inject from the main thread into AudioWorklet scope. The worklet runs in a separate context, so an extension has no access to it.

The timing happens at a layer JavaScript cannot reach, which is a good thing for the web platform and worth knowing if you are designing tests.

Step 6: What an Engine-Level Approach Covers

Anything that changes audio behaviour has to live in the browser engine. In Chromium, audio thread timing is managed in media/audio/ and third_party/blink/renderer/modules/webaudio/. The process() callback fires from AudioWorkletGlobalScope::Process(), scheduled by the audio device thread, and the timing comes from the underlying audio backend: CoreAudio on macOS and WASAPI on Windows.

JustBrowser is a multi-account browser built on a custom Chromium engine with native C++ integration, not an extension. It applies per-profile, seeded audio noise at the C++ level so the rendered waveform hash differs per profile: different profiles get different hashes, and the same profile across sessions gets the same one. AudioWorklet callback timing is a separate, hardware-driven surface that we do not currently claim to normalise. Test it with the script above.

The same holds for tools that layer JavaScript on top of stock Chromium: this surface follows the host hardware. For automation workflows, you can connect plain Playwright or Puppeteer to a profile through the REST API and CDP and run these tests programmatically.

Common Errors and Fixes

AudioWorklet fails to load with "Failed to load module script"

Cause: You are serving the worklet file from a different origin, CORS headers are missing, or the file path is wrong.

Fix: Serve both HTML and worklet.js from the same origin. Check the Network tab in DevTools to see if the file is loading. AudioWorklet module scripts require same-origin or proper CORS headers.

Variance is 0 or NaN

Cause: Not enough samples collected, the audio context is suspended, or the test is timing process() with currentTime, which advances by a fixed step and always gives a variance of zero.

Fix: Time the calls with Date.now() as in the code above, and wait longer; the test needs about 2000 samples (a few seconds at 44.1kHz). If nothing arrives at all, the audio context may be suspended. Click the page first to satisfy the user-gesture requirement.

Timing differs wildly between runs on the same profile

Cause: Background processes are interfering with audio thread scheduling, or the machine is under heavy CPU load.

Fix: Close other applications and let the system settle. Run the test 3-4 times and take the median. Some variance between runs is normal, but the overall pattern should be consistent.

Waveform hash differs after restart

Cause: The tool you use may randomise audio settings on each launch.

Fix: Waveform output should be stable per profile. For JustBrowser users, the audio noise seed is pinned per profile, as part of the profile identity, like canvas or WebGL. Worklet timing itself follows the host hardware.

Next Steps

You now have a test for AudioWorklet timing. From here:

  • Audit your profiles: Run the timing test on the profiles you use in production and document the variance values, so you know what a page could measure.
  • Compare across machines: Run the same test on different physical hardware to see how much of the pattern comes from the host.
  • Combine with other tests: Timing is one vector. Check canvas and GPU signals and the fingerprint checker and the TLS fingerprint explainer for the others, and BrowserLeaks for canvas and WebGL.
  • Monitor ongoing research: The audio fingerprinting space is evolving. Follow FingerprintJS publications and browser security research for new techniques.

If you monitor click quality with ClickzProtect, consistent test profiles matter for your own measurement. And if you track conversion data with JustAnalytics, the same isolation principles apply to the profiles you measure from.

The bottom line: OfflineAudioContext fingerprinting is well known. AudioWorklet timing is the next layer, less documented and impossible to alter from JavaScript. Measure it, know what your setup exposes, and decide what that means for your use case.

Frequently Asked Questions

How is AudioWorklet fingerprinting different from OfflineAudioContext fingerprinting?

OfflineAudioContext fingerprinting hashes the output waveform from a silent audio pipeline: the actual rendered audio samples. AudioWorklet fingerprinting measures timing jitter and processing latency in the dedicated audio thread. Same API family, completely different signals. A waveform hash can be controlled per profile while callback timing still follows the host hardware.

Why does AudioWorklet timing vary between devices?

AudioWorklet runs in its own real-time thread with direct access to the audio hardware callback. The timing of each process() call depends on buffer sizes, sample rates, hardware interrupt latency, CPU scheduling, and driver implementation. These factors create a timing pattern that differs between hardware and software combinations.

Can I detect AudioWorklet timing measurement on sites I visit?

Open DevTools, go to the Performance tab, and record while the page loads. Look for AudioWorkletProcessor entries in the flame graph. If you see repeated process() calls being measured, the page may be using timing analysis. You can also check the Sources panel for AudioWorklet module registrations.

How does JustBrowser handle AudioWorklet timing?

JustBrowser is a multi-account browser built on a custom Chromium engine. It applies per-profile, seeded audio noise at the C++ level, so each profile has its own stable waveform hash. AudioWorklet callback timing is a separate, hardware-driven surface that follows the host machine, and you can measure it with the test in this post.


Try JustBrowser

A multi-account browser for teams, built on a custom Chromium engine with native C++ integration, not an extension. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, across 40+ identity parameters. Each profile keeps its own cookies, storage and proxy, and teammates can share profiles. Local REST API + CDP for Playwright, Puppeteer, Selenium. $9.99/month or $99.99/year. 7-day free trial, card required — cancel any time in the seven days and you are not charged. Unlimited profiles.

Get started → · How it differs from Multilogin / GoLogin / AdsPower

Ready to manage multiple accounts?

Seven days free, then $9.99/month — one plan, everything included.

We'd like to use Google Analytics, a Google service, to understand how our website is used. It sets two cookies in your browser and runs only if you click Accept. You can change your choice at any time with Cookie settings. Cookie Policy

Sign-in cookies and the cookie that remembers this choice are always on; the website needs them to work.

Google Analytics, a Google service, helps us understand how our website is used. It sets two cookies, _ga and _ga_TVZHQ99TZW. It is now onoff in this browser. If your browser sends a Global Privacy Control or Do Not Track signal, it stays off. Cookie Policy