Ever wonder why your perfectly server-rendered content suddenly flashes to a loading spinner when users click something? You're not alone. React's hydration phase has some counterintuitive rules that can catch even experienced developers off guard.
I spent way too much time debugging these behaviors in production, so I built this test suite to document exactly when and why Suspense fallbacks trigger during hydration. Some of the patterns might surprise you.
Warning
Error: A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.
When React hydrates server-rendered content, it needs to make components interactive while keeping the UI consistent. React 19 introduced improvements to selective hydration and concurrent rendering, but there are some tricky parts worth understanding.
Caution
State updates during hydration always break Suspense → your server-rendered content will flash to loading spinners. You can prevent this by wrapping these state changes with startTransition
Problem: Server-rendered content flashes to loading spinner when users interact
Solutions:
-
Wrap state updates in
startTransition← fixes most casesimport { startTransition } from "react"; const handleClick = () => startTransition(() => setCount((c) => c + 1));
-
For async operations, double-wrap
import { startTransition } from "react"; // ← does NOT work with useTransition const handleClick = () => startTransition(async () => { await api.call(); startTransition(() => setCount((c) => c + 1)); // ← key part });
-
External stores always trigger fallbacks - no real workaround
const value = useSyncExternalStore(subscribe, getSnapshot); // Any mutation will trigger fallback - consider alternatives
Update: react-compiler seems completely to prevent ALL Suspense fallbacks during hydration
Need the full details? Just read on
flowchart TD
A[User Interaction] --> B{React Compiler Enabled?}
B -->|Yes| RC[🎉 React Compiler]
RC --> RCE[✅ No Fallbacks Ever]
B -->|No| C{App Hydrated?}
C -->|No - During Hydration| D{State Update?}
C -->|Yes - Post Hydration| E{State Update?}
D -->|No Change| F[✅ No Fallback]
D -->|Same Value| G{React Optimization}
D -->|New Value| H{External Store?}
G -->|useState/useReducer| F
G -->|External Store| I[💣 Suspense Fallback]
H -->|Yes - useSyncExternalStore| I[💣 Always Triggers]
H -->|No| J{Wrapped in Transition?}
J -->|No| I[💣 Suspense Fallback]
J -->|Yes| K{Async Operation?}
K -->|No - Sync| L{Rendering isPending?}
K -->|Yes - Async| M{When is State Update?}
L -->|Yes| I[💣 Suspense Fallback]
L -->|No| F[✅ No Fallback]
M -->|Pre-await| F[✅ No Fallback]
M -->|Post-await| N{Correctly Wrapped?}
N -->|No - Lost Context| I[💣 Suspense Fallback]
N -->|Yes| O{Which startTransition?}
O -->|Direct Import| P{Rendering isPending?}
O -->|useTransition Hook| I[💣 Still Triggers]
P -->|Yes| I[💣 Suspense Fallback]
P -->|No| F[✅ No Fallback]
E -->|No Change| F
E -->|Same Value| F
E -->|New Value| Q{External Store?}
Q -->|Yes| R[💣 Always Triggers]
Q -->|No| S{Wrapped in Transition?}
S -->|Yes| T[⚡ Prevents Fallback]
S -->|No| U[💣 May Trigger Fallback]
| Update Type | Behavior | React Compiler | Notes | Source Code |
|---|---|---|---|---|
useState(new value) |
💣 Triggers fallback | ✅ Prevents fallbacks | Without transition wrapper causes Suspense fallback¹⁾ (React team guidance) | code |
useState(same value) |
✅ Never triggers | React's built-in optimization prevents fallback | code | |
useReducer(new value) |
💣 Triggers fallback | ✅ Prevents fallbacks | Without transition wrapper causes Suspense fallback¹⁾ (React team guidance) | code |
useReducer(same value) |
✅ Never triggers | React's built-in optimization prevents fallback | code | |
startTransition(sync - direct import) |
✅ Prevents fallbacks | Direct import of startTransition works effectively during hydration | code | |
useTransition(sync - hook) |
💣 Triggers fallback | ✅ Prevents fallbacks | useTransition hook triggers fallbacks during hydration¹⁾ | code |
startTransition(async - post-await) |
💣 Triggers fallback | ✅ Prevents fallbacks | React loses transition context after await¹⁾ (See React docs) | code |
startTransition(correctly wrapped async - direct import) |
✅ Prevents fallbacks | Nested startTransition from direct import preserves context during hydration | code | |
startTransition(correctly wrapped async - useTransition) |
💣 Still triggers | ✅ Prevents fallbacks | Nested startTransition from useTransition hook triggers fallbacks during hydration¹⁾ | code |
startTransition + isPending render |
💣 Still triggers | ✅ Prevents fallbacks | Rendering isPending state breaks transition optimization¹⁾ | code |
useDeferredValue |
💣 Triggers fallback | ✅ Prevents fallbacks | Deferred values trigger fallbacks during hydration¹⁾ | code |
useDeferredValue + React.memo |
✅ Prevents fallbacks | Memoized components prevent re-renders during deferred updates (See React docs pitfall) | code | |
useSyncExternalStore |
💣 Always triggers | ✅ Prevents fallbacks | Cannot benefit from transitions at any phase¹⁾ (See docs) | code |
¹⁾ React Compiler's automatic memoization prevents fallbacks
Even if the server includes the full HTML for a lazy component, certain patterns during hydration will still trigger Suspense fallbacks and remove the existing content

