Skip to content
All fingerprint checksEnvironment & locale

the voice list arrives the way Chrome delivers it

Check id speech-voices-async-path

What an ordinary browser yields

On a cold Chromium page: an empty first getVoices(), a voiceschanged event, then a populated list. On a warm page, on a re-run, on a non-Chromium engine, or on a host with no speech engine, the row is not applicable — all four are ordinary.

What a detector infers

speech-voices reads WHAT is in the voice list. This reads HOW the list arrived, which is a separate fact and survives a roster whose every value is plausible. Chrome does not build the list in the renderer: it is owned by the browser process and handed to the page over IPC, so on a cold document the first synchronous getVoices() returns an empty array and a voiceschanged event follows once the list lands. A stack that answers getVoices() from a table skips that entirely and is synchronously complete from the first instant — correct values delivered by the wrong mechanism. The check is written almost entirely out of decline paths, because the observation is racy and destroyed by being made late. On a warm page, on a re-run of this audit, or on a bfcache restore, a REAL Chrome also returns a full list synchronously, so a complete first read is evidence of nothing and is never scored; the row says so and goes quiet. To have any chance of catching the cold case the first read is taken when the audit bundle is first evaluated rather than when you press the button, and even then it frequently finds the list already populated, which is an ordinary result. What the check CAN establish, when the first read really was empty, is the invariant underneath the timing: in Chrome the empty-to-populated transition is precisely what raises voiceschanged. A roster that goes from nothing to complete with no event in between was not delivered by the browser process — that, and only that, is the failing verdict. Stated plainly: this row can confirm the real delivery path and can catch a list installed without the event, and it cannot tell a synthetic warm list from a genuine one. It is gated to Chromium, since Gecko and WebKit populate on their own schedule and holding them to Chrome's ordering would be asserting a behaviour they never had.

How to resolve it

Let the voice list come from the browser's own speech service rather than answering getVoices() from a table installed in the page: a list that appears without the voiceschanged event a real delivery raises is visible to anyone reading early enough. Nothing needs doing about a full first read — once the list has landed a real Chrome is synchronous too, which is why that case is never held against you.

What is speech synthesis fingerprinting?

Read in the wild by

CloakBrowser Pro releases

The vendor's Chromium 150 stable release lists speech among the surfaces it had to bring into line with a real Chrome install — the surface being named in shipped release notes is what establishes it was worth fixing. The note does not say which detector reads it, and neither do we.

Release "CloakBrowser Pro — Chromium 150.0.7871.114.3" (tag chromium-v150.0.7871.114.3-pro, published 18 Jul 2026), "What's changed in the binary": "Closer alignment with a real Chrome install across speech and language profiles, font handling, storage reporting, Windows system colors and selection rendering, and graphics parameters."

CloakHQ/CloakBrowser — chromium-v150.0.7871.114.3-pro

PerimeterX / HUMAN

Its collector is written around the asynchronous path specifically: it calls getVoices(), and when the result comes back empty it re-reads the list from the onvoiceschanged handler. A collector that has to code around the empty-first-read is a collector that knows what the real delivery looks like — and its UA-CH gate waits on the same event before deciding.

PerimeterX collector main.min.js (PX_RE corpus, stample/ifood/source/main.min.js): `qI(){try{pn=hS.speechSynthesis.getVoices(),hS[hQ(195)][hQ(196)]=function(){(!pn||pn&&0===pn.length)&&(pn=hS[hQ(195)].getVoices())}}catch(t){}}` with hQ(195)='speechSynthesis', hQ(196)='onvoiceschanged'; and in the UA-CH gate `hS.speechSynthesis.getVoices().length>0?e():hS.speechSynthesis.addEventListener(hQ(1071),e,((n={})[hQ(220)]=!0,n))` with hQ(1071)='voiceschanged', hQ(220)='once'.

github.com/warterbili/PerimeterX_RE

Every attribution traces to a published artifact. See the sources and their limits.

Nearby checks in Environment & locale

See all 23 checks in Environment & locale
Who builds this test

Clearcote is a browser built for fingerprint coherence

It is a Chromium fork, maintained by the same people who wrote this reference. It ships as a compiled browser rather than as a stealth script injected into someone else's — which is a description of how it is built, and is not an argument about how it behaves on this check.

This audit takes no position on how Clearcote scores on Environment & locale checks, on this one, or anywhere else. It has no baseline corpus of other people's fingerprints to rank you against and no vendor scoreboard — nearly every check is self-referential, asking one browser the same question through two independent APIs and reporting whether both answers can be true at once. It runs identically on any browser, including ours. Run it on yours and read the result yourself.

See the other checks in Environment & locale — the family speech-voices-async-path belongs to.