projects

Hydration mismatch on the home page

status
resolved
severity
low (visual/console only, no broken functionality)
detected
2026-06-04
resolved
2026-07-31
duration
58 days live in production, undetected

React error #418 — a hydration mismatch — ran in every visitor's console on the home page for close to two months before anyone looked at it again.

Symptom

A Lighthouse audit against nathandebock.com on 2026-06-04 showed Best Practices at 96/100 with one failing audit: an uncaught Minified React error #418 in the browser console. Nothing was visibly broken — no layout shift, no missing content — the tree that failed to hydrate cleanly gets silently regenerated client-side, so the only symptom was the console error and the score drop.

Root cause

ProjectCard (components/ui/project-card.tsx) is a client component that computed formatDaysAgo(project.lastCommitDate) directly during render, defaulting to new Date() for "now". The home page is statically prerendered at build time, so the server-rendered HTML freezes that string — "last commit · 40d ago", say — at whatever "now" was when the site was last built and deployed. React hydrates the same client component in the visitor's browser using the browser's actual current date. Any gap between deploy time and a visitor's first load crosses enough day boundaries to produce a different string, and React flags the mismatch.

The repo had gone eight weeks without a rebuild by the time this was reinvestigated. The mismatch was not intermittent — it was guaranteed, and it got larger every day the build aged.

Why it wasn't caught earlier

A fix landed the next day (f57b67b, 2026-06-05) addressing a real but different hydration risk in the Lenis smooth-scroll provider under prefers-reduced-motion — its commit message specifically notes "no lenis-class hydration mismatch" as a design goal of that change. That diagnosis was reasonable given what was visible at the time, and it did fix a real bug (a stopped-but-still-mounted Lenis instance locking scroll entirely on long pages), but it was the wrong bug for this error. Nobody re-ran Lighthouse against production after that fix to confirm error #418 was actually gone, and the repo went dormant three commits later.

Locally, this class of bug is close to unreproducible by construction: a build followed immediately by a test has server "now" and client "now" within milliseconds of each other, so the mismatch window never opens. Every local QA pass before and after f57b67b scored Best Practices 100/100 — correctly, for what was being tested.

Fix

Moved the formatting out of the client component. app/(public)/page.tsx (a server component, already iterating the project list) now computes formatDaysAgo(project.lastCommitDate) once and passes the resulting string down as a staleness prop; ProjectCard renders it without recomputing anything. There is exactly one computation of the value now, done server-side, so server and client output cannot diverge — this removes the bug class, not just this instance of it.

Verified: clean production build, full test suite (95/95), no console errors on a fresh local production server. The specific multi-week-gap trigger can't be reproduced in the same session either way — the fix is verified by eliminating the dual computation, not by re-observing the symptom shrink to zero.

Pattern

This is the second production incident, across two different projects, whose root cause is "nothing was watching." The webhook incident documented in /projects/serlei went undetected until the client manually reconciled sales; this one went undetected until an unrelated audit reopened the repo. Neither has real monitoring attached — console.error and a one-off Lighthouse run are not observability. The fix here closes this specific bug; it does not close the pattern.