liarjs.dev
← Field notes
Workers

Workers and OffscreenCanvas: fingerprinting in the thread you forgot to patch

September 26, 2026 · 9 min read

A spoof that looks perfect in the document’s main thread often falls apart the moment a detector spawns a Web Worker and asks the same questions again. The Worker gets a fresh JavaScript realm: new globals, new prototypes, no memory of whatever you monkey-patched in the page. If your override lived in the main thread only, the Worker answers with the truth, and the mismatch between the two is a cleaner signal than any single value could ever be. This is the current state of play as of the 2026-07 snapshot; treat specific access counts as indicative, not exact.

One identity across main thread, Web Worker and OffscreenCanvas

The premise: one identity, every realm

Modern anti-detect detection is less about reading a value than about reading the same value from as many vantage points as possible, then checking they agree. The main-thread navigator is only one vantage point. A detector that trusts it alone is easy to fool. One that also re-reads from a dedicated Worker, a shared Worker, a service worker, an iframe (including cross-origin, relayed via postMessage) and, for canvas and WebGL, from inside an OffscreenCanvas running in a Worker, is asking your spoof to be consistent across every execution context the browser exposes.

That is the whole game. A patch injected into the main document does not automatically execute in a Worker’s realm. If you spoof navigator.userAgent by redefining a getter on the page’s Navigator.prototype, the Worker’s WorkerNavigator.prototype stays untouched. It is a different object in a different global. To cover it you would have to re-inject into every Worker at construction time, and even then the injection carries its own tells: Function.prototype.toString on your shim doesn’t read as [native code], and the timing of your bootstrap is observable. This is why engine-level (C++) spoofing is structurally cleaner than JS shimming. The same persona config is read in the renderer main thread, in each Worker and in the GPU process, so there is no second realm to forget.

The browsers that earned high Worker confidence were the ones whose GPU parameter set was identical in the worker and the main thread. Anything less reads as a partial patch.

controlled anti-detect browser testing / CreepJS worker section

WorkerNavigator: the re-read

The first move is the cheapest. A detector spawns a Worker and reads navigator.userAgent, navigator.platform, navigator.hardwareConcurrency and navigator.languages from inside it, then diffs against the main thread. In recon against live endpoints, reCAPTCHA reads WorkerNavigator.userAgent roughly ×26 and FingerprintJS Pro does the same at roughly ×26. The repeated reads are how they raise confidence that the answer is stable and not being lazily patched on first access.

The mechanics are trivial, which is why the check is so common. You can carry it out with a Blob URL: no separate file, no network fetch, nothing for a network-layer filter to block.

// Main thread: spawn a Worker from an inline Blob, ask it to report
// identity back, and diff against the main-thread values.
const workerSrc = `
  self.onmessage = () => {
    postMessage({
      userAgent: navigator.userAgent,
      platform: navigator.platform,
      hardwareConcurrency: navigator.hardwareConcurrency,
      languages: navigator.languages,   // WorkerNavigator exposes these too
    });
  };
`;
const url = URL.createObjectURL(new Blob([workerSrc], { type: 'text/javascript' }));
const worker = new Worker(url);

const workerNav = await new Promise((resolve) => {
  worker.onmessage = (e) => resolve(e.data);
  worker.postMessage('report');
});
URL.revokeObjectURL(url);

const mismatch =
  workerNav.userAgent !== navigator.userAgent ||
  workerNav.platform !== navigator.platform ||
  workerNav.hardwareConcurrency !== navigator.hardwareConcurrency;

// mismatch === true  =>  identity was patched in the main thread only.

If any field differs, the conclusion is not “unusual browser.” It is “partial override,” a much stronger accusation. A genuine browser cannot disagree with itself here.

OffscreenCanvas: the render you didn’t hook

The canvas equivalent is nastier because it sidesteps a whole category of spoof. Plenty of anti-detect implementations hook CanvasRenderingContext2D on the main thread, perturbing getImageData or toDataURL so the canvas hash varies per profile. But OffscreenCanvas gives a second, independent rendering surface, and it runs in a Worker. Its OffscreenCanvasRenderingContext2D.getImageData and measureText bypass any hook that only touched the main-thread 2D context.

Vastel-family scripts exercise OffscreenCanvas heavily (on the order of ×64 in recon), and both Cloudflare Turnstile and reCAPTCHA run canvas work inside a Worker. WebGL is the same story: an OffscreenCanvas WebGL context in a Worker re-runs the GPU fingerprint away from the main thread. There are two ways to reach the offscreen surface, and detectors use both:

// (A) transferControlToOffscreen: hand a real <canvas>'s backing to a Worker.
const canvas = document.createElement('canvas');
canvas.width = 280; canvas.height = 60;
const offscreen = canvas.transferControlToOffscreen();

