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
- 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. - Browser class. A cheap check of
navigator.brave, the user agent,navigator.vendorand, for Firefox, the resistFingerprinting traits. The class picks a row of the exclusion table (below) and is itself thebrowser_classcomponent. - Collectors. All 46 start at once (
Promise.all). For each one, in this order:- named in
collect({ exclude }):excluded, ruleIdcaller_excluded; - listed in the exclusion table for this browser class:
excludedwith the table’s ruleId; - the abort signal has already fired:
timed_out, ruleIdaborted; - 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.
- named in
- 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 ownfinallyblock. - Finalise. Each outcome becomes the public component shape (see “Hashing” below).
- Integrity checks. Fourteen read-only checks, two of them against a clean iframe realm. See Integrity checks.
- Identifiers. See Identifiers.
- Binding. Only when you pass
binding. See Anti-replay binding. - 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,pluginsandspeech_voicesreportnoneunhashed 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 longua_versionsor a keyboard layout in a non-Latin script can arrive as a digest. hashed: truetells you the value is a digest;hashed: falsethat it is the plain value.- Without a working
crypto.subtlea value that must be hashed is never sent raw: the component becomesunavailablewith ruleIdno_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.isBravebeing 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_rfponly when every one of these holds at once: the time zone isUTC,Etc/UTCorAtlantic/Reykjavik; the screen is exactly the size of the content area (innerWidthandinnerHeight); the available screen area equals the screen (no taskbar);devicePixelRatiois 1;screenXandscreenYare 0; andnavigator.webdriveris 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 plainfirefox. 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:
canvasdraws the same scene (text, an emoji, blending, an even-odd shape) twice on the same canvas and compares the twotoDataURL()strings.webgl_imagedraws the same shaded triangle twice and compares the tworeadPixels()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 nosrc(so it staysabout:blankand makes no request), setsdisplay: none, and appends it to the body; - it reads from the iframe’s
contentWindow: its ownFunction.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
largeflag (always hashed) and astableflag (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. Twocollect()calls are fully independent.