Skip to content
Chapter 31Lesson 1

Suspense, the fallback contract

React's Suspense component, the declarative boundary that replaces the loading flag in the App Router, and the senior skill of deciding where to draw it so each piece of UI reveals as its own unit.

A dashboard page reads its data on the server. The component is async, and somewhere in its body sits a line like this:

app/dashboard/page.tsx
const invoices = await listInvoices(); // data layer: Unit 5

That query takes around 800 milliseconds: not catastrophic, not instant, but long enough to notice. The question this lesson answers is about the user, not the code. For those 800 milliseconds, what fills the screen?

If you have written React before this course, you already have an answer, and it is the wrong one for the App Router. The answer you carry is a loading flag. You reach for a useState(false) called isLoading, flip it to true, kick off a fetch inside a useEffect, and flip it back to false when the data lands. Somewhere in your JSX you write if (isLoading) return <Skeleton />. It works, but it costs you more than it looks.

That flag lives in a Client Component, because useState and useEffect only run on the client. You have to thread it down to wherever the spinner actually shows. And it does not scale. The moment your page loads two independent things, say a profile and an activity feed, you need two flags, or one flag that conflates them. Every new piece of data becomes another small state machine you own and can get wrong, so loading state ends up scattered across the tree, manually wired, and easy to forget.

React’s answer is a single declarative primitive: the <Suspense> component, used in a way you have probably never seen. It replaces the entire flag-and-effect dance. The syntax is the easy part. The skill that takes longer to build is not how to write it but where to draw it, and that placement decision is what the rest of this lesson works toward.

Start with what Suspense actually is, because almost everyone gets the category wrong on first contact. It is not a hook. It is not a config flag you set in next.config. It is not a data-fetching library. It is a built-in React component, used in JSX like any other, with exactly one job.

Here is that job, stated as a contract: while any descendant is still loading, render the fallback; when every descendant has resolved, render the children. That is the whole of it. The component sits in your tree and watches the subtree beneath it for a single signal.

Three properties of that contract clear up the three misconceptions people arrive with.

  • Suspense is a component. You place it in JSX and give it a fallback prop and some children. There is no useSuspense() and no setup call.
  • It reacts to a signal that a child throws. When a component beneath the boundary is not ready, it emits a suspend signal , and <Suspense> catches it the way a try/catch catches a throw. This means the boundary does not need to know what its children are loading or how. A child reading from a database, a child reading a streamed promise, and a lazily-loaded component all look the same to the boundary. It only knows that something below it said “not yet.”
  • The rule is all-or-nothing per boundary. The fallback shows while any single child is still suspending, and the children show only once every one of them has resolved. One boundary has exactly two visual states, and it stays in the fallback state until the last holdout settles.

That last property is the one that drives the placement decision later, so keep it in mind: a boundary is a unit, and everything inside it appears together or not at all.

<Suspense> boundary fallback
<Suspense fallback={<InvoiceSkeleton />}>

showing — <InvoiceSkeleton />

<InvoiceList /> still loading…

A child below the boundary said “not yet,” so the fallback is on screen in its place.

Suspended → the boundary renders its fallback. InvoiceList is still awaiting its data, so the real list is not on screen — the InvoiceSkeleton is.
<Suspense> boundary resolved
<Suspense fallback={<InvoiceSkeleton />}>

showing — <InvoiceList />

  • INV-1042 $1,200
  • INV-1043 $840
  • INV-1044 $2,310
<InvoiceList /> resolved

The child finished — that, not any code you wrote, is what flipped the boundary to its children.

Resolved → the boundary renders its children. InvoiceList finished awaiting, so React swapped the skeleton for the real list. Nothing you wrote changed between these two frames — the child settling flipped the state.

Here is the literal shape, so you see it before any nuance: a fallback, and the thing it is guarding.

app/dashboard/page.tsx
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceList />
</Suspense>

InvoiceList here is an async Server Component, and its body does that 800ms await. Until the await settles, InvoiceList has produced no output, so it suspends, and the nearest <Suspense> above it shows <InvoiceSkeleton /> in its place. When the data arrives, React swaps the skeleton out for the list. You wrote no flag and flipped no boolean; the boundary did it.

The piece you supply is the fallback , the UI shown during the wait. The next section shows the two kinds of children that can sit inside the boundary and trigger it.

Before going further, it is worth seeing the old way and the new way side by side, because the old way is exactly what Suspense replaces. The two shapes differ in kind, not just in line count.

