Postmortems

A hydration mismatch hiding in the 14th decimal place

React said my server HTML didn't match the client. The diff was two numbers that agreed to thirteen significant digits. The story of why Math.sqrt is allowed to do that.

React hydration errors usually mean you did something obviously wrong: rendered a Date.now(), read window during render, let a browser extension inject markup. The console message is blunt about it: "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties."

Mine came with a diff. Here it is, and I want you to actually read the numbers:

+ translate(90.71857633407086 62.5625776397506)   scale(0.8119893988802784)   // client
- translate(90.71857633407114 62.562577639750714) scale(0.8119893988802781)   // server

The server said 90.71857633407114. The client said 90.71857633407086. They agree to thirteen significant digits and then part ways. React, quite reasonably, does not have a "close enough" mode for attribute equality, so it bailed on the whole subtree.

Where the numbers came from

The page was a relationship graph in a dashboard I build for my own projects: an SVG of nodes and edges, laid out by a small force-directed simulation. On every render, the layout function ran 460 iterations of the usual force-layout arithmetic: distances via Math.sqrt, angles via cos and sin, divisions everywhere. The resulting x, y and scale values were written directly into the SVG transform attribute.

The simulation is deterministic in the algorithmic sense. Same input, same iteration count, no randomness, no time dependence. Run it twice in the same JavaScript engine and you get bit-identical output. I knew that, which is why I had assumed it was hydration-safe.

What I had not internalised is that "deterministic" and "identical across engines" are different promises. The server render ran in Node; the client render ran in the browser. Both are V8 in my case, but even so, transcendental functions and floating-point operations are allowed to differ in the last unit of precision (1 ULP) between builds, platforms and optimisation tiers. IEEE 754 pins down addition and multiplication exactly, but Math.sqrt composed with Math.cos composed with a division chain carries no cross-environment bit-exactness guarantee.

One ULP is nothing. One ULP compounded through 460 iterations of feedback (this iteration's positions feed the next iteration's forces) is still almost nothing, which is exactly the trap: the divergence surfaced around the 14th significant digit, far too small to see in the rendered layout and far too large for string equality on an attribute.

So the server produced HTML with one perfectly valid float serialisation, the client's first render produced another, and React compared the strings.

The fix, in two layers

The honest fix is to accept that a client-side physics layout is a client-side thing, and stop pretending the server can pre-render it:

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

return !mounted ? <GraphPlaceholder /> : <svg>{/* positioned nodes and edges */}</svg>;

With no server-rendered positioned scene, there is nothing to mismatch. The chrome around the graph (legend, filters, headings) carries no layout-derived numbers, so it keeps server-rendering fine and the page does not flash empty.

The second layer is cheap insurance: round what you print. The transform now renders with x.toFixed(2) and k.toFixed(4). If a divergence lives in the 14th digit and you only ever serialise four, the divergence cannot reach the DOM. Rounding alone would arguably have fixed the original bug, and it is the fix I would try first on a page where SSR of the visualisation actually matters, because it keeps the server HTML meaningful.

What I deliberately did not do is reach for suppressHydrationWarning. It is tempting because it makes the console clean, but it only suppresses the warning: React still will not patch the mismatched attributes, so you ship a scene whose server and client versions silently disagree. Hiding a real inconsistency is worse than either fix.

The general shape of the bug

Strip the specifics away and the rule is: any float-heavy computation that feeds rendered DOM attributes is a hydration hazard, even when it is fully deterministic within one engine. It sits in the same family as Math.random() and Date.now() in render, but it is meaner, because it passes every local test you throw at it. Render twice in Node: identical. Render twice in the browser: identical. Only the cross-boundary comparison fails, and only by one part in ten trillion.

The escalation ladder I took away, cheapest first:

  1. Round the rendered value so the divergence falls below the printed precision. One line, keeps SSR.
  2. Gate on mounted for genuinely client-only visualisations. No server scene, no mismatch.
  3. Move the layout out of render entirely (a worker or an effect that stores a positions snapshot), which also stops you re-running 460 simulation iterations on every render. That one was already on my list for unrelated performance reasons.

I keep a written record of bugs like this across my projects, and this entry has already paid for itself once: the next time a hydration diff shows two numbers that look identical, I will not spend the first hour re-checking my data fetching. I will count the significant digits.

← All devlog stories