liarjs.dev
← Field notes
Geometry

DOMRect and subpixel geometry: the fingerprint DataDome loves and the dpr trap that breaks spoofers

October 3, 2026 · 8 min read

Layout geometry is a fingerprint you cannot turn off. Every call to getBoundingClientRect returns floating-point coordinates that depend on your fonts, your rendering pipeline, your zoom and your layout engine. It is a quiet, high-entropy signal, and most spoofers never think to touch it until a detector points at it. What does the geometry leak? Why does per-call noise get you judged a liar? And what is the detail that has sunk more DOMRect spoofers than any other? That last one is a hardcoded hash that only arms itself when devicePixelRatio === 1.

DOMRect subpixel noise and the devicePixelRatio==1 hardcoded-hash gate

What the rect actually contains

Element.getClientRects() returns a list of DOMRect objects; getBoundingClientRect() returns the union. Range.getClientRects() does the same for a text range. None of these round to integers. On a real machine you get values like 0.246032417…, and the fractional part is the product of how the layout engine positioned glyphs and boxes at subpixel precision, folded through the device’s devicePixelRatio, font metrics and text-rendering settings.

That fractional tail is the entropy. Two machines running the same Chromium build on the same page will agree on the integer positions and disagree in the low decimals if anything downstream of layout differs. It stays stable within a device across reads and reloads, and it varies across devices. That is the textbook profile of a durable fingerprint.

Detectors read it heavily. In a 2026-07 API-access recon of DataDome’s js.datadome.co/tags.js on a single page load, we counted getBoundingClientRect invoked roughly 965 times and getClientRects roughly 923, so DOMRect access aggregated to about 1888 calls, with a further ~2401 in the adjacent layout-measure bucket (the offsetWidth/offsetHeight span-measuring that also drives font enumeration). DataDome does more than hash the geometry. At that volume it is also sampling element visibility and coordinate behavior: are clicks and pointer events landing where the box actually is, the way a human’s would? The same measurements do device identification and behavioral plausibility at once.

Signal (one DataDome page load, 2026-07) Approx. calls
getBoundingClientRect 965
getClientRects 923
DOMRect total 1888
layout-measure (offsetWidth/Height etc.) 2401

CreepJS reads geometry hard too. domrect aggregated to roughly 144 fingerprint-dimension accesses in the same recon. But CreepJS spends its budget differently: less on the entropy of the values, more on whether you are lying about them.

The lie test: two reads, byte for byte

Here is the trap that catches naive noise. A detector reads the same rect twice and demands the two reads be identical. Real geometry is deterministic within a page, and a spoofer that adds fresh random jitter on every call cannot reproduce a value it just invented.

// The double-read a lie-detector runs against a fixed element.
const el = document.getElementById('fp-fixed');   // a stable, laid-out element
const a = el.getBoundingClientRect();
const b = el.getBoundingClientRect();

// On honest hardware - and on correct deterministic spoofing -
// these are byte-identical:
console.assert(a.x === b.x && a.y === b.y &&
               a.width === b.width && a.height === b.height,
               'per-call noise: the same rect read twice must not change');

We watched this play out in controlled anti-detect browser testing. Reading one rect twice, CloakBrowser returned A = 0.246032417… and B = 0.246032432…, a difference in the eighth decimal place. CreepJS flagged it as a lies hash under the title Element.getClientRects(). A competing engine, bablo, returned A == B == 0.246032403… on the same test and was clean. As the tester put it:

The same rectangle read twice came back differing in the eighth decimal, that flicker, not the value itself, is what the lie detector keys on.

The detector never needed a reference value for your device. It only needed you to contradict yourself. CloakBrowser’s source-level Chromium patch was more advanced than bablo’s engine-level disguise on paper, yet it shipped per-call randomization and drew three lie flags (Canvas, DOMRect and Audio) while bablo, AdsPower and VMLogin each drew zero. Route sophistication does not beat implementation correctness.

Our own scanner runs exactly this check:

// 8. DOMRect double-read
if (client.domrect.a !== client.domrect.b)
  add('domrect-lie', 'DOMRect read stability', 'bad',
    'getBoundingClientRect returned different values on two consecutive reads (per-call noise).', 12);
else
  add('domrect-lie', 'DOMRect read stability', 'ok', 'DOMRect is stable across repeated reads.', 0);

The rule that follows is the same one that governs canvas and audio: noise must be a pure function of (seed, input). Same profile, same read operation, identical output every time, different only across profiles. The moment getClientRects draws a fresh random number per call, you are caught. Everything true of canvas and audio noise holds for the DOMRect member of that same deterministic-noise family.

