$5 free credits when you sign up
← All posts
Engineering

Hydration-Safe Rendering for Money and Time

new Date() and Math.random() in a React render body cause hydration mismatches — and on a billing dashboard, a flicker on a number erodes trust. Here is the pattern that keeps server and client agreeing.

Nemo Team8 min read

On a dashboard that shows people their money, a flicker is not cosmetic — it's a trust problem. If a balance or a timestamp renders one value on the server and a different value after hydration, React throws error #418 and the number visibly jumps. The cause is almost always the same: reading a non-deterministic value during render. This post is why that happens and the one pattern that fixes it.

Why server and client disagree

A server-rendered React app renders twice: once on the server (to send HTML) and once on the client (to hydrate it). Hydration assumes the two renders produce identical output. Anything non-deterministic read during render breaks that assumption:

// ❌ each of these differs between server and client render
function Greeting() {
  const hour = new Date().getHours();      // server TZ vs client TZ
  const id = Math.random();                // different every call
  const seen = localStorage.getItem('x');  // undefined on server
  return <span>{hour < 12 ? 'Morning' : 'Afternoon'}</span>;
}

The server computes getHours() in its timezone; the client computes it in the user's. Different value, mismatched HTML, hydration error #418, visible flicker. Math.random() and localStorage (which doesn't exist server-side) fail the same way.

useState(() => new Date()) does NOT fix it

The tempting fix — a lazy state initializer — doesn't work, because the lazy initializer also runs on the server during SSR. The server still computes a value, still sends it in the HTML, and the client still diverges. The initializer being "lazy" changes when it runs, not where. You have to render a neutral value first.

The pattern: neutral default, then useEffect

The fix is to render a deterministic placeholder on the server and overwrite it with the real value after mount, where only the client runs:

function Greeting() {
  const [greeting, setGreeting] = useState('Hello');   // neutral, same on both
  useEffect(() => {
    const h = new Date().getHours();                   // client-only, post-hydration
    setGreeting(h < 12 ? 'Morning' : 'Afternoon');
  }, []);
  return <span>{greeting}</span>;
}

Server and client both render Hello initially — identical HTML, clean hydration — and the real greeting appears a tick later on the client. useEffect never runs on the server, so anything inside it (time, randomness, localStorage, window) is safe.

Why this matters extra for a billing dashboard

On a marketing page a hydration flicker is ugly. On a dashboard showing money, it's corrosive: a balance or spend figure that renders $0.00 then jumps to $412.19, or a "last updated" time that flickers, makes a user wonder whether the number itself is trustworthy. The numbers are the product. So the rule is enforced as a review gate: no new Date(), Date.now(), Math.random(), crypto.randomUUID(), localStorage, or window.* in a client component's render body. Money and time values follow the neutral-default-then-useEffect pattern, every time.

Static pages: bake at build, not at render

For statically pre-rendered pages the same constraint applies, just shifted earlier: compute time-dependent values at build (via the framework's static-params/metadata hooks), never at request render. A "published 3 days ago" that's computed at render will be wrong the moment the page is cached and served later. Bake the absolute date at build and format relative-time on the client in useEffect if you need "3 days ago" to stay live.

Catching it before it ships

Because the failure is easy to reintroduce (someone adds a new Date() to a render body without thinking), it's a grep-able review gate, not just a convention. A reviewer (human or automated) scans changed client components for the banned reads and flags them before merge. A rule that depends on everyone remembering it will regress; a rule that's checked on every diff won't. (Same philosophy as the parity checks — verify the invariant, don't assume it.)

The takeaway

Hydration mismatch comes from reading non-deterministic values — time, randomness, localStorage — during render, and a lazy state initializer doesn't save you because it runs on the server too. Render a neutral default and overwrite it in useEffect after mount; bake static values at build. On a dashboard where the numbers are the product, a stable render isn't polish — it's the difference between a balance that looks authoritative and one that looks like it's guessing.

Written by Nemo TeamEngineering, product, and company posts from the Nemo Router team — code-first, cost-honest, no vendor-marketing fluff.

More from Engineering

All posts →
Engineering

Canary Deploys and Auto-Rollback by SLO

A deploy shouldn't need a human watching a dashboard. Here is how a 5% canary, a fixed observation window, and SLO-gated auto-rollback let changes ship and self-heal without a 3 a.m. page.

Nemo Team
9 min
Engineering

Credit Ledger Parity Checks: Catching Drift Early

If a balance and its ledger ever disagree, money is wrong somewhere. Here is how continuous parity checks compare balance to ledger sum and surface a reservation leak before it becomes a billing incident.

Nemo Team
8 min
Engineering

Zero-Downtime Migrations With Two Schema Owners

One Postgres, two migration engines — Alembic and Prisma — that must never touch each other. Here is how additive-only migrations and idempotent hotfixes keep deploys safe and downtime-free.

Nemo Team
9 min