const src = `
  self.onmessage = (e) => {
    const off = e.data.canvas;               // (A) transferred surface, OR
    // const off = new OffscreenCanvas(280, 60);  // (B) construct fresh in-worker
    const ctx = off.getContext('2d');
    ctx.textBaseline = 'alphabetic';
    ctx.font = '16px "Arial"';
    ctx.fillStyle = '#069';
    ctx.fillText('liarjs.dev \u{1F512} render', 4, 24);

    // Font/text metric probe, untouched by a main-thread measureText hook:
    const m = ctx.measureText('liarjs.dev \u{1F512} render');
    // Pixel readback, untouched by a main-thread getImageData hook:
    const px = ctx.getImageData(0, 0, off.width, off.height).data;

    let hash = 2166136261;                    // FNV-1a over the pixels
    for (let i = 0; i < px.length; i++) { hash ^= px[i]; hash = Math.imul(hash, 16777619); }
    postMessage({ width: m.width, actualBB: m.actualBoundingBoxAscent, hash: hash >>> 0 });
  };
`;
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
const worker = new Worker(url);
worker.postMessage({ canvas: offscreen }, [offscreen]);   // transfer ownership

The detector then compares the Worker’s offscreen canvas hash and text metrics against a main-thread canvas drawn with the same instructions. If your noise lives only in the main-thread encode path, the offscreen render comes back clean and the two disagree. The disagreement is the tell, not the hash value itself.

What CreepJS actually asserts

CreepJS (abrahamjuliot.github.io/creepjs) has a dedicated Worker section, and it does not merely collect a Worker fingerprint. It requires identity between the Worker and the main thread. It re-reads timezone, GPU (WebGL vendor/renderer and the parameter set), user agent, device descriptors and languages inside the Worker, then reports a confidence level (high or moderate) for how well the two contexts agree.

In controlled testing against competitor anti-detect browsers, the split was stark:

Browser Worker confidence Why
AdsPower high Whole WebGL/GPU parameter set consistent across contexts
VMLogin high Same: entire GPU parameter set reproduced in both realms
bablo moderate GPU parameters not fully reproduced across contexts
CloakBrowser moderate Partial cross-context agreement

The high tier went to the browsers whose entire GPU parameter set matched in the Worker and the main thread, meaning not just the unmasked renderer string but MAX_TEXTURE_SIZE, viewport dims, extensions and precision formats. moderate is what a partial patch earns: the string agrees, the deep parameter set doesn’t, and the detector notices. This is the same lesson the WebGL work teaches on the main thread, extended into a second realm. Reproduce the whole picture or don’t bother claiming the GPU.

The implication for spoofing

If you take one thing from this, take the propagation requirement. A fingerprint configuration must reach every execution context:

  • the renderer main thread,
  • dedicated and shared Workers,
  • service workers,
  • iframes, including cross-origin frames reached via postMessage,
  • and the GPU process (for WebGL and WebGPU adapter identity).

A JS shim injected into the main document satisfies exactly one of these. Everything else is a fresh realm that either runs your re-injection (with its attendant toString and timing tells) or runs the truth. Engine-level spoofing reads the one persona config in all of them. That is why the same seeded, deterministic canvas noise appears identically in the main-thread encode path and the OffscreenCanvas Worker path, and why the Worker’s WorkerNavigator.userAgent is the configured UA rather than the build’s real one.

One subtlety matters here. Cross-context consistency and per-call stability are different requirements, and you need both. A Worker canvas hash that matches the main thread but changes on the second read within the Worker is still a lie. CreepJS’s stability checks catch per-call randomization regardless of which realm it happens in. Deterministic noise, hash(seed, input) -> output, is the only thing that survives being read twice in two threads.

How our scanner tests it

Our consistency scanner keeps this deliberately narrow and unambiguous. It spawns a Worker and reads navigator.userAgent inside it, then compares against the main-thread UA. The relevant logic:

// 6. Worker UA == main UA
if (client.worker && client.worker.userAgent) {
  if (client.worker.userAgent !== ua)
    add('worker-ua', 'Worker ↔ main-thread UA', 'bad',
      'navigator.userAgent inside a Web Worker differs from the main thread (partial override).', 18);
  else
    add('worker-ua', 'Worker ↔ main-thread UA', 'ok', 'Worker and main-thread UA match.', 0);
}

A mismatch is scored as a bad “partial override” with weight 18, heavy enough to move the verdict, because a UA that disagrees with itself across threads is close to conclusive evidence of a main-thread-only patch. It sits alongside the other cross-realm and cross-interface checks in that file (HTTP UA vs navigator.userAgent, WebGL vs WebGPU GPU identity, canvas and DOMRect double-read stability), all variations on the same demand: one identity, asserted from every angle we can reach.

This ties directly to the native-integrity and WebGPU-triad work. Native integrity asks whether a value is spoofed in JS at all ([native code], own-instance vs prototype getters). The GPU triad asks whether WebGL, WebGPU and the UA platform agree with each other. The Worker check asks whether identity survives a change of realm. They are three faces of the same principle, and a real browser passes all three for free.

Takeaway

The thread you forgot to patch is the one the detector reads first. If your spoof lives in the main-thread JS or the main-thread canvas only, a Worker re-read of WorkerNavigator and an OffscreenCanvas render will expose it as a partial override, and “partial override” is a stronger signal than any anomalous value. Propagate one configuration to every realm (main thread, Workers, service workers, iframes, GPU process), keep the noise deterministic so it survives repeated reads, and reproduce the whole GPU parameter set in the Worker rather than just the renderer string. That is the difference between CreepJS high and moderate, and it is why engine-level spoofing beats JS injection here.

Sources & further reading
  1. CreepJS (Abraham Juliot)
  2. OffscreenCanvas, MDN
  3. Fingerprint.com demo
More field notes
← Back to field notes