This page is part of the Session State Persistence & Hydration Fallbacks section, which covers how frontend applications preserve user intent across crashes, tab closures, and framework-level rendering failures.
The Problem: When SSR and the Client Disagree #
Hydration mismatches happen when the DOM tree the server (or static generator) produced diverges from what the client-side virtual DOM expects during the initial reconciliation pass. The runtime consequence varies by framework: React 18 throws Error: Hydration failed because the initial UI does not match what was rendered on the server, Vue 3 emits [Vue warn]: Hydration mismatch, and Svelte silently patches attributes that differ β silently, until the patched DOM breaks downstream event bindings or accessibility associations.
The user-visible blast radius is larger than the console warning suggests. A single mismatch in a formβs parent can orphan <label for> associations, reset controlled input values, drop focus to <body>, and sever the componentβs connection to its external store β all within a single paint frame. In session-critical flows such as checkout, multi-step wizards, or collaborative document editing, this amounts to invisible data loss with no retry path for the user.
The root causes fall into three categories:
- Non-deterministic render output:
Date.now(),Math.random(),crypto.randomUUID(), orIntl.DateTimeFormatcalled during render produce different values in the Node.js SSR environment versus the browser. - Environment-gated code running too early:
typeof window !== 'undefined'guards that short-circuit on the server but execute client-side logic before hydration commits. - Third-party script DOM mutations: Analytics tags, chat widgets, or A/B testing tools that rewrite attributes before the frameworkβs hydration pass completes.
Prerequisites #
Architecture: How Hydration Recovery Works #
Core Implementation #
The pattern below composes three units: a framework-agnostic diff utility, an IndexedDB-backed snapshot store with tiered fallback, and a React error boundary that ties them together. Vue and Svelte variants follow in the Advanced section.
// βββ types.ts ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export interface HydrationSnapshot {
version: string; // semver of the state schema
stateHash: string; // SHA-1 of JSON.stringify(data) for quick equality
sessionSeed: string; // stable per-session identifier, set server-side
timestamp: number; // Unix ms β SSR sets this, CSR reads it
data: Record<string, unknown>;
}
// βββ utils/hydration-diff.ts βββββββββββββββββββββββββββββββββββββββββββββββββ
export function diffHydrationState(
serverSnapshot: HydrationSnapshot,
clientState: Record<string, unknown>
): { matches: boolean; divergentKeys: string[] } {
const divergentKeys: string[] = [];
for (const key of Object.keys(serverSnapshot.data)) {
if (JSON.stringify(clientState[key]) !== JSON.stringify(serverSnapshot.data[key])) {
divergentKeys.push(key);
}
}
// Client keys absent from the server snapshot are also divergent
for (const key of Object.keys(clientState)) {
if (!(key in serverSnapshot.data) && !divergentKeys.includes(key)) {
divergentKeys.push(key);
}
}
return { matches: divergentKeys.length === 0, divergentKeys };
}
// βββ utils/deterministic-id.ts βββββββββββββββββββββββββββββββββββββββββββββββ
// Hash-based stable ID β same seed always produces the same output.
// Never include Date.now() or Math.random() here; that defeats SSR/CSR parity.
export function generateStableId(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
return `sid-${Math.abs(hash).toString(36)}`;
}
// βββ db/snapshot-store.ts ββββββββββββββββββββββββββββββββββββββββββββββββββββ
import { openDB } from 'idb';
const DB = 'session-recovery';
const STORE = 'state-snapshots';
export async function persistSnapshot(snap: HydrationSnapshot): Promise<void> {
try {
const db = await openDB(DB, 1, {
upgrade: (d) => d.createObjectStore(STORE, { keyPath: 'version' }),
});
const tx = db.transaction(STORE, 'readwrite');
await tx.store.put(snap);
await tx.done;
} catch {
// Tier 2: sessionStorage (tab-scoped, cleared on close)
try {
sessionStorage.setItem(`recovery:${snap.version}`, JSON.stringify(snap));
} catch {
// Tier 3: localStorage (survives tab close, quota-limited)
const queue: HydrationSnapshot[] = JSON.parse(
localStorage.getItem('recovery-queue') ?? '[]'
);
queue.push(snap);
// Evict oldest entry if quota pressure is detected
if (queue.length > 5) queue.shift();
localStorage.setItem('recovery-queue', JSON.stringify(queue));
}
}
}
export async function loadLatestSnapshot(): Promise<HydrationSnapshot | null> {
try {
const db = await openDB(DB, 1);
const all = await db.getAll(STORE);
return all.sort((a, b) => b.timestamp - a.timestamp)[0] ?? null;
} catch {
return null;
}
}
// βββ components/HydrationBoundary.tsx ββββββββββββββββββββββββββββββββββββββββ
import React, { Component, ErrorInfo } from 'react';
import { diffHydrationState, type HydrationSnapshot } from '../utils/hydration-diff';
import { persistSnapshot, loadLatestSnapshot } from '../db/snapshot-store';
interface Props {
serverSnapshot: HydrationSnapshot;
clientState: Record<string, unknown>;
fallback: React.ReactNode;
onRecovery?: (divergentKeys: string[]) => void;
children: React.ReactNode;
}
interface State {
hasError: boolean;
divergentKeys: string[];
}
export class HydrationBoundary extends Component<Props, State> {
state: State = { hasError: false, divergentKeys: [] };
static getDerivedStateFromError(): Partial<State> {
return { hasError: true };
}
async componentDidCatch(error: Error, _info: ErrorInfo) {
if (!error.message.toLowerCase().includes('hydration')) return;
const { divergentKeys } = diffHydrationState(
this.props.serverSnapshot,
this.props.clientState
);
this.setState({ divergentKeys });
this.props.onRecovery?.(divergentKeys);
// Flush current state to durable storage before any re-render
await persistSnapshot({
...this.props.serverSnapshot,
data: this.props.clientState,
timestamp: Date.now(),
});
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
Architecture Note #
The boundaryβs power comes from sequencing: componentDidCatch fires synchronously on the React commit thread, before the reconciler discards the failed subtree. Calling persistSnapshot inside componentDidCatch guarantees the last in-memory state is written to IndexedDB (or its fallback) before React unmounts the child tree and renders the fallback prop. Without this sequencing, a fast network round-trip or a tab hibernation event can wipe the in-memory state before the write completes.
useSyncExternalStore provides the complementary guarantee on the read path: by supplying a getServerSnapshot that returns the embedded window.__HYDRATION_SNAPSHOT, React can compare server and client reads on the same frame without re-fetching from the server. Any store that wraps useSyncExternalStore correctly will never feed stale data into the hydration pass, eliminating the most common source of timestamp and UUID drift.
Edge Cases and Failure Modes #
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Infinite rehydration loop | Blank page, CPU spike | Guard useEffect / onMount mutations with a useRef initialised flag; never write to state synchronously during the first render |
| Timezone / locale drift | dateModified attributes differ between SSR and CSR |
Normalise all dates to UTC ISO-8601 strings before passing to render; use toISOString() not toLocaleString() |
| Third-party script DOM mutation | Attribute mismatch on <html> or <body> |
Load analytics and chat widgets via useInsertionEffect or defer to requestIdleCallback after the hydration commit |
BroadcastChannel memory leak |
Tab count Γ listener = accumulating handlers | Return a cleanup closure from initCrossTabSync and call it in useEffect return / onDestroy |
| IndexedDB quota exceeded in crash loop | persistSnapshot silent failure |
Poll navigator.storage.estimate() before writes; evict expired snapshots using an LRU policy on the timestamp field |
| Race between async data fetch and hydration commit | Stale server HTML after client fetch resolves | Use streaming SSR with Suspense boundaries, or defer all fetches to a useEffect that checks document.readyState === 'complete' |
Advanced Variant: Vue 3 and Svelte Reconciliation Guards #
// βββ vue/use-hydration-guard.ts ββββββββββββββββββββββββββββββββββββββββββββββ
import { onBeforeMount, onMounted, ref } from 'vue';
import { diffHydrationState } from '../utils/hydration-diff';
import { persistSnapshot, loadLatestSnapshot } from '../db/snapshot-store';
import type { HydrationSnapshot } from '../types';
export function useHydrationGuard(serverSnapshot: HydrationSnapshot) {
const isRecovering = ref(false);
const divergentKeys = ref<string[]>([]);
// onBeforeMount runs after SSR HTML is parsed but before Vue patches the DOM β
// the ideal place to diff without triggering a full re-render.
onBeforeMount(async () => {
const clientState = JSON.parse(
sessionStorage.getItem('client-state') ?? '{}'
) as Record<string, unknown>;
const diff = diffHydrationState(serverSnapshot, clientState);
if (!diff.matches) {
divergentKeys.value = diff.divergentKeys;
isRecovering.value = true;
await persistSnapshot({ ...serverSnapshot, data: clientState, timestamp: Date.now() });
}
});
onMounted(() => {
// Safe to access the fully-hydrated DOM here; patch only divergent keys
if (isRecovering.value) {
console.warn('[hydration-guard] divergent keys:', divergentKeys.value);
}
isRecovering.value = false;
});
return { isRecovering, divergentKeys };
}
// βββ svelte/HydrationGuard.svelte ββββββββββββββββββββββββββββββββββββββββββββ
// <script lang="ts">
// import { onMount } from 'svelte';
// import { diffHydrationState } from '../utils/hydration-diff';
// import { persistSnapshot } from '../db/snapshot-store';
// import type { HydrationSnapshot } from '../types';
//
// export let serverSnapshot: HydrationSnapshot;
// let isRecovering = false;
//
// onMount(async () => {
// const clientState = JSON.parse(sessionStorage.getItem('client-state') ?? '{}');
// const { matches, divergentKeys } = diffHydrationState(serverSnapshot, clientState);
// if (!matches) {
// isRecovering = true;
// await persistSnapshot({ ...serverSnapshot, data: clientState, timestamp: Date.now() });
// console.warn('[svelte hydration-guard] divergent keys:', divergentKeys);
// isRecovering = false;
// }
// });
// </script>
//
// {#if isRecovering}
// <slot name="fallback" />
// {:else}
// <slot />
// {/if}
// βββ Cross-tab synchronisation βββββββββββββββββββββββββββββββββββββββββββββββ
// Broadcast a validated snapshot to sibling tabs so they don't redundantly
// attempt their own recovery; each tab closes the channel on unmount.
export function initCrossTabSync(
channelName: string,
onSync: (snap: HydrationSnapshot) => void
): () => void {
const channel = new BroadcastChannel(channelName);
channel.onmessage = (event: MessageEvent<HydrationSnapshot>) => onSync(event.data);
return () => channel.close();
}
export function broadcastRecoveredSnapshot(
channelName: string,
snap: HydrationSnapshot
): void {
const channel = new BroadcastChannel(channelName);
channel.postMessage(snap);
channel.close();
}
Testing and CI/CD Validation #
Fault-injection tests for hydration recovery must simulate the moment of divergence, not just the happy path. The harness below uses Vitest with JSDOM and mocks window.__HYDRATION_SNAPSHOT to introduce a controlled mismatch.
// tests/hydration-boundary.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { HydrationBoundary } from '../components/HydrationBoundary';
import * as store from '../db/snapshot-store';
const baseSnapshot = {
version: '1.0.0',
stateHash: 'abc123',
sessionSeed: 'test-seed',
timestamp: 1700000000000,
data: { userId: 'u1', formDraft: 'hello world' },
};
describe('HydrationBoundary', () => {
beforeEach(() => {
vi.spyOn(store, 'persistSnapshot').mockResolvedValue(undefined);
// Suppress React's error boundary console output in test runs
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('renders children when there is no mismatch', () => {
render(
<HydrationBoundary
serverSnapshot={baseSnapshot}
clientState={baseSnapshot.data}
fallback={<p>Recoveringβ¦</p>}
>
<p>Content loaded</p>
</HydrationBoundary>
);
expect(screen.getByText('Content loaded')).toBeTruthy();
});
it('renders fallback and persists snapshot on hydration error', async () => {
// Introduce mismatch: userId differs between server and client
const ThrowOnMount = () => {
throw new Error('Hydration failed because the initial UI does not match');
};
render(
<HydrationBoundary
serverSnapshot={baseSnapshot}
clientState={{ userId: 'u2', formDraft: 'hello world' }}
fallback={<p data-testid="fallback">Recoveringβ¦</p>}
onRecovery={(keys) => expect(keys).toContain('userId')}
>
<ThrowOnMount />
</HydrationBoundary>
);
expect(screen.getByTestId('fallback')).toBeTruthy();
// Allow the async persistSnapshot call inside componentDidCatch to flush
await vi.waitFor(() =>
expect(store.persistSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ userId: 'u2' }) })
)
);
});
it('does not call persistSnapshot for non-hydration errors', () => {
const ThrowGeneric = () => { throw new Error('Something else'); };
render(
<HydrationBoundary
serverSnapshot={baseSnapshot}
clientState={baseSnapshot.data}
fallback={<p>Error</p>}
>
<ThrowGeneric />
</HydrationBoundary>
);
expect(store.persistSnapshot).not.toHaveBeenCalled();
});
});
For CI/CD pipelines, add a Playwright smoke test that navigates to a page with ?force_mismatch=1 (a query param your SSR layer handles by injecting a deliberately stale snapshot) and asserts:
# In your CI pipeline (GitHub Actions / GitLab CI)
npx playwright test tests/hydration-recovery.e2e.ts --reporter=dot
// tests/hydration-recovery.e2e.ts
import { test, expect } from '@playwright/test';
test('recovery fallback renders and restores state on forced mismatch', async ({ page }) => {
await page.goto('/session-demo?force_mismatch=1');
// The fallback should appear briefly, then resolve to the restored state
await expect(page.getByRole('status', { name: /recovering/i })).toBeVisible();
await expect(page.getByTestId('session-content')).toBeVisible({ timeout: 5000 });
// Confirm no unhandled JS errors were thrown
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
expect(errors.filter((e) => /hydration/i.test(e))).toHaveLength(0);
});
Telemetry: Structured Mismatch Logging #
Non-blocking telemetry completes the recovery loop by giving you production signal on mismatch rates. The window.onerror interceptor below batches payloads through requestIdleCallback and transmits them via navigator.sendBeacon, keeping the main thread clear through First Contentful Paint.
// telemetry/hydration-logger.ts
interface HydrationTelemetry {
traceId: string;
eventType: 'MISMATCH' | 'RECOVERY_SUCCESS' | 'RECOVERY_FAILURE';
latencyMs: number;
divergentKeys: string[];
sessionSeed: string;
}
const queue: HydrationTelemetry[] = [];
export function logHydrationMismatch(payload: Omit<HydrationTelemetry, 'traceId'>): void {
queue.push({ ...payload, traceId: crypto.randomUUID() });
if ('requestIdleCallback' in window) {
requestIdleCallback(flushQueue);
} else {
setTimeout(flushQueue, 0);
}
}
function flushQueue(): void {
if (queue.length === 0) return;
const body = JSON.stringify({ events: queue.splice(0) });
navigator.sendBeacon?.('/api/telemetry/hydration', body);
}
// Wire up at app entry point
export function initHydrationInterceptor(sessionSeed: string): void {
const originalOnerror = window.onerror;
window.onerror = (msg, _url, _line, _col, error) => {
if (typeof msg === 'string' && /hydration|mismatch/i.test(msg)) {
logHydrationMismatch({
eventType: 'MISMATCH',
latencyMs: performance.now(),
divergentKeys: [], // populated by HydrationBoundary.onRecovery callback
sessionSeed,
});
}
return originalOnerror?.(msg, _url, _line, _col, error) ?? false;
};
}
Configure your alerting pipeline to trigger when the MISMATCH event rate exceeds 2% of total page loads over any 5-minute window. Correlate sessionSeed values across events to identify whether mismatches cluster around specific users, regions, or deploy SHA hashes.
Graceful Degradation #
When full recovery is impossible β corrupt IndexedDB, incompatible schema version, or a user on a browser without BroadcastChannel β the fallback path must still preserve core functionality. Render a skeleton UI immediately using aria-busy="true", hydrate complex state-dependent subtrees asynchronously via Suspense or framework defer, and surface a non-blocking recovery prompt via aria-live="polite" that lets the user explicitly accept or discard recovered state.
For Draft Auto-Save & Recovery Workflows, the merge-or-discard prompt is especially important: form drafts recovered from storage may be minutes old, and silently applying them without user confirmation is worse than losing them. Prefer an optimistic rollback β restore the last validated snapshot and present an inline diff β over a hard reset to clean state, which should be reserved for schema-incompatible versions only.
Preventing cumulative layout shift (CLS) during recovery is a Fallback UI Rendering Patterns concern: size skeleton placeholders to match the expected recovered content dimensions using CSS aspect-ratio or explicit min-height derived from server-rendered values stored in the snapshot.
FAQs #
How do I prevent hydration mismatches from dynamic timestamps or random IDs?
Use deterministic server-side generation with hash-based IDs derived from a stable seed (user ID concatenated with the current route). Pass the seed as a serialised prop so CSR and SSR always derive the same value. Defer any Date.now() or crypto.randomUUID() calls to post-hydration lifecycle hooks β never call them during render.
What is the recommended fallback when IndexedDB is corrupted during state restoration?
Implement the four-tier fallback: IndexedDB first, then sessionStorage, then localStorage, and finally a clean slate with a user-facing recovery prompt. Validate the schema version field at each tier before applying state; if versions differ, discard the stored snapshot and prompt the user rather than applying a potentially incompatible state shape.
Does intercepting window.onerror for hydration errors affect performance?
Not if you defer work correctly. The interceptor itself should do nothing except push to an in-memory queue. Batch transmission happens inside requestIdleCallback or sendBeacon, both of which execute off the critical rendering path. Sample high-frequency events at 10β20% in production to avoid beacon quota exhaustion.
Can I safely suppress hydration warnings in production?
No. suppressHydrationWarning masks divergence that will surface as broken accessibility trees, corrupted form state, and silent data loss. Use it only for known, non-interactive cosmetic differences β for example, locale-formatted numbers in read-only text nodes where the display difference carries no semantic weight.
Related #
- Session State Persistence & Hydration Fallbacks β parent section covering all persistence and recovery patterns
- LocalStorage & IndexedDB Sync Strategies β storage tier selection, quota management, and schema versioning
- Draft Auto-Save & Recovery Workflows β preserving user-authored content across crashes and tab closures
- Fallback UI Rendering Patterns β CLS-safe skeleton rendering during error and recovery states
- State Reset & Cleanup Protocols β clearing stale state after recovery without leaking listeners or timers