invoice-list.tsx
'use client';
export function InvoiceList() {
const [invoices, setInvoices] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch('/api/invoices')
.then((res) => res.json())
.then((data) => {
setInvoices(data);
setIsLoading(false);
});
}, []);
if (isLoading) return <InvoiceSkeleton />;
return <ul>{invoices.map(/* … */)}</ul>;
}

Imperative: you own the loading state machine and have to thread it everywhere. The loading concern is spread across three places: the flag, the effect that flips it, and the branch that reads it. Every independent piece of data needs its own flag, and forgetting to flip one is a whole class of bug.

Look at what moved. In the first variant, the loading state is the component’s problem: declared with useState, advanced inside an effect, read in a branch, and repeated for every new query. In the second, the component does not mention loading at all. It awaits its data and returns its UI, while the boundary handles the wait. There is no flag to forget to flip, because there is no flag.

So a child throws a suspend signal and the boundary catches it. But which children actually do that? In 2026 App Router code there are two shapes you will author, and recognizing them is the practical core of using Suspense. Both throw the same signal, which is why a single <Suspense> can sit above either one without caring which it gets.

The first is the one you have already seen.

async function InvoiceList() {
const invoices = await listInvoices(); // data layer: Unit 5
return (
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.number}{invoice.total}
</li>
))}
</ul>
);
}

The component is async. That alone tells React the component may not produce its output synchronously.

async function InvoiceList() {
const invoices = await listInvoices(); // data layer: Unit 5
return (
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.number}{invoice.total}
</li>
))}
</ul>
);
}

It awaits its data in the body. Until this promise settles, the function has not returned any JSX, so there is nothing to render. The component suspends, and the nearest <Suspense> ancestor shows its fallback in the meantime.

async function InvoiceList() {
const invoices = await listInvoices(); // data layer: Unit 5
return (
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.number}{invoice.total}
</li>
))}
</ul>
);
}

Once the data is in, the component returns its UI exactly as a synchronous component would. React swaps the fallback for this output.

1 / 1

The second shape is one you met when you learned that a Server Component can hand a promise to a Client Component. Now you get to see its loading behavior. Here is the setup: a Server Component starts a query but does not await it, passing the unsettled promise down as a prop. A Client Component receives that promise and reads it with use().

// app/dashboard/page.tsx — Server Component
function DashboardPage() {
const invoicesPromise = listInvoices(); // data layer: Unit 5
return (
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceTable invoicesPromise={invoicesPromise} />
</Suspense>
);
}
// invoice-table.tsx — Client Component
'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) {
const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;
}

On the server, start the query but don’t await it. You now hold a promise, not data.

// app/dashboard/page.tsx — Server Component
function DashboardPage() {
const invoicesPromise = listInvoices(); // data layer: Unit 5
return (
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceTable invoicesPromise={invoicesPromise} />
</Suspense>
);
}
// invoice-table.tsx — Client Component
'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) {
const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;
}

Pass the unsettled promise down as a prop. The server doesn’t block here; it hands the pending work to the client.

// app/dashboard/page.tsx — Server Component
function DashboardPage() {
const invoicesPromise = listInvoices(); // data layer: Unit 5
return (
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceTable invoicesPromise={invoicesPromise} />
</Suspense>
);
}
// invoice-table.tsx — Client Component
'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) {
const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;
}

In the Client Component, use() reads the promise. While it’s pending, use() suspends the component, raising the same signal the async Server Component threw.

// app/dashboard/page.tsx — Server Component
function DashboardPage() {
const invoicesPromise = listInvoices(); // data layer: Unit 5
return (
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceTable invoicesPromise={invoicesPromise} />
</Suspense>
);
}
// invoice-table.tsx — Client Component
'use client';
type InvoiceTableProps = { invoicesPromise: Promise<Invoice[]> };
export function InvoiceTable({ invoicesPromise }: InvoiceTableProps) {
const invoices = use(invoicesPromise);
return <SortableTable rows={invoices} />;
}

The boundary sits above the Client Component and catches that signal. The fallback shows until the promise resolves, then the interactive table renders with its data.

1 / 1

Why would you ever reach for the second shape when the first is simpler? Because the table is interactive: it sorts, it filters, and so it needs to be a Client Component. This pattern lets the interactive shell be a Client Component while the data it displays is still streaming in from the server. The shell and the data have different homes, and use() is the seam between them: it suspends the Client Component on a pending promise and hands back the value the moment it resolves.

Those are the two shapes you will write. For completeness, two other things can also suspend: React.lazy(), used to code-split a component so its code loads on demand, and Suspense-aware hooks from some data libraries. Neither is the 2026 default for fetching data, so they get this mention and no more.

