A slow database query should not make the whole page feel slow. Without Suspense, one slow server component holds back the entire response, and the user stares at a blank tab. With Suspense, the fast parts of the page arrive first and the slow part fills in when it is ready.
This comes up in Next.js interviews all the time, and it is also the thing that most improves perceived speed on real pages. Here is how it works and where it goes wrong.
What Suspense does
<Suspense> marks a boundary. If something inside it is not ready yet, React shows the fallback for that part and keeps rendering everything outside the boundary normally.
“Not ready” means a child is waiting on data, for example an async server component or a lazy-loaded component. When it finishes, React swaps the fallback for the real content.
import { Suspense } from "react";
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading revenue...</p>}>
<Revenue />
</Suspense>
</main>
);
}The <h1> does not wait for Revenue. That is the whole idea.
Streaming slow server components
In the App Router, server components can be async. React renders them on the server and sends HTML to the browser in chunks, instead of waiting for the whole tree. This is streaming.
The shell of the page and any fallbacks go out first. When a suspended component finishes, the server sends its HTML along with a small instruction that places it where the fallback was. No client-side fetch, no extra round trip.
async function Revenue() {
const res = await fetch("https://api.example.com/revenue"); // slow
const data: { total: number } = await res.json();
return <p>Revenue: {data.total}</p>;
}There is nothing special inside Revenue. It is a plain async server component. The streaming comes from the <Suspense> boundary above it.
- Shelllayout, heading, fallbacks
- Fallback visibleuser can already read and scroll
- Slow data resolvesserver finishes Revenue
- Chunk streamedHTML replaces the fallback
Each step is a chunk of the same HTTP response.
loading.tsx is a Suspense boundary
In Next.js, a loading.tsx file next to a page.tsx is shorthand. Next.js wraps the page (and its children) in a <Suspense> whose fallback is the default export of loading.tsx.
export default function Loading() {
return <p>Loading dashboard...</p>;
}It is conceptually this:
<Layout>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</Layout>Two things follow from that. The layout stays visible and interactive while the page loads, because it sits outside the boundary. And loading.tsx is coarse: it covers the whole page segment. For finer control, add your own <Suspense> boundaries inside the page, around just the slow components.
Avoiding waterfalls
A waterfall is when requests run one after another that could have run together. Suspense does not fix this on its own. It only decides what shows while you wait.
export default async function Page() {
const user = await getUser();
const posts = await getPosts();
const stats = await getStats();
/* total time = user + posts + stats */
}If the calls do not depend on each other, start them together.
export default async function Page() {
const [user, posts, stats] = await Promise.all([
getUser(),
getPosts(),
getStats(),
]);
/* total time = the slowest one */
}The other fix is to split the page into components that each fetch their own data, with a boundary around each. Sibling server components render in parallel, so their fetches start at the same time.
export default function Page() {
return (
<>
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
</>
);
}Now each section appears as soon as its own data is ready, and a slow Stats never delays Posts.
The real waterfall to watch for is parent and child. If Posts awaits its data and then renders a child that awaits more data, the child’s fetch cannot start until the parent finished. Fetch what the child needs higher up, or restructure so the requests do not depend on each other.
Error boundaries alongside
Suspense handles waiting. It does not handle failing. If a component inside a boundary throws, you need an error boundary to catch it, otherwise the error bubbles up and takes out more of the page than you want.
In Next.js, an error.tsx file creates an error boundary for its route segment. It must be a client component.
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<p>Could not load the dashboard.</p>
<button onClick={reset}>Try again</button>
</div>
);
}Like loading.tsx, it wraps the whole segment. For one risky widget, use a smaller error boundary around it, next to its Suspense boundary. Plain React has no built-in error boundary component for function components, so people write a class or use a small library such as react-error-boundary.
The usual nesting is error boundary outside, Suspense inside: the error boundary catches failures, and the Suspense boundary handles the loading state of the same content.
Common mistakes
One boundary around everything. If the whole page is inside a single <Suspense>, you get the all-or-nothing behavior back. Put boundaries around the slow parts only.
Awaiting before the boundary. The boundary only helps for work that happens inside it. If the page component itself awaits the slow call, the page is blocked before it can return any JSX.
export default async function Page() {
const revenue = await getRevenue();
return (
<Suspense fallback={<p>Loading...</p>}>
<Chart data={revenue} />
</Suspense>
);
}export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Chart />
</Suspense>
);
}Fallbacks that shift the layout. A fallback of a single line that gets replaced by a tall chart pushes everything below it down. Give skeletons roughly the same size as the real content.
Awaiting in a layout. A slow await in layout.tsx blocks every page under it, and loading.tsx in the same folder does not cover the layout itself. Keep layouts fast and push data fetching down into pages and components.
Forgetting the error side. Streaming makes more things happen late, so more things can fail late. Pair boundaries with error.tsx or a local error boundary.
Expecting it to help with client fetching. A useEffect fetch does not trigger Suspense. Suspense works with server components, lazy, and libraries or frameworks that integrate with it.
Interview answer in 30 seconds
“Suspense lets part of the tree wait without blocking the rest. React shows the fallback for that boundary and renders everything else. In the Next.js App Router, async server components are streamed: the shell goes out first, and the slow parts arrive as later chunks of the same response. loading.tsx is an automatic Suspense boundary around the page segment, and I add my own boundaries for finer control. Suspense does not remove waterfalls, so I use Promise.all or separate components that fetch in parallel. And I pair it with error.tsx or an error boundary, because Suspense handles waiting, not failure.”
Key takeaways
- A Suspense boundary shows a fallback for slow children and lets the rest of the page render.
- Async server components stream: the shell first, slow parts as later chunks.
loading.tsxis a Suspense boundary around the route segment. Add your own for finer control.- Suspense does not fix waterfalls. Fetch in parallel with
Promise.allor sibling components. - The boundary only covers work inside it. Do not await the slow call above it.
- Pair Suspense with
error.tsxor an error boundary, since it does not catch errors. - Give fallbacks the same size as the final content to avoid layout shift.