See for yourself directly in your browser: useTransition vs startTransition
Regular State Updates (SuspenseFallbackOnStateChange.tsx)
const [count, setCount] = useState(0);
const handleClick = () => setCount((prev) => prev + 1); // 💣 Triggers fallbackReducer Updates (SuspenseFallbackOnReducerChange.tsx)
const [state, dispatch] = useReducer(reducer, initialState);
const handleClick = () => dispatch({ type: "increment" }); // 💣 Triggers fallbackExternal Store Changes (SuspenseFallbackOnExternalStore.tsx)
const value = useSyncExternalStore(subscribe, getSnapshot);
// Any external store mutation 💣 Always triggers fallbackuseTransition Hook Updates (SuspenseFallbackOnUseTransitionUpdate.tsx)
const [, startTransition] = useTransition();
const handleClick = () => {
startTransition(() => {
setCount((prev) => prev + 1); // 💣 Still triggers fallback during hydration with useTransition hook
});
};Correctly Wrapped Async useTransition (SuspenseFallbackOnCorrectlyWrappedAsyncUseTransition.tsx)
const [, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
await someAsyncOperation();
startTransition(() => {
setCount((prev) => prev + 1); // 💣 Still triggers fallback during hydration with useTransition
});
});
};Async State Updates After await (SuspenseFallbackOnAsyncStateAfterAwait.tsx)
const handleClick = () => {
startTransition(async () => {
await someAsyncOperation();
setCount((prev) => prev + 1); // 💣 Loses transition context after await
});
};Deferred Value Updates (SuspenseFallbackOnDeferredValue.tsx)
const [count, setCount] = useState(0);
const deferredCount = useDeferredValue(count);
const handleClick = () => setCount((prev) => prev + 1); // 💣 Triggers fallback even when rendering deferred value
return (
<>
<p onClick={handleClick}>Counter: {deferredCount}</p>
<ChildWithSuspense />
</>
);React's built-in optimizations prevent fallbacks when updates don't actually change state, and transitions effectively prevent fallbacks during hydration.
Same-Value State Updates (NoSuspenseFallbackOnSameStateValue.tsx)
const handleClick = () => setCount((prev) => prev); // ✅ React optimizes this awaySame-Value Reducer Updates (NoSuspenseFallbackOnSameReducerValue.tsx)
const reducer = (state, action) => {
case 'return_same': return state; // ✅ No actual change = no fallback
}Transition-Wrapped Updates (NoSuspenseFallbackOnTransitionUpdate.tsx)
const handleClick = () => {
startTransition(() => {
setCount((prev) => prev + 1); // ✅ Prevents fallback during hydration
});
};startTransition Direct Import Updates (NoSuspenseFallbackOnTransitionUpdate.tsx)
import { startTransition } from "react";
const handleClick = () => {
startTransition(() => {
setCount((prev) => prev + 1); // ✅ Prevents fallback during hydration
});
};Correctly Wrapped Async startTransition (Direct Import) (NoSuspenseFallbackOnCorrectlyWrappedAsyncTransition.tsx)
import { startTransition } from "react";
const handleClick = () => {
startTransition(async () => {
await someAsyncOperation();
startTransition(() => {
setCount((prev) => prev + 1); // ✅ Prevents fallback during hydration
});
});
};Deferred Value Updates with Memoization (NoSuspenseFallbackOnDeferredValueWithMemo.tsx)
const ChildWithSuspenseWithMemo = memo(ChildWithSuspense);
// ...
const [count, setCount] = useState(0);
const deferredCount = useDeferredValue(count);
const handleClick = () => setCount((prev) => prev + 1);
// ✅ Prevents fallback during hydration (☝️ requires React.memo)
return (
<>
<p onClick={handleClick}>Counter: {deferredCount}</p>
<ChildWithSuspenseWithMemo />
</>
);While startTransition effectively prevents Suspense fallbacks during hydration, there are important exceptions that can catch developers off guard.
Due to a JavaScript limitation, React loses the transition context after await operations. As documented in the React docs, state updates after await are not automatically treated as transitions and will trigger Suspense fallbacks during hydration.
The React docs recommend wrapping post-await state updates in another startTransition, but the effectiveness depends on which startTransition you use:
// ❌ Loses transition context after await
startTransition(async () => {
await someAsyncFunction();
setCount(1); // Triggers fallback during hydration
});
// ✅ Correctly wrapped with direct import - prevents fallback during hydration
import { startTransition } from "react";
startTransition(async () => {
await someAsyncFunction();
startTransition(() => {
setCount(1); // Prevents fallback during hydration
});
});
// 💣 Correctly wrapped with useTransition - still triggers fallback during hydration
const [, startTransition] = useTransition();
startTransition(async () => {
await someAsyncFunction();
startTransition(() => {
setCount(1); // Still triggers fallback during hydration
});
});The nested startTransition pattern works during hydration only when using the direct import from React, not when using the startTransition from the useTransition() hook.
Even when state changes are properly wrapped in transitions, rendering the isPending state can still trigger fallbacks during hydration.
Rendering Transition Pending State (SuspenseFallbackOnIsPendingRender.tsx)
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(() => {
setCount((prev) => prev + 1);
});
};
return (
<button>
Counter: {count} {isPending && "(pending)"}{" "}
{/* 💣 This triggers fallback */}
</button>
);The state change itself is properly wrapped in a transition, but rendering the isPending state causes additional renders that aren't transition-wrapped. This can trigger Suspense fallbacks during hydration, making it a subtle but important gotcha for developers who want to display pending states in their UI.
External stores using useSyncExternalStore have a unique constraint: they cannot benefit from transition optimizations. As documented in the React docs, external store mutations cannot be marked as non-blocking transitions, making them always trigger Suspense fallbacks.
React loses the transition context after await operations due to a JavaScript limitation. This means state updates after await behave like synchronous updates and will trigger Suspense fallbacks during hydration, even when the initial call was wrapped in startTransition.
This limitation will be resolved once AsyncContext becomes available, but for now the workaround is to wrap post-await state updates in another startTransition.
startTransition is effective during both hydration and post-hydration phases. During the hydration phase:
- Transitions successfully prevent Suspense fallbacks for synchronous state updates
- React can safely defer updates while maintaining consistency
- The exceptions are async state updates after
awaitand renderingisPendingstate, which break the optimization
🎉 React Compiler automatic memoization optimizations completely solves hydration Suspense issues
Even without any startTransition wrapping React Compiler's automatic memoization prevents fallbacks in all cases!
Here you can see that the same example with react-compiler does not trigger the fallback:

