Docs · WhiskerJS 0.1.0 · signalsVersion 5 · idVersion 2

How it works

The collection pipeline, step by step, with every limit and reason code.

In plain words

When you call collect(), WhiskerJS asks the browser 46 small questions at the same time (screen size, fonts, how a test image renders, and so on). Each question has one second to answer. Every answer gets a status: it worked, it is not available here, it was refused, it was random, it was skipped on purpose, or it took too long. Big answers are turned into short fingerprints (hashes) so the raw data never leaves. Then it checks the page for signs of robots or faked values, and builds two identifiers from the answers that rarely change.

In one sentence: collect() runs 46 small, independent probes at the same time, each against its own clock, turns whatever happens into a status, hashes anything large, checks the page for signs of automation or tampering, and derives two identifiers from the stable part.

This page explains each step in plain words first, then in exact detail. Everything here describes the code at signalsVersion 5 and idVersion 2.

The pipeline

load(options)             pick the window, clock, timers and crypto (once)
  |
collect(options)
  1. budget               overall deadline = now + timeoutMs
  2. browser class        brave, firefox_rfp, firefox, safari, chromium, other
  3. run 46 collectors    in parallel; each one: excluded? -> race(work, timer, abort)
  4. clean up             release the shared WebGL context
  5. finalise             sanitise each outcome; hash large or unprintable values
  6. integrity checks     14 checks, some against a fresh blank iframe
  7. identifiers          visitorId and structuredId from the stable components
  8. binding              echo your nonce and timestamp, digest over the result
  9. onResult hook        your optional callback gets a copy
  -> resolve with the result

Step by step

  1. Budget. timeoutMs (default 5000) sets the deadline for the whole call. A value that is not a finite number falls back to 5000; it is clamped to the range 0 to 60000.
  2. Browser class. A cheap check of navigator.brave, the user agent, navigator.vendor and, for Firefox, the resistFingerprinting traits. The class picks a row of the exclusion table (below) and is itself the browser_class component.
  3. Collectors. All 46 start at once (Promise.all). For each one, in this order:
    • named in collect({ exclude }): excluded, ruleId caller_excluded;
    • listed in the exclusion table for this browser class: excluded with the table’s ruleId;
    • the abort signal has already fired: timed_out, ruleId aborted;
    • otherwise its work races three things: its own result, a timer, and the abort signal. The timer is the smaller of the collector’s inner timeout (1000 ms for every collector today) and the time left before the overall deadline. A collector that loses the race is not cancelled; its late answer is simply ignored.
  4. Clean up. Collectors that share a resource release it once all of them have settled. Today that is the single WebGL context, which is released with WEBGL_lose_context. Probes that add elements to the page (font measurement, font preferences, content-blocker baits) remove them in their own finally block.
  5. Finalise. Each outcome becomes the public component shape (see “Hashing” below).
  6. Integrity checks. Fourteen read-only checks, two of them against a clean iframe realm. See Integrity checks.
  7. Identifiers. See Identifiers.
  8. Binding. Only when you pass binding. See Anti-replay binding.
  9. Hook. When load({ onResult }) was given, it is called with a deep copy of the result. See Integration.

durationMs is the whole time from step 1 to the end of step 7, rounded to whole milliseconds, read from the injected clock (performance.now() by default).

If anything outside every guard fails, collect() still resolves: with a fallback result where every component is blocked with ruleId internal_error, both ids null and no flags.

Timeouts and budgets

Limit Value What happens when it is hit
Inner timeout 1000 ms per collector that component is timed_out, ruleId timeout
Overall budget timeoutMs, default 5000, max 60000 every component still pending is timed_out, ruleId timeout
Abort collect({ signal }) every component still pending is timed_out, ruleId aborted; the iframe checks are skipped
Voices wait up to 600 ms inside speech_voices the list is read as it is then
Bait wait 50 ms inside content_blockers cosmetic filters get this long to hide the baits

A timeoutMs of 0 still resolves with every component present (most of them timed_out). The other steps (finalising, integrity checks, hashing) are not under the budget, but they are short and do not wait on the browser except for crypto.subtle.digest.

Statuses and reason codes

Every component is always present and has exactly one status.

Status Plain meaning ruleIds you can see
ok the value was read none
unavailable this browser does not offer the API, or it gave nothing usable no_api, no_document, no_context, no_layout, no_match, no_value, no_digest, skipped
blocked the API refused or failed security_error, threw, empty_image, no_buffer, short_buffer, silent, bad_outcome, internal_error
noisy two identical renders differed, so the browser adds random noise render_mismatch
excluded not collected, on purpose caller_excluded, brave_farbling, firefox_rfp
timed_out it ran out of time or was cancelled timeout, aborted

What each ruleId means:

ruleId Meaning
no_api the API or constructor does not exist here
no_document there is no document (or no document.body where one is needed)
no_context a canvas, WebGL or WebGPU context or adapter could not be created
no_layout elements were added but the page laid nothing out (for example display: none on the body)
no_match no media query option matched
no_value the API answered with nothing usable
no_digest the value had to be hashed but crypto.subtle is missing or failed
skipped read only in some browsers on purpose (audio_latency, see Signals)
security_error the browser threw a SecurityError or NotAllowedError
threw the browser threw any other error
empty_image the canvas export was empty or not an image
no_buffer, short_buffer, silent the offline audio render gave no buffer, too few samples, or only zeros
bad_outcome a collector returned something that is not an outcome (a guard; not expected)
internal_error the fallback result: something outside every guard failed
render_mismatch two renders of the same scene differed
caller_excluded you excluded it with collect({ exclude })
brave_farbling, firefox_rfp the exclusion table removed it for this browser class
timeout, aborted inner timeout or overall budget; abort signal
unknown a guard: a reason that is not a well-formed name is replaced with this

