Skip to content
Chapter 31Lesson 1

Suspense, the fallback contract

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

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 about 800 milliseconds: long enough to notice. This lesson is about the user, not the code. For those 800 milliseconds, what fills the screen?

If you have written React before, your instinct is a loading flag, and in the App Router it is the wrong one. You hold an isLoading boolean in useState, flip it on, fetch inside a useEffect, and flip it off when the data lands, with if (isLoading) return <Skeleton /> somewhere in your JSX. It works, but it costs more than it looks.

The flag lives in a Client Component, because useState and useEffect only run on the client, and you have to thread it down to wherever the spinner shows. It also does not scale: the moment a page loads two independent things, 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 can get wrong, so loading state ends up scattered across the tree and easy to forget.

React’s replacement is one declarative primitive, the <Suspense> component. The syntax is the easy part. The skill that takes longer is deciding not how to write it but where to draw the boundary, and that is what the rest of this lesson works toward.

What Suspense is: a built-in component with one contract

Section titled “What Suspense is: a built-in component with one contract”

Suspense is easy to miscategorize. It is not a hook, not a next.config flag, not a data-fetching library. It is a built-in React component you place in JSX like any other, and its contract is one sentence: while any descendant is still loading, render the fallback; once every descendant has resolved, render the children.

Three properties make that contract work.

  • It is a component. You give <Suspense> a fallback prop and some children. There is no useSuspense and no setup call.
  • It reacts to a signal a child throws. A component that is not ready emits a suspend signal , and <Suspense> catches it the way try/catch catches a throw. So the boundary never needs to know what its children are loading or how: a child reading a database, a child reading a streamed promise, and a lazily-loaded component all look identical to it. It only knows something below said “not yet.”
  • It is all-or-nothing per boundary. The fallback shows while any child is still suspending, and the children show only once every child has resolved. A boundary is a unit with two visual states, and it stays in the fallback until the last holdout settles.

That last property drives where you place boundaries: everything inside one 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 InvoiceSkeleton is on screen in its place.
<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. The child settling flipped the state, not any code you wrote.

Here is the literal shape, a fallback and the thing it guards:

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

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

The piece you supply is the fallback . The next section covers the two kinds of children that can sit inside a boundary and trigger it.

Here are the two shapes side by side. They 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. It lives in three marked places: the flag, the effect that flips it, and the branch that reads it. Every new query needs its own flag, and forgetting to flip one is a whole class of bug.

There is no flag to forget to flip, because there is no flag.

A child throws a suspend signal and the boundary catches it. But which children actually do that? There are two shapes you will write, and both throw the same signal, which is why a single <Suspense> can sit above either one without caring which it gets.

The first 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, which tells React it 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. Until this promise settles the function has returned no JSX, so the component suspends and the nearest <Suspense> ancestor shows its fallback.

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 like any synchronous component, and React swaps the fallback for this output.

1 / 1

The second is the one from when a Server Component handed a promise to a Client Component, now seen from the loading side. 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; 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 above the Client Component catches that signal. The fallback shows until the promise resolves, then the interactive table renders with its data.

1 / 1

Why reach for the second shape when the first is simpler? Because the table sorts and filters, so it has to be a Client Component. This pattern lets the interactive shell be a Client Component while its data still streams 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 returns the value the moment it resolves.

Two other things can suspend: React.lazy(), which code-splits a component so its code loads on demand, and Suspense-aware hooks from some data libraries. Neither is the default for fetching data.

A fallback must render instantly and mirror the layout

Section titled “A fallback must render instantly and mirror the layout”

You write the fallback yourself. Suspense can’t generate one from the children, because it has no idea what the resolved content will look like. Two rules separate a fallback that helps from one that hurts.

The fallback renders synchronously and must not itself suspend. It is what you show while something loads, so if it also has to load, by awaiting or reading a pending promise, there is nothing to show during the wait and the boundary serves no purpose. Keep fallbacks instant: static markup, no data reads.

Prefer a skeleton that mirrors the final layout, with the same heights, the same number of rows, and the same rough shape as the content it stands in for. A spinner or a line of “Loading…” text works, but it is lower quality for a concrete reason: a spinner occupies a tiny box, so when the larger content arrives, everything below it lurches down the page. A skeleton that already reserves the content’s eventual footprint swaps in place, and nothing moves. That avoided jump is the point; layout shift on data load is a real UX defect, jarring on a surface the user hits dozens of times a day.

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, so when the list arrives it fills the dashed zone and shoves the footer down to the line below.

You know what Suspense is, what suspends, and what a good fallback looks like. What takes judgment 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” or “which component is async,” but what the user perceives as one thing arriving. 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 slow read holds the fast one back: the user waits the full 800ms to see the 10ms profile. A boundary is all-or-nothing, so the profile cannot appear until the feed resolves too.

The diagram makes 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.

The rule: 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: why two parallel boundaries each reveal separately, the mechanism that lets the server send the fast one first, is the next lesson, on streaming. Here you are learning only the placement decision.

Now walk the decision yourself. The drill 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. Nesting buys you a content-first reveal, shell then partial then full, with no coordinating state.

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

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

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

The outer fallback covers everything until its awaited content (the header) is ready, so the whole page is the PageSkeleton and 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.

The outer content resolves, so the header paints for real. The inner boundary now shows its own FeedSkeleton while the slow ActivityFeed loads, 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, the cascade is one boundary inside another with some synchronous content between them:

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

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

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, the shell and then a slow region inside it, not at every component that happens to be async.

Here is the trap, 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, treats it as the same instance, reuses the already-resolved subtree, and skips the fallback entirely. The user keeps seeing inv_001’s data, stale and wrong, for the full new load, with no loading indication. It looks like nothing happened, then the content silently changes underneath them.

The fix tells React 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 forces a fresh mount, which re-suspends, which brings the fallback back. This is the clean way to show a loading state when the route parameter changes: no isLoading flag, no effect, just an identity hint.

The deliberate opposite exists for when you don’t want the fallback on a change, like search-as-you-type, where flashing a skeleton on every keystroke is jarring. Wrapping the update in startTransition keeps the old content visible and gives you an isPending flag instead. It has its own React lesson; for now, just know key and transitions pull in opposite directions on purpose.

Each item below is a real bug someone ships. Suspense has one job; these are not it.

  • It does not catch errors. If a component below the boundary throws, that error sails past Suspense and crashes the tree. Catching it is the job of an Error Boundary, which the App Router exposes through error.tsx; we cover that, and the “resource not found” case, two lessons from now. Suspense catches a suspend signal, not an error.
  • It does not retry, and the fallback is not an error state. The fallback means “still loading,” nothing more. Suspense will not re-attempt a failed load for you.
  • It does not deduplicate fetches. Two children reading the same data fire two requests; making the second one cheap is request memoization with cache(), covered when we reach caching.
  • It does not auto-skeleton. You write the fallback. Suspense has no view into the children’s shape and cannot generate a placeholder from them.

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

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

Right now App wraps both widgets in one shared <Suspense>. That boundary is all-or-nothing: its fallback covers everything until the 1500ms slow read finishes, holding the fast widget back. Split it so the slow widget gets its own boundary and 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> and skeleton, so the two reads are independent. The profile’s boundary resolves at about 50ms and paints while the feed still shows SlowSkeleton; the feed fills in at about 1500ms. Only App changes.

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

    SharedSkeleton is now unused: it was exactly the combined fallback 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.

    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 what Suspense does, and more often does not, do. 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.