The blank invariant

There is a second floor beneath the lie test. A 0×0 ghost element, an element with no dimensions, must return an all-zero rect: {x, y, width, height, left, right, top, bottom} all zero. CreepJS checks this explicitly, and a non-zero ghost rect is flagged as unknown ghost dimensions.

// The ghost element must stay all-zero.
const ghost = document.createElement('div');   // 0×0, never laid out with size
document.body.appendChild(ghost);
const r = ghost.getBoundingClientRect();
// Required: r.width === 0 && r.height === 0 && r.x === 0 && r.y === 0 …

So any perturbation function must skip empty rects: w <= 0 || h <= 0 gets left untouched. Blank stays blank; only laid-out, non-empty geometry gets deterministic noise. This is the same discipline as skipping fully-transparent pixels in canvas noise and silent samples in audio noise. Do not perturb data that is supposed to be zero, because the detector knows it should be zero.

The math invariants

Beyond “reads are equal” and “blank stays blank,” a rect has internal arithmetic that must hold:

  • right, left === width
  • bottom, top === height

Coherent deterministic noise preserves these automatically, because it perturbs the underlying box and lets the derived members fall out consistently. Sloppy noise that jitters left and width independently breaks the identity and hands the detector a free tell. These are cheap to check and, like the double-read, require no per-device reference.

The dpr === 1 trap

Now the detail that undoes spoofers who got everything above right. When devicePixelRatio === 1, CreepJS constructs a 100×100 element rotated 45 degrees, takes the bounding rect of that rotated box, hashes it and compares the hash to a hardcoded constant: '9d9215cc'.

// The rotated-box probe CreepJS hashes when dpr === 1.
const box = document.createElement('div');
box.style.cssText =
  'position:absolute;width:100px;height:100px;transform:rotate(45deg);';
document.body.appendChild(box);
const r = box.getBoundingClientRect();
// hash(r) is compared to the hardcoded '9d9215cc'.
// Any subpixel perturbation of this rect breaks the match.

A 45-degree rotation spreads the box’s corners across irrational-looking subpixel coordinates, so the bounding rect is a precise floating-point value. Any noise you add anywhere in the DOMRect path perturbs this rect and breaks the hash. You cannot single out this one rotated box and leave it pristine. The noise function does not know which element the detector is about to probe, and hardcoding an exception for a 100×100 rotate(45deg) box is exactly the kind of product-specific tell that gets reverse-engineered.

The escape hatch is built into the check itself: when devicePixelRatio !== 1, the entire rotate check is skipped. The hardcoded hash only exists for the dpr-1 case. So the safety envelope for DOMRect noise is narrow and precise:

devicePixelRatio Rotate-hash check Safe to add rect noise?
=== 1 Armed, compares to hardcoded 9d9215cc No. Any noise breaks it. Disable rect noise entirely.
!== 1 Skipped Yes, if noise is deterministic and preserves the math invariants.

This is why the right persona matters. A best-practice choice is 1536×864 at dpr 1.25, which is 1920×1080 at 125% Windows display scaling, the single most common Windows fingerprint in the wild. At dpr 1.25 the rotate check is skipped and deterministic noise sails through the double-read, the blank invariant and the math invariants. On a dpr-1 persona the rotate hash is armed and there is no honest way to add subpixel noise, so the correct move is to disable DOMRect noise entirely rather than ship a lie. A profile with no rect noise is boring; a profile that fails the hardcoded hash is a flagged liar. Boring wins.

The engine we track gates this automatically: its RectSeed() returns 0, noise off, unless the configured dpr is explicitly a non-1 value, so a desktop dpr-1 persona gets “no rect noise” instead of “one lie.” That is the correct default. The gate is unique to geometry; canvas and audio have no equivalent dpr dependency.

Takeaway

DOMRect is high-entropy, unavoidable and read at industrial volume by DataDome for both identity and behavior. If you spoof it, obey the deterministic-noise contract you already owe canvas and audio: pure function of (seed, input), byte-identical across repeated reads, blank rects left at zero, and right-left == width / bottom-top == height preserved. Then add the one rule that only geometry has: check devicePixelRatio first. If it is 1, do not touch the geometry at all, because the rotated-box hash is hardcoded and you will lose. Pick a dpr-1.25 persona, keep the math coherent, and the noise stays invisible. This is a 2026-07 snapshot; detectors move, so verify against a live CreepJS and DataDome run before trusting any of it.

Sources & further reading
  1. CreepJS (Abraham Juliot)
  2. Element.getBoundingClientRect, MDN
  3. DataDome bot test
More field notes
← Back to field notes