When Next.js hydration only breaks under load
- Next.js
- Hydration
- SSR
- Caching
- Production
There is a version of the hydration mismatch that resists every normal debugging technique. It looks like this in your error tracker:
Hydration failed because the initial UI does not match what was rendered on the server.
occurrences: 412 in the last 24h
affected users: 0.7%
You open the page. It works. You open it in incognito, on a phone, with a VPN, in a different timezone. It works. You cannot make it fail, and it is failing four hundred times a day.
Ordinary hydration mismatches are deterministic — a date formatted in the wrong timezone, a Math.random() in render, localStorage read during the first pass. Reproduce the condition and you reproduce the bug. This class is different: the trigger is not an input to your component, it is the state of the server when the request arrived. Which is why the one machine you cannot reproduce it on is the one with a single user.
Why load changes the answer
Three things are true in production that are not true on your laptop, and all three are about the server rendering more than one thing at a time.
Your dev server is single-user. Every concurrent request shares the same Node process, the same module scope, and the same globals. One request at a time never reveals that.
Your dev server has no cache in front of it. In production the HTML a browser hydrates may have been rendered for somebody else, minutes ago, under different conditions.
Your dev server is not under memory pressure. Behaviour that only appears when the heap is near its limit — timeouts firing, fallbacks rendering, connections being refused — never appears with 200MB in use.
Cause 1: module-scope state shared between concurrent requests
This is the big one, and it is the same root cause as the most common SSR memory leak seen from the other side.
// Broken: one variable, shared by every request the process handles.
let currentUser = null
export async function getServerSideProps({ req }) {
currentUser = await getUserFromCookie(req)
const dashboard = await buildDashboard(currentUser)
return { props: { dashboard, userName: currentUser.name } }
}
With one user this is correct. With two overlapping requests, request A sets currentUser, awaits, and request B overwrites it during that await. A's dashboard is now built for B.
The hydration mismatch is the symptom you get lucky with — the client re-renders with its own session and the text differs, so React complains. The failure you do not see is the response where the mismatch happens to be invisible and user A simply receives user B's data.
Anything derived per request must live in the request's own scope:
export async function getServerSideProps({ req }) {
const user = await getUserFromCookie(req) // local to this invocation
const dashboard = await buildDashboard(user)
return { props: { dashboard, userName: user.name } }
}
Treat this one as a security incident, not a rendering bug. If a mismatch report shows one user's name against another user's content, the leak is already happening in the responses where nothing complained.
Cause 2: a shared cache serving HTML rendered for someone else
// Broken: the response varies by cookie, but the CDN is told it does not.
export async function getServerSideProps({ req, res }) {
res.setHeader('Cache-Control', 'public, s-maxage=600')
const user = await getUserFromCookie(req)
return { props: { greeting: `Hello, ${user.name}` } }
}
The first visitor's HTML is cached and served to everyone for ten minutes. Each of them hydrates a page addressed to a stranger, and every one of them logs a mismatch.
Personalized responses must not be shared:
export async function getServerSideProps({ req, res }) {
// private: the browser may cache it, shared caches may not.
res.setHeader('Cache-Control', 'private, no-store')
const user = await getUserFromCookie(req)
return { props: { greeting: `Hello, ${user.name}` } }
}
The rule that prevents this whole family: if the response depends on a cookie, it cannot be public. If you want both personalization and a CDN, the personalized part has to render on the client after mount, or move behind a separate authenticated request — not into shared HTML.
Cause 3: ISR serving a stale page against fresh client data
export async function getStaticProps() {
const products = await getProducts()
return { props: { products }, revalidate: 60 }
}
Nothing here is wrong. But if the client fetches the same data on mount and renders from it immediately, then during the revalidation window the server's HTML is up to 60 seconds older than what the client just fetched. Under low traffic the page is regenerated rarely and the window is small; under high traffic it is being hit constantly and some fraction of users land exactly inside it.
That is the tell for this cause: the mismatch rate rises with traffic instead of staying proportional to it.
The fix is to make the first client render match the server rather than the fetch:
// The server's data renders first; fresh data is applied after mount.
const { data } = useSWR('/api/products', fetcher, {
fallbackData: products,
revalidateOnMount: false, // do not replace during the hydrating render
})
Cause 4: a timeout or fallback that only fires when things are slow
// Broken: under load this call sometimes exceeds its budget, and the
// server renders a different tree than the client will.
export async function getServerSideProps() {
const recommendations = await Promise.race([
fetchRecommendations(),
new Promise((r) => setTimeout(() => r(null), 200)),
])
return { props: { recommendations } }
}
At 200ms this resolves properly every time on your laptop. In production, when the recommendation service is busy, some requests time out and render the empty state — and then the client, hydrating with its own successful fetch, renders the populated one.
Any server-side timeout is a source of nondeterministic HTML. If a fallback is genuinely acceptable, it has to be visible to the client too, so both sides agree on what happened:
const recommendations = await withTimeout(fetchRecommendations(), 200)
return {
props: {
recommendations: recommendations ?? [],
// Tell the client which branch the server took, so it can match.
recommendationsTimedOut: recommendations === null,
},
}
How to actually reproduce it
You cannot find these by loading the page. You have to make your machine behave like the server.
Run a production build with concurrency and a small heap:
yarn build
node --max-old-space-size=512 node_modules/.bin/next start
Drive real concurrent load, with different sessions:
# Two distinct users, hammering the same route at once
autocannon -c 50 -d 30 -H "Cookie: session=user-a" http://localhost:3000/dashboard &
autocannon -c 50 -d 30 -H "Cookie: session=user-b" http://localhost:3000/dashboard
Then check the crossover directly, which is the fastest single test for Cause 1:
# Fetch as user A, 40 times, concurrently — and count distinct names in the HTML
seq 40 | xargs -P 20 -I{} curl -s -H "Cookie: session=user-a" \
http://localhost:3000/dashboard \
| grep -o 'data-user-name="[^"]*"' | sort -u
Every response was requested as user A. If that prints more than one name, you have request-scoped data in module scope, and you have just reproduced in ten seconds what you could not reproduce all week.
Check what your CDN is being told:
curl -sI https://your-site.com/dashboard | grep -i -E 'cache-control|vary|age|x-vercel-cache'
A public or s-maxage on a route that reads a cookie is the bug, visible without any load at all. An Age header above zero on a personalized page means it is already being served from a shared cache.
Instrumenting production, when local reproduction fails
If it still will not reproduce, capture the context from real failures rather than guessing:
'use client'
import { useEffect } from 'react'
export function HydrationReporter({ serverRenderedAt, cacheStatus }) {
useEffect(() => {
// React fires this for recoverable hydration errors in React 19.
const onError = (event) => {
if (!String(event.message).includes('Hydration')) return
report({
serverRenderedAt, // when the HTML was made
cacheStatus, // HIT or MISS
clientRenderedAt: new Date().toISOString(),
ageSeconds:
(Date.now() - new Date(serverRenderedAt).getTime()) / 1000,
})
}
window.addEventListener('error', onError)
return () => window.removeEventListener('error', onError)
}, [serverRenderedAt, cacheStatus])
return null
}
Pass serverRenderedAt and the cache status down from getServerSideProps. The distribution of ageSeconds across failures is the diagnosis by itself:
- Ages clustered near zero — a concurrency bug. The HTML was fresh; two requests interfered.
- Ages spread across your whole cache TTL — a caching bug. Stale HTML meeting fresh client state.
- Failures clustered in time rather than by age — a load-dependent timeout or fallback. Correlate against your latency graphs for that window.
A quick checklist
- Rule out the deterministic causes first — they are far more common and much cheaper to fix.
- Grep for module-scope
let/varin anything underpages/,app/orlib/that a request handler writes to. - Check
Cache-Controlon every personalized route: cookie-dependent responses must beprivate. - Check every server-side
Promise.race, timeout and fallback for a branch the client cannot see. - Reproduce with concurrent load, two sessions, and a small heap — not with one browser tab.
- Run the crossover test: many concurrent requests as one user, count distinct identities in the output.
- If it still hides, ship the reporter and read the
ageSecondsdistribution.
When this is worth outside help
The deterministic mismatches are usually a good afternoon's work. This class is not, and it is worth being honest about why: the bug is in the interaction between your code, your cache and your traffic, so it cannot be found by reading a component in isolation. It needs someone to reproduce production conditions deliberately.
It is also the class where the visible symptom is the least important part. A hydration warning caused by shared module state is a data-leak report that happens to have rendered visibly — the responses where the wrong data looked plausible did not warn anybody.
I wrote up what stabilizing a high-traffic SSR marketplace involved: intermittent failures under real traffic, a memory leak underneath them, and hydration as one symptom among several rather than the disease.
A Production Health Audit is built for exactly this: I run your app under conditions that match your users rather than your laptop, and hand back a written list of what is actually breaking, what it costs, and the fix for each one.