See for yourself directly in your browser: default vs react-compiler
- Good: Fast components don't wait for slow ones
- Good: React optimizes away unnecessary updates
- Good: Transitions prevent loading flashes during hydration
- Challenge: External stores can't benefit from transition optimizations
- Gotcha: Rendering
isPendingstate can still trigger fallbacks
- Selective Hydration: Components hydrate independently
- Priority-Based: User interactions can reprioritize hydration
- Optimization: Same-value updates are completely skipped
- Consistent Behavior: Transitions work the same way throughout the app lifecycle
All behaviors are thoroughly tested using E2E Playwright tests with:
- Real SSR: Using
renderToPipeableStreamwith Rspack build process - Client Hydration: Actual hydration with
hydrateRoot - Lazy Components: Artificial delays to simulate real-world loading
- Browser Testing: Real browser interactions and timing
// 1. Build components with SSR
npm run build
// 2. Navigate to pre-rendered HTML
await page.goto(`file://${process.cwd()}/dist/${componentName}/index.html`);
// 3. Wait for hydration
await page.waitForLoadState('networkidle');
// 4. Trigger state change
await page.locator('button').first().click();
// 5. Verify Suspense behavior
const hasNoFallback = await page.locator('text=Suspense Boundary Content').isVisible();
expect(hasNoFallback).toBe(true);- Build the project:
npm run build - Open any example: Navigate to
dist/[ExampleName]/index.htmlin your browser - Interact with the UI: Click buttons to trigger state changes and observe Suspense behavior
- Observe the fallback: Watch for "Suspense Boundary Fallback" vs "Suspense Boundary Content"
- Transitions effectively prevent Suspense fallbacks during hydration -
startTransitionworks as intended for synchronous updates (but React Compiler makes this manual approach unnecessary) - The async context limitation is solved by React Compiler - React loses transition context after
await, but automatic memoization prevents the fallbacks regardless - External stores are fixed by React Compiler - while they cannot benefit from transitions, automatic memoization prevents their fallbacks
- React optimizes same-value updates - built-in optimization prevents unnecessary fallbacks
- React Compiler fixes hydration issues - no more manual
startTransitionwrapping needed to prevent server-rendered content discarding
Don't trust my word? Good - you shouldn't. Every behavior I've documented here comes from actual tests you can run yourself.
Just clone this repo and run npm test to see all these hydration quirks in action. The tests use real SSR with renderToPipeableStream and actual hydration with hydrateRoot in real browsers - no mocks or shortcuts.
I wrote these tests because I was debugging some gnarly hydration issues in production and found the relevant documentation scattered across different pages and sections. Turns out React's hydration behavior has some pretty specific rules that aren't immediately obvious from the main API docs.
Big thanks to @rickhanlonii and @gaearon for catching an important mistake in my original understanding