liarjs.dev
← Field notes
Runtime

The runtime underneath: heap limits, clock grain and the numbers V8 leaks

November 4, 2026 · 5 min read

Below the fingerprint surface there is a JavaScript engine in a process on an operating system, and it answers questions about itself that nobody thought of as identity. Heap ceilings. Clock granularity. When the process started, to the fraction of a millisecond. The exact digits of a floating point conversion.

Some of these are excellent signals. Some of them are folklore that falls apart when you measure. This post is mostly about telling the two apart, because the folklore versions get repeated a lot.

What the runtime answers that the identity layer never sees

jsHeapSizeLimit: a real signal, not a constant

performance.memory.jsHeapSizeLimit is the ceiling V8 will let the JavaScript heap reach. It is commonly presented as a per-platform constant, and the two figures quoted are 4294705152 bytes on Windows and 4395630592 on macOS. Those are real: a desktop Chrome stable on each platform does report them, and a browser claiming Windows while reporting the macOS figure has contradicted itself.

It is also not a constant. Measured on one machine, one Chrome version, one afternoon:

how it was launched jsHeapSizeLimit
ordinary window 4 395 630 592 (4192 MB)
headless 3 758 096 384 (3586 MB)

Same binary, same host. The limit is chosen from the platform’s address-space layout and the device’s memory, and headless configuration lands somewhere else entirely.

The consequence for a check is specific and worth stating, because getting it wrong produces a stream of false positives: only flag a value that lands exactly on another platform’s known figure. An unrecognised value says nothing at all. A browser reporting 3586 MB is not lying about anything; it is running headless, or on a machine with less memory, or under a flag. A browser claiming Windows and reporting 4192 MB has named macOS.

Note also that Windows and Linux share a figure, so this check is blind to that pair. A signal that works in one direction and not the other is still worth having, as long as the code says so.

Timer resolution is a property of the page

performance.now() is often described as revealing the browser through its clock precision. Measured back to back on a normal https page:

17897.900000035763
17897.900000035763
17897.900000035763
17897.900000035763
17897.900000035763

Five identical readings, because the clock is coarsened. That coarsening is a Spectre mitigation, and its grain depends on whether the page is cross-origin isolated - which is decided by the response headers the site sent, not by the browser. A page serving COOP and COEP gets a fine clock; every other page gets a coarse one.

So timer resolution is only a browser signal within a fixed isolation state, and a fingerprinting script that does not control its own headers is measuring its own deployment. The same trap catches SharedArrayBuffer: on this run typeof SharedArrayBuffer was undefined and crossOriginIsolated was false, on a perfectly ordinary desktop Chrome. Treating the absence of SharedArrayBuffer as a browser or platform tell measures the page’s headers.

Where timing does carry engine information is in the deviation from the expected policy. A build that adds jitter to defeat timing attacks, or that fails to coarsen when the policy says it should, differs from stock Chrome in a way that is about the build. That is a much narrower claim than “clock precision identifies the browser”, and it is the one that survives.

timeOrigin: a quantised process birthday

performance.timeOrigin is the wall-clock time the browsing context was created, as a float in milliseconds. Its fractional part on this run:

0.10009765625     = 205 / 2048

A dyadic rational, because the value is quantised rather than continuous. Two things follow.

The fractional part is a small, effectively random per-context value, useful as a session-scoped nonce and useless as a durable identifier - it changes on every page load.

The quantisation grid is the interesting part. It is a property of how the browser derives the value, so a build that computes it differently, or that returns a suspiciously round number, differs from stock. A timeOrigin with a fractional part of exactly zero is not something a normal browser produces.

And Date.now() - performance.timeOrigin - performance.now() should be a small number. A clock override that patches one of the three and not the others produces a large one.

The engine’s arithmetic identity

These are the durable ones, because they come from the compiled math library rather than from any policy:

Math.tan(-1e300)   // -1.4214488238747245
(0.1).toFixed(20)  // "0.10000000000000000555"

Transcendental functions are not specified to the last bit, so different engines and different platform math libraries produce different final digits. toFixed at high precision exposes the exact double-to-decimal conversion. Both are stable per engine build, both are free to read, and neither has anything to do with the identity a persona configures.

The same category holds Error.prototype.stack. Its format is engine-specific - V8 opens with the Error: message line and follows with at … frames - and the number of frames from a known call depth is a structural fact. A browser claiming Chrome whose stack does not have V8’s shape has a serious problem, and one whose frame count is off by one has something in the call path that was not there before.

Why this cluster is hard to spoof

The pattern across all of the above is that these values are produced by the layers a persona configuration does not model. There is no heapLimit field in an identity struct because nobody thinks of the heap ceiling as part of who you are. It is part of what you are running as.

Which makes them a good complement to the identity checks rather than a replacement. Individually each is weak or conditional: the heap limit only fires on an exact platform match, timing is contaminated by page headers, the math values only separate engines rather than machines. Together they describe the process, and a persona that has never considered the process gets all of them from the host.

The practical advice for a scanner is to weight them accordingly. heap-os is worth a few points when it fires on an exact figure and worth nothing otherwise. The math and stack values are worth collecting as identity dimensions rather than as lie detectors. And the timing values need their preconditions recorded alongside them, or they will be read next year as if they meant something they did not.

Sources & further reading
  1. MDN, Performance: timeOrigin
  2. MDN, Window: crossOriginIsolated
  3. Chrome, timer coarsening and Spectre mitigations
More field notes
← Back to field notes