Avoiding Hydration Mismatch Errors

Text content did not match. Server: "2 minutes ago" Client: "3 minutes ago" is React telling you that the HTML it streamed from the server does not match what the browser produced on its first render. When you use React Query for SSR, the usual culprit is not the cache data itself — which is byte-identical after hydration — but something derived from it in the render path: a relative timestamp, a locale-formatted number, a randomly generated key, or a query that was still pending when the server dehydrated it. This page catalogs those causes and the fixes. It sits inside SSR & RSC Hydration Boundaries; if your problem is a redundant refetch rather than a mismatch warning, the companion recipe Hydrating React Query in the Next.js App Router covers the clean wiring.

A hydration mismatch is different from a refetch flash: the flash is a performance issue, the mismatch is a correctness error that makes React throw away the server markup for the affected subtree and re-render it from scratch, losing the SSR benefit and sometimes corrupting focus or scroll position. The goal is a render that is a pure, deterministic function of the hydrated cache.

Diagnostic Checklist

You have a hydration mismatch, not a refetch problem, if:

  • The console shows Hydration failed because the initial UI does not match what was rendered on the server or Text content did not match. Server: … Client: ….
  • The mismatched text is a time, date, currency, or number that depends on locale or timezone.
  • The element that flickers or resets contains a value from Date.now(), Math.random(), crypto.randomUUID(), or performance.now() computed during render.
  • The mismatch appears only for a query that was still loading when the page was sent — a pending query that hydrated to undefined.
  • Wrapping the element in suppressHydrationWarning makes the console warning disappear but the visible value still snaps to a new value after load.

Why server and client renders diverge A single hydrated cache value branches into three render paths. A pure render of the value matches on both server and client. A render that applies Date.now, random, or locale formatting produces two different strings and fails. A pending query hydrates to undefined on the server but resolves on the client, also diverging. hydrated cache value identical on server & client pure render {data.title} deterministic derived, non-deterministic Date.now() · Math.random() toLocaleString(tz) pending query dehydrated server: undefined client: resolves match ✓ mismatch ✗ mismatch ✗ Fix: fixed locale/tz · defer to useEffect · shouldDehydrateQuery excludes pending Keep the render a pure function of the hydrated cache
The same cache value renders cleanly only when the render is deterministic; derived non-determinism and dehydrated pending queries are the two divergence sources.

Step-by-Step Implementation

Step 1 — Read the mismatch diff before changing anything

React prints the exact server and client strings. That diff tells you whether the problem is a formatted value (locale/timezone), a generated value (random/time), or a missing value (pending query hydrating to undefined). Do not guess — the diff names the divergent text.

// Reproduce reliably: the server renders in UTC, your browser in local time.
function PublishedAt({ iso }: { iso: string }) {
  // BROKEN: toLocaleString uses the runtime's timezone, which differs
  // between the server (UTC) and the client (visitor local).
  return <time dateTime={iso}>{new Date(iso).toLocaleString()}</time>;
}

Cache Behavior Analysis. The iso string comes from the hydrated cache and is identical in both environments, so React Query is not at fault. toLocaleString() reads process.env.TZ on the server and the browser’s timezone on the client, producing two strings from one input. The mismatch is in the render function, not the cache, which is why no staleTime change fixes it.

Step 2 — Make the render deterministic

Pin the locale and timezone so formatting is identical everywhere, or defer any inherently client-only formatting (like a live relative time) to a useEffect that runs after hydration.

import { useEffect, useState } from 'react';

// Deterministic: fixed locale + timezone renders the same string on both sides.
function PublishedAt({ iso }: { iso: string }) {
  const fixed = new Intl.DateTimeFormat('en-US', {
    dateStyle: 'medium',
    timeStyle: 'short',
    timeZone: 'UTC',
  }).format(new Date(iso));
  return <time dateTime={iso}>{fixed}</time>;
}

// Or defer client-only relative time until after hydration.
function RelativeTime({ iso }: { iso: string }) {
  const [label, setLabel] = useState<string | null>(null);
  useEffect(() => {
    const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60_000);
    setLabel(`${mins} min ago`);
  }, [iso]);
  // Server and first client render both output the stable ISO fallback.
  return <time dateTime={iso}>{label ?? new Date(iso).toISOString()}</time>;
}

Cache Behavior Analysis. Both components now produce a value that depends only on the hydrated iso string and constants, so the server and first client render are byte-identical and hydration succeeds. RelativeTime outputs the deterministic ISO string during hydration and swaps to the relative label in useEffect, which React does not diff against the server HTML because it runs after commit — no mismatch, and the cache value is untouched.

Step 3 — Exclude pending and error queries from dehydration

If you dehydrate a query that has not resolved, the server sends undefined for it while the client resolves real data — a guaranteed mismatch. React Query v5 already excludes pending and error queries by default, but if you have customized dehydrateOptions, make the filter explicit with shouldDehydrateQuery.

