ClientRects and text-metrics fingerprinting: sub-pixel rendering as identity
Put a word on a page. Measure where it landed, down to the last decimal place the browser will give you. On one machine the left edge of the box comes back as 42.3984375. On another, same browser version, same operating system family, same font name, it comes back as 42.40625. The two numbers differ in the third decimal. A human reading the page sees the same word in the same place. The layout engine, the font rasterizer, and the floating-point math underneath disagree by a fraction of a pixel, and that disagreement is repeatable.
That fraction is the fingerprint. You did not need a canvas, a permission prompt, or any API a privacy reviewer would flag on sight. getClientRects() and getBoundingClientRect() are ordinary layout-reading calls that every web app uses to position tooltips and measure scroll offsets. measureText() is how you size text before drawing it. They were never meant to be identity signals. They became identity signals because browsers report them as full-precision doubles, and full precision exposes the exact sub-pixel result of a long rendering pipeline that depends on the machine underneath.
A roadmap
We start with the two API families and what they actually return. Then the part most explainers skip: why the same glyph lands at a different sub-pixel position on two machines, which is a story about font hinting, rasterizer choice, and IEEE 754. Then the entropy question, anchored to the one large measurement study that put a number on it. After that, the history, from the 2015 font-metrics paper through Chrome’s extended TextMetrics to the open Mozilla bug that has sat unfixed since 2018. Then the defenses, why rounding does not save you, and why a spoofer that patches one call but not the other paints a target on itself.
The two measurement surfaces
There are two distinct ways a script reads sub-pixel geometry, and they share a root cause but expose it differently.
The first is layout geometry. Element.getBoundingClientRect() returns a single DOMRect with x, y, width, height, top, right, bottom, and left. Element.getClientRects() returns a list of DOMRect objects, one per layout box the element generates, which matters for inline elements that wrap across lines. Both report their values as double-precision floats. Wrap a span of text in a known font at a known size, read the rectangle, and you have measured exactly how wide the layout engine decided that text is, sub-pixel included.
A subtle point separates these calls from their older cousins. The integer properties offsetWidth, clientWidth, scrollTop and friends round to whole pixels. getBoundingClientRect() and getClientRects() never did; they return the full fractional value. Chromium even debated promoting the integer properties to sub-pixel precision back in 2014, and the intent that proposed it noted plainly that the rect methods “are now specified to return the full precision value,” with the offset and client properties being the laggards. The high-precision path was always the rectangle path. That is the one collectors use.
The second surface is text metrics. CanvasRenderingContext2D.measureText(text) returns a TextMetrics object. Its oldest member is width, the advance width of the string. Modern browsers expose a much richer set: actualBoundingBoxAscent and actualBoundingBoxDescent (how far the inked pixels of this specific string reach above and below the baseline), actualBoundingBoxLeft and actualBoundingBoxRight, and the font-relative fontBoundingBoxAscent and fontBoundingBoxDescent. The “actual” boxes describe the glyphs you passed; the “font” boxes describe the font’s own design metrics. All of them are doubles, and all of them depend on how the host rasterized those glyphs.
These two surfaces measure the same underlying thing, the geometry of rendered text, from two different vantage points. Layout geometry sees it after the CSS box model has had its say. Canvas text metrics see it closer to the rasterizer. A collector that wants a signal hard to fake reads both, because a spoofer is unlikely to keep them perfectly consistent. We will return to that.
Why the same glyph lands in a different place
This is the part that earns the technique. If text rendering were deterministic across machines, none of this would work; the rectangles would be identical and carry zero bits. They are not identical, and the reasons stack.
Start with the font itself. “Arial 18px” is not one thing. The browser resolves that name to whatever file the operating system has registered under it, and the substitute differs across platforms. Even when the actual bytes match, the glyph outlines are vector curves that must be turned into a position and a set of pixels, and that conversion is where machines diverge.
The first divergence is the rasterizer. Windows renders text through DirectWrite, macOS through Core Text, and Linux through FreeType. These are three separate codebases with three different philosophies about how to fit curves to a pixel grid. They make different rounding decisions, apply different sub-pixel positioning, and produce different advance widths for the same glyph at the same nominal size. The browser asks the host for the metrics; the host answers in its own dialect.
The second is hinting. A font carries instructions, or “hints,” that nudge its outlines onto the pixel grid at small sizes so stems stay crisp. How aggressively those hints get applied is a system setting, and the rasterizers honor it differently. FreeType alone ships several hinting modes, and a Linux box configured for slight hinting reports different glyph boxes than one configured for full hinting, with the same font and the same browser. Anti-aliasing and sub-pixel smoothing (the ClearType family on Windows, the grayscale path elsewhere) add another axis. None of this changes what the eye sees in any way the reader would notice. All of it changes the decimals.
Then the device pixel ratio folds in. On a 2x display the layout engine works in CSS pixels but lays glyphs onto a denser device grid, so the fractional CSS-pixel positions that come back differ from a 1x display rendering the same content. Display scaling on Windows (125%, 150%) shifts them again. This is why screen and DPR signals sit close to layout fingerprinting; the device-pixel-ratio post covers that axis on its own.
And underneath every one of these, the arithmetic is IEEE 754 binary floating point. The pipeline accumulates scales, transforms, and kerning offsets in doubles, and the order and rounding of those operations is an implementation detail that varies by engine version and sometimes by CPU. A value like 42.3984375 is not noise; it is 42 + 51/128, an exact dyadic fraction that fell out of a specific sequence of binary operations. Change the rasterizer, the hinting, the DPR, or the engine build, and the sequence changes, and the last few decimal places move. The number is reproducible on one machine because the pipeline is deterministic there. It differs across machines because the pipeline is not the same pipeline.
How much is this worth
Entropy is the only honest measure of a fingerprint, and for text metrics there is a clean number from a controlled study rather than a vendor brochure.
In 2015, a Financial Cryptography paper measured exactly this. The method was to render a battery of strings in the page, read back their on-screen glyph dimensions through ordinary DOM measurement, and treat the vector of measurements as a fingerprint. Across an experiment of more than 1,000 browsers, font metrics alone uniquely identified 34 percent of participants. The variation came from the same stack described above: which fonts were installed, which substitute the browser chose, and the hinting and anti-aliasing settings of the host. The authors also found the signal is cheap to harvest. Of the more than 125,000 Unicode code points they surveyed, testing just 43 of them accounted for all the variation they saw in the population. You do not need a long probe. A few dozen carefully chosen characters carry the whole signal.
That 34 percent is for one vector measured in isolation, on hardware from a decade ago, before the richer TextMetrics attributes existed. It is not the headline entropy of a modern stack, which combines layout geometry with canvas, WebGL, fonts, and the rest. The point is the order of magnitude. A signal that singles out a third of a crowd by itself, from a probe you can run in under a second with no permission prompt, is not a rounding error in a fingerprint. It is a load-bearing column.
The layout-geometry variant adds an orthogonal trick on top of font metrics. Browsers disagree not just on where text lands but on how they handle pathological CSS. Feed a transform: scale() with an absurd magnitude and the resulting rect values diverge by engine in ways that have nothing to do with fonts: one engine clamps to a large finite number, another returns scientific notation, an older one returns -Infinity or NaN. A collector batches a handful of these edge-case transforms, hardware-accelerated and not, alongside the ordinary text measurements, and hashes the whole vector. The transform cases probe the engine; the text cases probe the rasterizer. Together they cover more of the stack than either alone.
What a probe looks like in practice
The mechanics of harvesting this are small enough to describe without handing anyone an evasion. A collector keeps a fixed list of probe strings, chosen to stress the rasterizer the way the 2015 work showed pays off: a handful of code points whose glyphs differ across fonts and platforms, padded with a pangram or two so a wide spread of letterforms gets exercised in one read. For each probe it does one of two things, and usually both.
The layout path inserts the string into a hidden element with a fixed font stack and a fixed pixel size, off-screen or zero-opacity so nothing flickers, then reads getBoundingClientRect() or getClientRects(). The canvas path is even lighter: it never touches the DOM, it just calls measureText() on a 2D context with the same font set, and reads width plus the bounding-box members. Both paths return a vector of doubles. The collector concatenates the vectors in a fixed order and hashes the result to a short, stable string. Same machine, same hash, every time. Different machine, different hash, most of the time.
The hash matters less than the raw vector for a sophisticated detector. A hash is a yes/no identity check: does this device match a known device. The raw decimals are richer. They let a server-side model ask whether the values are internally consistent, whether they match the platform the user-agent claims, and whether they sit in the cluster a real population of that platform produces or in the suspiciously tidy region a spoofer tends to land in. That distinction, hash for re-identification versus raw vector for plausibility scoring, is the same split that runs through the rest of the JavaScript-runtime fingerprinting surface.
*The hash answers re-identification. The raw vector answers whether the values could have come from the platform the browser claims.*The history, briefly
The font-metrics idea predates the canvas hysteria. The 2015 paper put the 34 percent number on it, but it built on the older observation that you could detect installed fonts by measuring text width: set an element to a target font with a generic fallback, measure it, and if the width differs from the fallback the font is present. That width-comparison trick is the backbone of JavaScript font enumeration, covered in the font fingerprinting post. Glyph-metric fingerprinting took the same measurement primitive and pointed it at the rasterizer instead of the font list.
Canvas text metrics arrived through the standards process with eyes open about the privacy cost. When Chromium shipped the extended TextMetrics attributes in version 77 in 2019, adding the actualBoundingBox and fontBoundingBox members, the position taken in the review was that text metrics were already a fingerprinting surface and the new attributes exposed no novel one. That is a defensible call and also a quiet admission: the door was already open, so widening it changed little. The same attributes later landed in Firefox. Today every major engine exposes them.
The layout-geometry variant has a different and more awkward standards history. A Mozilla bug filed in 2018 by Tom Ritter, titled “Investigate getClientRects for fingerprinting,” laid out the surface: font rendering, Windows display scaling, the rendering of form controls like select dropdowns and progress bars, and MathML and emoji geometry all leak through these calls. It is still open. The bug notes the core difficulty with the obvious fix, that naive rounding does not hold, because an attacker can slowly grow an element until a rounded value ticks over and thereby recover the boundary the rounding was meant to hide. Seven years on, no general fix has shipped in Firefox for the layout-rect surface. The standards bodies looked at this, understood it, and largely decided the cost of breaking layout math was higher than the cost of the leak.
Why rounding does not save you
The instinct on first contact is to round the values. Quantize every rect to whole pixels, snap every measureText width, and the sub-pixel entropy vanishes. It is the obvious defense and it does not work cleanly, for two reasons that are worth separating.
The first is the boundary-probing attack the Mozilla bug names. Suppose the browser floors widths to integers. A script sizes an element, reads the floored width, then grows the element by tiny increments and watches for the integer to tick up by one. The increment at which it ticks reveals the sub-pixel position of the original boundary to within the step size. Rounding hid the low bits in any single read, but a sequence of reads reconstructs them. Defeating that requires adding noise or quantizing the inputs to layout, not just the outputs, which is a far deeper change.
The second is that rounding breaks real layout. Web apps measure text to wrap it, to fit it, to position carets and tooltips and virtualized scroll lists. Strip the sub-pixel precision and a class of those break in visible ways. This is the same tension the canvas fingerprinting defenses live with: the API is load-bearing for legitimate code, so you cannot simply remove it, and any value you return that is not the true one risks breaking something that depended on the true one.
Tor Browser accepts that cost where most browsers will not. It rounds layout measurements toward integers, limits font enumeration, ships a fixed bundle of fonts so the rasterizer input is uniform across users, and applies a restricted character fallback so unusual code points cannot fan out into distinctive glyph shapes. The layout cost is real and accepted as the price of the privacy goal. That is a coherent position for a browser whose entire purpose is anonymity. It is a non-starter for a mainstream browser that has to render the whole web without complaints.
The spoofer’s consistency problem
The defensive lens that matters most for anyone building or detecting automation is consistency, and this is where text-metrics fingerprinting earns its reputation as hard to fake.
A fingerprint spoofer wants to present a chosen, common profile rather than the machine’s real one. For a single scalar like the user-agent string, that is a one-line swap. For sub-pixel geometry it is not, because the values are not independent. The width a span reports through getBoundingClientRect, the advance the same text reports through measureText().width, the actualBoundingBoxAscent of its tallest glyph, and the canvas rasterization of that same text are all outputs of one pipeline. On a real machine they are mutually consistent by construction, because they came from the same rasterizer with the same hinting at the same DPR.
A patch that intercepts one call and returns plausible-looking numbers, while the others keep returning the host’s real values, creates a contradiction no real machine would produce. The spoofed layout rect claims a Windows-DirectWrite width; the un-patched measureText underneath reports the Linux-FreeType advance; the canvas readback shows FreeType’s anti-aliasing. A detector that reads all three and checks them against each other does not need to know the true machine. It only needs to notice that these three numbers do not belong to the same animal. This is the general failure mode of JavaScript-level patching, and it is why source-level engine work, the patched-Chromium and patched-Firefox approaches, exists: to change the rasterizer’s behavior at the layer where all three calls draw their numbers, so they stay consistent. Doing that convincingly means shipping a real alternate font stack and real alternate hinting, which is most of the way to just running the target operating system.
There is a second axis the spoofer has to cover: context. measureText() runs in a worker through an OffscreenCanvas, and layout geometry can be read inside an iframe or a nested browsing context. A patch applied only to the top-level document’s main thread leaves the worker and the iframe reporting the host’s real values, and a detector that compares the metric vector across contexts catches the seam. This is the same cross-context consistency check that catches half-applied stealth patches elsewhere, covered in the iframe and worker fingerprinting post. Sub-pixel metrics are a good place to run it because the expected answer is exact equality: the same string in the same font measured in two contexts on a real machine returns byte-identical doubles, so any divergence is a tell with no false positives.
Even a source-level spoof faces the float-math problem. Reproducing another platform’s sub-pixel values is not picking nicer-looking numbers; it is reproducing the exact sequence of IEEE 754 operations a different rasterizer performs, in the right order, at the right precision, for every glyph the detector might probe. Miss the last two decimal places on one character and a collector that has those values memorized for the claimed platform sees the mismatch. The signal is hard to spoof for the same reason it is good at fingerprinting: it is the literal output of a long deterministic computation that nobody fully re-implements just to lie about it.
Where this sits in 2026
Layout and text-metric fingerprinting occupy a stable, slightly thankless place in the detector’s toolkit. They are not the flashiest signal. Canvas and WebGL carry more raw entropy, audio carries a different orthogonal slice, and the navigator object hands over a dozen scalars for free. But sub-pixel geometry is cheap, fast, and quietly resistant to the spoofing tricks that defeat the more famous vectors, because its values have to agree with each other and with the rasterizer that produced them. A collector that reads getClientRects, measureText, and a canvas readback together gets a built-in lie detector for any profile that was assembled rather than rendered.
The defensive state has barely moved. The Mozilla bug is open in 2026, seven years after it was filed, because the honest fix breaks layout and the cheap fix can be probed around. Chrome shipped the richer metrics and noted the surface was already open. Tor pays the layout-correctness tax and accepts it. Everyone else has decided, by inaction, that a signal which singles out a third of a crowd is a cost they will carry to keep getBoundingClientRect returning the truth. The numbers are still doubles. The third decimal place still differs across machines, and it still tells whoever asks which machine drew the word.
Sources & further reading
- Fifield, D. and Egelman, S. (2015), Fingerprinting Web Users Through Font Metrics — the controlled study; font metrics uniquely identified 34 percent of over 1,000 browsers, and 43 of 125,000+ code points sufficed to capture all variation.
- Berkeley BLUES (2015), Fingerprinting Web Users through Font Metrics (FC ‘15) — the authors’ own write-up of the method and the 34 percent figure.
- Ritter, T. / Mozilla (2018), Bug 1507879: Investigate getClientRects for fingerprinting — the still-open bug enumerating the layout-rect leak and why naive rounding can be probed around.
- Eklund, E. / Chromium (2014), Intent to Implement and Ship: Subpixel precision for clientWidth, offsetWidth, scrollTop et al — confirms getBoundingClientRect and getClientRects already returned full-precision values while the integer properties did not.
- Chromium blink-dev (2019), Intent to Ship: New TextMetrics API in Canvas — the extended actualBoundingBox/fontBoundingBox attributes shipped in Chrome 77.
- MDN (2026), TextMetrics — Web APIs — reference for measureText() return members including actualBoundingBoxAscent/Descent and fontBoundingBox metrics.
- Laperdrix, P., Bielova, N., Baudry, B., Avoine, G. (2020), Browser Fingerprinting: A Survey — survey placing font and rendering metrics among the discriminating attributes.
- Laperdrix, P., Rudametkin, W., Baudry, B. (2016), Beauty and the Beast: Diverting Modern Web Browsers to Build Unique Browser Fingerprints — IEEE S&P study on HTML5-era rendering fingerprints, including canvas and font surfaces.
- BrowserLeaks (2026), ClientRects Fingerprinting — live demonstration that hashes DOMRect outputs across a battery of transform and text cases.
- TUM/LRZ PrivacyCheck (2024), Fingerprinting getClientRects — worked examples showing per-engine divergence on pathological CSS transforms.
- Tor Project (2026), Fingerprinting protections — Tor Browser — the bundled-font, letterboxing, and measurement-rounding approach to neutralizing layout fingerprints.
- Mowery, K. and Shacham, H. (2012), Pixel Perfect: Fingerprinting Canvas in HTML5 — the foundational canvas-rendering fingerprint, the sibling surface to text metrics.
Further reading
Canvas fingerprinting: how a single toDataURL call identifies a device
Traces how rendering text and shapes to an HTML5 canvas and hashing the toDataURL output yields a stable per-device value, the GPU, driver and font causes behind the variation, the 2012 origin, and how much entropy it really carries.
·22 min readWebGL fingerprinting: the renderer string, precision, and shader quirks
A primary-source reference on WebGL fingerprinting: the UNMASKED_RENDERER and UNMASKED_VENDOR strings, supported extensions, shader precision formats, rendered-image hashing, and the browser mitigations that bucket or hide them.
·24 min readAudioContext fingerprinting: the OscillatorNode signature explained
Traces how rendering an oscillator through OfflineAudioContext and a DynamicsCompressor produces a stable per-device float, the floating-point and FFT causes behind the variation, the 2016 origin, and how much entropy it really carries.
·18 min read