Suspense for Data — vs Suspense for Code, Error Boundaries, Streaming SSR
TL;DR
<Suspense> lets a component declaratively wait for an async dependency without manual loading state. Originally it shipped for code splitting (React.lazy); React 18+ extended it to data fetching when paired with the use(promise) hook or framework integrations (RSC, TanStack Query suspense, Next.js streaming). The pattern: child component “suspends” by throwing a promise → React renders the nearest <Suspense> fallback → when the promise resolves, React re-renders the child. Error boundaries catch the throwing variant.
Interview Q&A
Q: What does <Suspense> actually do?
A: When a descendant component throws a Promise during render, the nearest <Suspense> ancestor:
- Catches the throw.
- Renders its
fallback. - Waits for the promise to resolve.
- Re-renders the descendant.
<Suspense fallback={<Spinner />}>
<UserProfile id={userId} />
</Suspense>
If UserProfile (or anything inside it) suspends, the Spinner shows. When data is ready, UserProfile renders. The component doesn’t need a loading state — React provides it.
Q: Suspense for code vs data — what’s the difference?
A: Same primitive, different things being awaited:
| Suspense for code | Suspense for data | |
|---|---|---|
| What suspends | React.lazy(() => import(...)) |
use(promise), useSuspenseQuery, RSC await |
| Resolves when | Chunk loads | Data fetches |
| First arrived | React 16 (React.lazy) |
React 18 stable (with framework support) |
Code-splitting Suspense has been around forever; data Suspense was the headline of React 18 but only really got production-ready with React 19’s use() and frameworks adopting it.
Q: Show me use(promise) with Suspense.
A:
import { use, Suspense } from "react";
function UserName({ promise }: { promise: Promise<User> }) {
const user = use(promise);
return <h1>{user.name}</h1>;
}
function App({ userId }: { userId: string }) {
const promise = useMemo(() => fetchUser(userId), [userId]);
return (
<Suspense fallback={<Skeleton />}>
<UserName promise={promise} />
</Suspense>
);
}
use(promise) throws the promise on first call → Suspense catches → fallback renders → promise resolves → re-render → use(promise) returns the value.
Critical: the same Promise reference must be passed across renders. A new fetchUser(userId) each render = infinite suspend-resume loop. useMemo (or external cache) is mandatory.
Q: How do TanStack Query and Suspense interact?
A: TanStack Query has useSuspenseQuery:
import { useSuspenseQuery } from "@tanstack/react-query";
function UserName({ id }: { id: string }) {
const { data: user } = useSuspenseQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
return <h1>{user.name}</h1>;
}
<Suspense fallback={<Skeleton />}>
<UserName id={id} />
</Suspense>
useSuspenseQuery suspends the component while the query is fetching. data is always defined (no need for if (!data) return null). Pair with an error boundary for errors.
This pattern moves loading/error state out of the component into the parent boundary — cleaner code at the cost of needing boundaries upstream.
Q: How do you handle errors in suspended components?
A: Error boundaries. A suspended component that throws (non-promise error) propagates to the nearest error boundary.
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<Skeleton />}>
<UserProfile id={id} />
</Suspense>
</ErrorBoundary>
React doesn’t ship a built-in error boundary component; use react-error-boundary library or roll your own as a class component:
class ErrorBoundary extends React.Component<{ fallback: ReactNode; children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) { return { error }; }
componentDidCatch(error: Error, info: React.ErrorInfo) { reportError(error, info); }
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}
Reset on retry: pass key based on what changed (e.g., the userId) so the boundary re-mounts and clears its error state.
Q: Streaming SSR — how does Suspense make it work?
A: With React 18+’s streaming renderer (renderToPipeableStream / renderToReadableStream), the server can flush HTML for the shell before suspended boundaries resolve. As each suspended subtree finishes on the server, its HTML streams down with an inline <script> that injects it into the right place.
// Server pseudocode
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* slow data fetch */}
</Suspense>
User experience:
- Header skeleton + page chrome arrive → first paint.
- Header data resolves →
<Header />HTML streams → injected. - Feed data resolves →
<Feed />HTML streams → injected.
Each boundary is independent. The slow Feed doesn’t block the fast Header. This is how modern SSR feels instant even with backend data fetches.
Next.js App Router does this for free. Plain React with renderToPipeableStream works but you’re wiring everything yourself.
Q: Suspense + transitions — the “no flash” pattern.
A: When a Suspense boundary is inside a transition, React keeps the previous content visible while the new content loads — no flash to fallback:
const [page, setPage] = useState("home");
const [isPending, startTransition] = useTransition();
function navigate(p: string) {
startTransition(() => setPage(p));
}
<Suspense fallback={<Spinner />}>
<Page name={page} /> {/* suspends on data fetch */}
</Suspense>
Without transition: clicking a nav link flashes Spinner → new page. With transition: previous page stays visible (slightly dimmed via isPending), new page replaces it when ready. Much better UX for client-side nav.
Q: When not to use Suspense for data?
A:
- You don’t control the parent boundary. Suspense requires a wrapper; if you can’t add one, can’t use.
- You want loading state inline with the component.
useQuerywithisLoadingis more flexible if the loading UI is complex (skeleton matched to layout). - You can’t ensure stable promise references.
use()requires the same Promise across renders; without a cache layer, this is hard. - You’re in a non-RSC, non-streaming codebase. Suspense for data without
<Suspense>boundaries arranged thoughtfully and without streaming SSR loses most of its benefit.
For most existing client-only apps, useQuery’s loading/error state is more practical than ripping in Suspense everywhere.
Q: How does Suspense interact with concurrent rendering?
A: Tightly. Suspense + transitions = the “smooth interruption” model: a transition’s render can include suspended boundaries that don’t block urgent updates. If the user changes the input again before the data resolves, React discards the in-flight transition and starts a new one.
useDeferredValue + Suspense: render the new value’s fallback only if it takes long enough; otherwise smoothly swap. The combinations are powerful for typeahead, filter, route transitions.
Gotchas / edge cases
- Same Promise across renders —
use(fetchX())in render creates a new Promise each render → infinite suspend. Always cache viauseMemo, parent prop, or framework-managed cache. - Suspense doesn’t catch normal errors — they need an
ErrorBoundary. Suspense only catches thrown promises. - Fallback flash on fast resolve — pair with
useTransitionor a min-display library. - Nested Suspense boundaries — the innermost catches; outer boundaries don’t see the suspension. Architect deliberately.
- Streaming SSR + client-only code — components using
windowneeduseEffectguards; otherwise they fail during server render. Suspenseoutside of streaming SSR still works on the client; the fallback just shows during the initial CSR mount.- Error boundary doesn’t reset by itself — need a
keychange or a manual reset method.
What a senior is expected to say
- “Suspense is a declarative async-await for the component tree. The component throws a promise; the boundary renders the fallback; resolution re-renders the component.”
- “Code-splitting Suspense (React 16) is the old well-supported case; data-fetching Suspense (React 18+) is now production with
use(),useSuspenseQuery, and framework integrations.” - “Promise identity is the trap —
use(fetchX())each render is infinite suspend. Cache the promise (useMemo, parent prop, library).” - “Error boundaries catch thrown errors — Suspense catches thrown promises. They compose: wrap a Suspense in an ErrorBoundary.”
- “Streaming SSR uses Suspense boundaries as flush points — shell streams first, each boundary streams its HTML when ready. The ‘instant’ feel of modern SSR (Next App Router) is this.”
- “Suspense inside a transition keeps previous content visible while loading — no flash to fallback. The big UX win for client-side nav.”
Cross-references
- React 19
use()hook: react_19.md - Concurrent rendering (the priority companion): concurrent_rendering.md
- Server Components (the most common producer of suspense): server_components.md
- Hydration (where streaming SSR + Suspense meet): hydration.md
- TanStack Query suspense mode: ../11_apis_data_fetching/02_tanstack_query.md
Further reading
- React docs —
<Suspense>: https://react.dev/reference/react/Suspense - React 18 working group — Suspense for data: https://github.com/reactwg/react-18/discussions/47
react-error-boundarylibrary: https://github.com/bvaughn/react-error-boundary- TanStack Query — Suspense Mode: https://tanstack.com/query/latest/docs/framework/react/guides/suspense