A good fallback mirrors the resolved layout

Section titled “A good fallback mirrors the resolved layout”

You write the fallback. Suspense does not generate one for you from the children, because it has no idea what the resolved content will look like. So the quality of the loading experience is entirely on you, and two rules separate a fallback that helps from one that hurts.

The first is a hard constraint, not a preference. The fallback is rendered synchronously and must not itself suspend. The reason follows from what the fallback is for: it is what you show while something is loading. If the fallback also has to load, by awaiting or reading a pending promise, then there is nothing to show during the wait and the boundary has no purpose. Keep fallbacks dumb and instant: static markup, no data reads.

The second rule is where experience shows. Prefer a skeleton that mirrors the final layout: the same heights, the same number of rows, the same rough shape as the content it stands in for. A spinner or a line of “Loading…” text is acceptable, and you will see it everywhere, but it is lower quality for a concrete reason. A spinner occupies a tiny box, so when the real content arrives much larger, everything below it lurches down the page. The content does not fade in; it shoves. A skeleton that already occupies the content’s eventual footprint swaps in place, and nothing moves.

Recent activity

loading — spinner fallback
Loading…
← rest of the page jumps

list needs this room — footer is shoved down to here

footer ends up here
The spinner is tiny; when the list arrives it grows into the dashed zone and shoves the footer down to the line below — a visible jump.

That jump is not a cosmetic nitpick. Layout shift on data load is a measurable UX defect: it reads as jank, it is jarring on a surface the user hits dozens of times a day, and it is exactly why teams invest in skeletons that match the resolved shape. Reserving the space the content will occupy is what removes the shift.

You now know what Suspense is, what suspends, and what a good fallback looks like. The decision that takes judgment, and the one this lesson has been building toward, is where the boundary goes.

One diagnostic question carries most of the skill: “What should the user see resolve as a single unit?” Not “where is the code” and not “which component is async.” Ask what the user perceives as one thing arriving, and put a boundary around that.

To see why it matters, take a page with two independent reads: a user-profile read that returns in 10 milliseconds, and an activity-feed read that takes 800. Watch what one boundary does to them.

app/dashboard/page.tsx
function DashboardPage() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserProfile />
<ActivityFeed />
</Suspense>
);
}

The user waits the full 800ms to see the 10ms profile too: the slow read holds the fast one back. Because a boundary is all-or-nothing, the profile cannot appear until the activity feed also resolves, so the fast widget is stuck behind the slow one.

The diagram makes the relationship between boundary count and reveal granularity literal.

1 boundary

1 unit
<Suspense>
Profile ~10ms
Activity feed ~800ms
1 fallback covers both
shared fallback

All-or-nothing: the fast profile is held back until the feed resolves — both appear together, at ~800ms.

2 boundaries

2 units
<Suspense>
Profile ~10ms
own fallback
<Suspense>
Activity feed ~800ms
own fallback

Two independent units: Profile paints at ~10ms, the feed fills in at ~800ms.

Boundary count is reveal granularity. One boundary reveals its contents as one unit; two boundaries reveal two units independently.

So here is the rule, stated cleanly: draw the boundary around the smallest piece of UI that loads as a single concept, such as a widget, a list, or a sidebar card. Two things that resolve independently belong in two boundaries, so each reveals the moment it is ready. Two things that only make sense together, like a chart and the legend that explains it, or a total and the rows it sums, belong in one boundary, because revealing half of a single idea is worse than revealing none of it.

One caveat, so you are not misled. Why two parallel boundaries each reveal separately, the mechanism that lets the server send the fast one first and the slow one later, is the subject of the next lesson, on streaming. Here you are learning only the placement decision, framed as a question about the user. The transport that makes that placement pay off comes next.

Try walking the decision yourself. The drill below poses the questions in the order an experienced engineer asks them, and that order matters as much as any single answer.

Where does the boundary go?

Nested boundaries compose into a reveal cascade

Section titled “Nested boundaries compose into a reveal cascade”

Boundaries are components, and components nest, so boundaries nest. This is more powerful than it sounds: nesting gives you a content-first reveal, shell then partial then full, without writing a single line of coordinating state.

The model is simple once you hold the all-or-nothing rule in mind. An outer boundary’s fallback covers everything beneath it until its directly-awaited content is ready. Once that outer content renders, an inner boundary takes over for its own slower subtree, showing the inner fallback while everything around it is already on screen. Each boundary minds only its own children.

Watch it unfold in the figure below.