import { dehydrate, defaultShouldDehydrateQuery } from '@tanstack/react-query';

const state = dehydrate(queryClient, {
  // Only ship queries that fully resolved on the server.
  shouldDehydrateQuery: (query) =>
    defaultShouldDehydrateQuery(query) && query.state.status === 'success',
});

Cache Behavior Analysis. dehydrate walks every query and calls shouldDehydrateQuery per entry; returning false drops that query from the serialized payload entirely. Composing with defaultShouldDehydrateQuery keeps React Query’s built-in rules (success-only) and adds the explicit status === 'success' guard, so a query still in pending or error never reaches the client as a half-formed entry — the client simply fetches it fresh instead of hydrating an undefined that mismatches the server HTML.

Step 4 — Apply suppressHydrationWarning only to unavoidable leaves

Some values genuinely cannot match — a precise “seconds ago” counter, for instance. For a single leaf, suppressHydrationWarning tells React to tolerate that one node’s text difference. It is a scalpel, not a blanket.

// Acceptable: one leaf, cosmetic, genuinely time-dependent.
function LiveClock({ iso }: { iso: string }) {
  return (
    <span suppressHydrationWarning>
      {new Date(iso).toLocaleTimeString()}
    </span>
  );
}

Cache Behavior Analysis. suppressHydrationWarning suppresses the console warning for this element’s text and one level of children only; it does not reconcile the difference, so React still adopts the client value after hydration. Because the underlying data is the same cache value and only the presentation is time-sensitive, this is safe — but applied to a subtree hiding a real data divergence it would mask a correctness bug while leaving the flicker in place.


Edge Cases and Gotchas

Random keys generated during render

Generating IDs in render (key={crypto.randomUUID()}) produces different keys on server and client, which both mismatches and defeats reconciliation. Derive keys from stable cache fields instead.

// BROKEN: new id every render, different on server vs client
{items.map((it) => <Row key={crypto.randomUUID()} item={it} />)}

// RIGHT: stable key from the entity itself
{items.map((it) => <Row key={it.id} item={it} />)}

Reading browser-only APIs in render

window, localStorage, and matchMedia do not exist on the server, so any value read from them during render diverges. Gate them behind a mounted flag set in useEffect.

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Render the server-safe branch until mounted, then the client-only value.
return mounted ? <Themed prefersDark={matchMedia('(prefers-color-scheme: dark)').matches} /> : <Themed prefersDark={false} />;

Dehydrating locale-dependent data instead of raw values

Store raw ISO strings and numbers in the cache and format at render time with a fixed locale, rather than dehydrating pre-formatted strings that encode the server’s locale. Pre-formatting bakes the server timezone into the cache and forces every consumer to inherit it.


Common Pitfalls and Resolutions

Observable Issue Root Cause Diagnostic Resolution
Text content did not match on a date or number toLocaleString/Intl uses the runtime timezone or locale, differing between server and browser Format with an explicit locale and timeZone, or defer relative formatting to a useEffect
Mismatch on a list item key or generated ID Math.random, Date.now, or crypto.randomUUID called during render Derive keys and IDs from stable cache fields; move any randomness into an event handler or effect
A query renders empty on the server then fills in on the client A pending query was dehydrated and hydrated to undefined Exclude non-success queries with shouldDehydrateQuery, or await prefetchQuery so it resolves before dehydration
suppressHydrationWarning hides the warning but content still snaps It was applied to mask a real data divergence rather than a cosmetic time value Fix the underlying determinism; reserve suppressHydrationWarning for single time-dependent leaf nodes

Frequently Asked Questions

Why does formatting a date from my cache cause a hydration mismatch?

The cache value is identical on server and client, but toLocaleString formats it using the runtime’s locale and timezone. The server uses its own timezone (often UTC) while the browser uses the visitor’s, so the same timestamp renders as two different strings and React flags the divergence. Format with a fixed timeZone and locale via Intl.DateTimeFormat, or defer formatting to a useEffect so the client-specific string is applied only after hydration completes.

Should I dehydrate error and pending queries?

By default React Query v5 dehydrates only successful queries, and that is usually what you want. Pending queries carry no data and hydrate to undefined, causing an immediate refetch and a possible mismatch; error queries hydrate a thrown state the client may render differently. Use dehydrateOptions.shouldDehydrateQuery to explicitly include or exclude queries by status when you deviate from the default — compose it with defaultShouldDehydrateQuery so you keep the built-in rules.

Is suppressHydrationWarning a safe way to silence these errors?

Only for a single leaf element whose difference is genuinely unavoidable and cosmetic, such as a live relative timestamp. It suppresses the warning for that element’s text and one level of children but does not fix the underlying divergence, so React still discards and re-renders that node on the client. Never wrap a large subtree with it to mute a real data mismatch — that hides a correctness bug while leaving the visible flicker in place.