A ruleId always matches ^[a-z0-9_]{1,32}$.

Some ok components report a plain word instead of a measurement: none (nothing found, for example no plugins or no content blocker), exact or altered (canvas_readback), he=blocked, he=failed or he=none inside ua_client_hints and ua_versions (the detailed Client Hints were refused by a Permissions Policy, failed, or do not exist).

Hashing

Plainly: anything big or unusual leaves the library only as a fingerprint of itself, never as the raw thing.

Exactly:

  • The digest is SHA-256 from crypto.subtle.digest, cut to its first 128 bits and written as 32 lowercase hex characters. Strings are UTF-8 encoded first; byte values (audio samples, WebGL pixels) are hashed as bytes.
  • Thirteen components are large and always hashed: audio, canvas, font_preferences, fonts, fonts_canvas, math, media_codecs, permissions, plugins, speech_voices, webgl_image, webgl_params, webgpu. The exception is a plain sentinel: fonts, fonts_canvas, plugins and speech_voices report none unhashed when they find nothing.
  • Every other component is reported as is, unless its value is longer than 128 characters or contains a character outside printable ASCII (0x20 to 0x7e). Then it is hashed too. This is why a long webgl_renderer, a long ua_versions or a keyboard layout in a non-Latin script can arrive as a digest.
  • hashed: true tells you the value is a digest; hashed: false that it is the plain value.
  • Without a working crypto.subtle a value that must be hashed is never sent raw: the component becomes unavailable with ruleId no_digest.

The exclusion table

Some browsers randomise or normalise certain signals on purpose. Reading them would only add noise to the id, so for those browser classes the components are not collected at all and report excluded.

Browser class Excluded components ruleId
brave audio, canvas, fonts, fonts_canvas, hardware_concurrency, plugins, webgl_image brave_farbling
firefox_rfp canvas, hardware_concurrency, pixel_ratio, screen_frame, screen_resolution, time_zone, webgl_image, webgl_renderer, webgl_vendor firefox_rfp
firefox, safari, chromium, other none
  • Brave is recognised by navigator.brave.isBrave being a function. Its “farbling” adds per-site, per-session noise to these signals.
  • Firefox with resistFingerprinting (also Tor Browser) reports fixed or normalised values. Firefox is classed firefox_rfp only when every one of these holds at once: the time zone is UTC, Etc/UTC or Atlantic/Reykjavik; the screen is exactly the size of the content area (innerWidth and innerHeight); the available screen area equals the screen (no taskbar); devicePixelRatio is 1; screenX and screenY are 0; and navigator.webdriver is not true. Each trait alone is common in stock Firefox, and stock Firefox under automation (Playwright runs it in UTC with the screen equal to the viewport) matches several, so the test errs towards plain firefox. A missed resistFingerprinting browser only leaves normalised values in; a false positive would drop nine components (seven of them in the visitorId).

The table is exported as EXCLUSION_TABLE. Changing it bumps signalsVersion.

Noise handling: the double render

Some browsers and extensions add small random changes to canvas and WebGL readback, often only in private windows. WhiskerJS cannot know in advance, so it checks:

  • canvas draws the same scene (text, an emoji, blending, an even-odd shape) twice on the same canvas and compares the two toDataURL() strings.
  • webgl_image draws the same shaded triangle twice and compares the two readPixels() buffers byte by byte.

If the two differ, the component is noisy with ruleId render_mismatch and carries no value. In the visitorId it counts as “missing”, like any other non-ok status, so the id stays the same from run to run instead of changing every time. canvas_readback is a separate, simpler probe: it paints a known colour into a 1x1 canvas and reports exact or altered.

The clean iframe realm

Several integrity checks need a second, untouched copy of the browser’s built-in objects to compare against. WhiskerJS makes one like this:

  • after the collectors finish, it creates one <iframe> with no src (so it stays about:blank and makes no request), sets display: none, and appends it to the body;
  • it reads from the iframe’s contentWindow: its own Function.prototype.toString, its navigator and screen getters, and a few values;
  • it removes the iframe straight away, also when a check throws.

Nothing is written to either window. A script that overrides a navigator getter, patches toString to hide the override, or wraps a function in a Proxy usually does so only in the page’s realm, so the clean realm sees through it. The checks that use the realm are iframe_mismatch and prototype_lie. After an abort no iframe is created and these two do not fire.

Design notes

  • Each collector is a small object: a name, an inner timeout, a large flag (always hashed) and a stable flag (part of the visitorId).
  • Collectors read the browser only through the injected window (load({ env })), which is how the tests run in Node against a hand-written fake browser (test/fake-browser.ts) that records every call.
  • A collector that needs a resource another collector also uses shares it: one WebGL context per collection, one Client Hints request per collection.
  • load() keeps no state between calls. Two collect() calls are fully independent.