<Suspense> · PageSkeleton fallback
<Suspense> · FeedSkeleton covered

Step 1 of 3 — the entire page is the outer skeleton.

Outer fallback covers everything. Its directly-awaited content (the header) isn't ready, so the whole page is the outer PageSkeleton — the inner boundary is hidden behind it.
<Suspense> · PageSkeleton resolved
Dashboard Invoices Settings
<Suspense> · FeedSkeleton fallback

showing — <FeedSkeleton />

Step 2 of 3 — shell + header real, the inner region is still its own skeleton.

Outer resolves. The header paints with real content, and the inner boundary takes over — showing its own FeedSkeleton while the slow ActivityFeed is still awaiting, with everything around it already on screen.
<Suspense> · PageSkeleton resolved
Dashboard Invoices Settings
<Suspense> · FeedSkeleton resolved

showing — <ActivityFeed />

  • Maya paid INV-1042 2m
  • Leo opened INV-1043 9m
  • Ada refunded INV-1041 14m

Step 3 of 3 — full content, produced by nesting alone — no orchestration code.

Inner resolves. The ActivityFeed finished its slow read, so React swaps the FeedSkeleton for the real feed. Full content on screen — three visual states, and you wrote zero coordinating state.

In code, that cascade is just one boundary inside another, with some synchronous content in between:

app/dashboard/page.tsx
<Suspense fallback={<PageSkeleton />}>
<DashboardHeader />
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</Suspense>

The outer fallback shows until DashboardHeader is ready. Once it is, the header paints and the inner boundary takes over, showing <FeedSkeleton /> while ActivityFeed finishes its slow read. That is three visual states with zero state variables.

The tool cuts both ways, so one caution: nesting too deeply produces a distracting cascade of skeletons popping in one after another, which feels busier and slower than it is. Nest at meaningful UX seams, such as the shell and then a slow region inside it, not at every component that happens to be async.

There is a trap here that surprises every developer the first time, and the fix is one prop. The setup is ordinary: a detail view that re-renders with new props. The user is looking at invoice inv_001, clicks invoice inv_002, and the component re-renders with a new invoiceId.

You would expect the fallback to return while the new invoice loads. It does not. React sees the same component type in the same position in the tree, concludes it is the same instance, reuses the already-resolved subtree, and skips the fallback entirely. So the user keeps seeing invoice inv_001’s data, stale and wrong, for the full duration of the new load, with no loading indication at all. It looks like nothing happened, and then the content silently changes underneath them.

The fix tells React the truth: this is a different thing. Put a key on the suspending subtree, one that changes with the input.

app/invoices/[id]/page.tsx
<Suspense fallback={<InvoiceSkeleton />}>
<InvoiceDetail invoiceId={invoiceId} />
</Suspense>

React reuses the resolved tree on a prop change: no fallback, stale content. When invoiceId changes, React keeps the old resolved subtree mounted and shows the previous invoice until the new data quietly arrives.

A changed key means a fresh mount, a fresh mount means a fresh suspend, and a fresh suspend means the fallback comes back. This is the clean way to say “show a loading state when the route parameter changes,” with no isLoading flag reintroduced and no effect, just an identity hint.

There is a deliberate opposite, named once so you know it exists. Sometimes you don’t want the fallback on a change, as with search-as-you-type or switching tabs, because flashing a skeleton on every keystroke is jarring. For that, you wrap the update in startTransition, which keeps the old content visible and gives you an isPending flag instead of swapping in the fallback. That is the inverse of the key move: key says “this is new, show the loading state,” while a transition says “keep the old, this is not urgent.” The transition hooks have their own React lesson; here, just know that the two pull in opposite directions on purpose.

Every misconception in this list is a real bug someone ships, so it is worth drawing the boundary of responsibility explicitly. Suspense has one job, and these are the jobs it does not have.

  • It does not catch errors. If a component below the boundary throws an error, an actual error rather than a suspend signal, that error sails straight past Suspense and crashes the tree. Catching it is the job of an Error Boundary, which the App Router exposes through an error.tsx file. We cover that, and the “this resource doesn’t exist” case, two lessons from now. Suspense catches a suspend signal, not an error; they are different signals.
  • It does not retry, and the fallback is not an error state. The fallback means “still loading,” full stop. It does not mean “something went wrong,” and Suspense will not re-attempt a failed load for you.
  • It does not deduplicate fetches. Two children that read the same data fire two requests. Making the second one cheap, through request memoization with cache(), is a separate mechanism covered when we reach caching.
  • It does not auto-skeleton. As you saw, you write the fallback. Suspense has no view into the children’s shape and cannot generate a placeholder from them.

