# How it works

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

Source: https://whiskerjs.com/docs/how-it-works/ (last updated 2026-10-26)

> **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

```text
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](https://whiskerjs.com/docs/integrity/).
7. **Identifiers.** See [Identifiers](https://whiskerjs.com/docs/identifiers/).
8. **Binding.** Only when you pass `binding`. See [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding).
9. **Hook.** When `load({ onResult })` was given, it is called with a deep copy of the result. See [Integration](https://whiskerjs.com/docs/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](https://whiskerjs.com/docs/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.