The first one bites hardest: “I wrapped it in Suspense, why did the error still crash the page?” Now you know it is the wrong tool for that signal. The error story has its own lesson.

You have read the unit-of-UX decision; now make it. Below are two simulated-async widgets sharing a single boundary. Give the slow one its own.

This runs entirely in the browser, with no server and no database. Suspending is faked with a setTimeout-backed promise read by use(), standing in for the async Server Component or streamed promise you’d write in real App Router code. Read it as “this widget takes 50ms or 1500ms to be ready,” and don’t carry the setTimeout shape into production.

Right now App wraps both widgets in one shared <Suspense>. That single boundary is all-or-nothing, so its fallback covers everything until the 1500ms slow read finishes, holding the fast widget back. Split it: give the slow widget its own boundary so the fast one reveals on its own.

Both widgets share one Suspense boundary, so the fast widget is stuck waiting on the slow one. Give the slow widget its own <Suspense> with its own skeleton fallback (SlowSkeleton), and keep the fast widget behind its own boundary with FastSkeleton, so each reveals the moment it's ready.

Preview
    Reference solution

    Each widget gets its own <Suspense> with its own skeleton, so the two reads are independent units. The profile’s boundary resolves at about 50ms and paints while the feed’s boundary is still showing SlowSkeleton, and the feed fills in at about 1500ms. Only App changes; the widgets, skeletons, and promises are untouched.

    export function App() {
    return (
    <>
    <Suspense fallback={<FastSkeleton />}>
    <UserProfile />
    </Suspense>
    <Suspense fallback={<SlowSkeleton />}>
    <ActivityFeed />
    </Suspense>
    </>
    );
    }

    SharedSkeleton is now unused, because the single combined fallback is exactly the shape you removed.

    Check the model against the misconceptions it replaces.

    An async Server Component is still awaiting its data. What is on screen during that wait, and what flips it to the real content?

    The closest enclosing <Suspense> shows its fallback; the moment the component’s await settles it stops suspending, and React replaces the fallback with the component’s output.
    Nothing paints until you set a loading flag back to false from inside a useEffect.
    The fallback stays up until you call a function that dismisses it once the data is in hand.
    The entire page stays blank until every component on it has finished loading.

    A dashboard runs two independent reads: a profile that comes back in 10ms and an activity feed that takes 800ms. You want the profile on screen the instant it’s ready, not 800ms later. Which arrangement does that?

    Put each read inside its own <Suspense>, each with its own fallback.
    Put both reads inside a single <Suspense> that shares one fallback.
    Keep one <Suspense> around the page and add an isLoading flag to the profile.
    Drop the boundary entirely and let both reads render whenever the page loads.

    An invoice detail view sits behind a <Suspense>. The user clicks from inv_001 to inv_002, the component re-renders with the new invoiceId, and inv_001’s data stays on screen for the full new load — no skeleton in between. Why does the fallback skip, and which one-line change brings it back?

    React sees the same component in the same slot and reuses its resolved subtree; giving that subtree a key tied to invoiceId marks it as a new instance, forcing a remount that suspends afresh.
    The boundary lost track of the load; reintroducing an isLoading flag in a useEffect that watches invoiceId is what re-shows the skeleton.
    The stale render is an uncaught failure; wrapping the view in try/catch lets you replace the old data while the new invoice loads.
    One boundary can only suspend once, so nesting a second <Suspense> around the same component restores the fallback on later changes.

    Each claim is about the boundary of responsibility you just drew — what Suspense does and, more often, what it does not. Mark each statement True or False.

    If a component below a <Suspense> boundary throws an error, the boundary catches it and shows the fallback.

    Suspense catches a suspend signal — “not ready yet” — not an error. A thrown error sails straight past it and crashes the tree; catching it is the job of an Error Boundary, which the App Router exposes as error.tsx (two lessons from now). The fallback means “still loading,” never “something went wrong.”

    A fallback can itself await data or read a pending promise.

    The fallback is rendered synchronously and must not suspend. It is what you show while something loads — if it had to load too, there would be nothing to show during the wait and the boundary would have no purpose. Keep fallbacks dumb and instant: static markup, no data reads.

    A single <Suspense> boundary is all-or-nothing: it shows its fallback while any one child is still suspending, and reveals its children only once every child has resolved.

    One boundary has exactly two visual states and stays in the fallback state until the last holdout settles. That is precisely why a slow read can hold a fast one hostage under a shared boundary — and why two independent reads usually want two boundaries.