One Slow Component, One Skeleton: How a Suspense Boundary Splits the Page
The dashboard has three regions: a header (fast), a stats row (fast), and a "recent activity" feed that waits 1.2 seconds on a third-party API. Without a boundary, React cannot send any of it until the feed resolves — the browser sits on an empty document for 1.2 seconds and every fast region is hostage to the slow one. A Suspense boundary declares the opposite: send everything else now, park this spot with a placeholder, fill it in when ready.
export default async function Dashboard() {
return (
<main>
<Header /> {/* resolves immediately */}
<StatsRow /> {/* resolves immediately */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* async: awaits the API */}
</Suspense>
</main>
);
}
async function RecentActivity() {
const items = await fetch('https://feed.example.com/recent', {
cache: 'no-store',
}).then((r) => r.json());
return <Feed items={items} />;
}
What the browser receives, in order, is the part worth memorizing:
1. headers
2. shell HTML: <main>, header, stats, <activity-skeleton> <- paints immediately
3. ...later... a script/link chunk that swaps the skeleton for <Feed>
Time to First Byte is measured against step 2, not against the feed. The user sees a working header and stats — clickable, styled — while the slow region still resolves; the skeleton is replaced by streaming the real markup and a small script that commits it. Critically, this works before hydration: the shell and its fallback are plain HTML, so a slow third-party API costs a visible placeholder in one region, not a blank page.
The boundary also defines ownership of failure and loading. An error inside RecentActivity propagates to the nearest error boundary — one created by a parent, not to the page root — and a loading.tsx above it would cover the same region with a different placeholder. Think of the tree as a set of nested, independently resolving boxes: what happens inside one box does not reset another. That is why the stats row does not flash when the feed finally arrives.
Two properties of async children that surprise people. First, siblings under the same boundary are not parallel with each other — two async components side by side inside one <Suspense> still await in whatever order React processes them, so if both are slow, give each its own boundary; then both requests start immediately and land independently. Second, boundaries compose: a boundary inside a boundary streams the inner one first when the outer resolves, which is how you get progressive detail rather than one monolithic spinner.
Placement is the design decision interviews ask about. Too high (one boundary around the whole page) and you are back to a full-page skeleton with no benefit. Too low (none) and a single slow read blocks everything below it. The working heuristic: one boundary per independently slow region, each with a skeleton that matches the final layout's shape — same height, same number of rows — so the swap does not shift the page. A skeleton that differs from the real content is a layout-shift bug wearing a spinner's clothes.
loading.tsx Is a Suspense Boundary: What the File Actually Does
Every App Router project gets streaming for free the moment it adds loading.tsx, and most teams never connect that file to the mechanism. The relationship is exact: loading.tsx is sugar for wrapping the segment's children in a Suspense boundary whose fallback is that file's default export.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton rows={6} />;
}
// what the router effectively renders around every page in dashboard/
<Suspense fallback={<Loading />}>
{matchedPage}
</Suspense>
The equivalence is worth stating precisely, because it explains three observed behaviors:
- It covers the whole segment. Everything under
dashboard/— the page and any deeper segment not yet matched — renders inside it. A slow read anywhere in that subtree hits this skeleton, not a white screen. - It works on client-side navigation, not just first load. Clicking a
<Link>into the segment triggers a server render of the new page; while it streams, React shows the sameloading.tsxfallback in place. This is also why<Link>prefetching a dynamic route prefetches the loading boundary — the skeleton is the fastest thing to prepare. - Nesting multiplies.
app/dashboard/loading.tsxandapp/dashboard/settings/loading.tsxproduce two independent boundaries: navigating into settings shows the settings skeleton inside the dashboard shell, which may already be mounted from the previous page. Deeper skeletons never unmount the layout above them.
app/
layout.tsx shell (persists across navigation)
dashboard/
loading.tsx boundary A: covers all of dashboard/
page.tsx slow: awaits analytics
settings/
loading.tsx boundary B: nested inside A
page.tsx slow: awaits profile
When to reach past the file convention for explicit <Suspense>: when one widget inside a page is slow and the rest should render — loading.tsx covers the segment, so it would skeletonize the good parts too. Explicit boundaries let the page shell, the chart, and the table render while only the slow table streams:
<Suspense fallback={<TableSkeleton />}>
<SlowTable />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
Both approaches can coexist: keep loading.tsx as the segment-level default and add boundaries for individual slow regions. Note the rule that trips people up — loading.tsx cannot be partial. It is a file; it is all or nothing for that segment. Also, a boundary does not fetch anything: the data load still happens in the async component that awaits it, and if that component has no boundary above it, the file-based fallback never appears — you get a blocked document instead.
One more property worth having ready: loading.tsx also defines what a <Link> can prefetch for a dynamic route. The loading boundary is static enough to prepare before the click, so the skeleton can be in the router's cache when the user navigates — the click paints the fallback instantly while the real data streams behind it. That turns a decorative file into a latency optimization: a segment with a slow page and no loading.tsx has nothing meaningful to prefetch, and the user pays the full wait on every click.
A related file, template.tsx, behaves similarly but is not a Suspense boundary: it re-mounts on every navigation (no state preservation), which makes it right for enter animations and wrong for a stable shell. Choosing between layout.tsx, template.tsx, and loading.tsx is choosing between persist, remount, and stream — three different lifecycle answers to three different questions.
Streaming in Practice: Parallel Boundaries, Waterfalls, and the Numbers You Check
Streaming changes the economics of a slow page: instead of "everything waits for the slowest read", each region pays only for its own data. Getting the win requires three deliberate choices — where the boundaries are, what runs inside each one, and how you verify it afterwards.
Parallelism comes from boundary placement. Inside one boundary, awaits serialize; across boundaries, work starts as soon as each branch is scheduled:
// BAD: one boundary, two sequential awaits -> 400 ms before anything streams
<Suspense fallback={<Skeleton />}>
<Combined /> {/* await feed(); await recommendations(); */}
</Suspense>
// GOOD: two boundaries -> both requests start immediately
<Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
<Suspense fallback={<RecsSkeleton />}><Recommendations /></Suspense>
Inside a single branch, keep applying Promise.all for independent reads — boundaries give you cross-region parallelism, not free parallelism within a region.
The order of the bytes matters for what the user can do. A shell containing the nav and the primary action arrives first and is interactive before the JS for the heavy regions loads, because the shell is plain HTML. Structure the tree accordingly: expensive interactive chrome in the shell, anything requiring data below a boundary. A classic mistake is putting the entire page (including its fetch) under one boundary — you have renamed the spinner, not removed it.
Navigation behaves the same. On a client-side route change, the router renders the destination and shows the nearest loading.tsx/boundary fallback while data streams; if you use router.push inside a transition, React can keep the current UI interactive until the new content is ready. The mental model — the current screen stays visible until the next one has something to show — is the same for loading files, explicit boundaries, and prefetching.
Verify with numbers, not vibes. In DevTools' Network tab, the document request's timing shows TTFB (the shell) far below the completion time of the slowest data request; the response body arrives in chunks, with the skeleton in the first chunk and the real markup appended later. If TTFB equals the slow API's latency, something is bypassing your boundary — usually an await placed above the <Suspense> (in the layout, or directly in the page before the JSX that contains the boundary). That ordering bug is the single most common reason streaming "doesn't work": the boundary is present, but its parent already waited.
// the bug: awaiting before the boundary exists
const activity = await getActivity(); // blocks the whole subtree
return <Suspense fallback={<Skeleton />}><Feed data={activity} /></Suspense>;
Move the await inside the child component and the shell flushes. With that fixed, the check is a single line in the response headers and body: chunked transfer, shell first, skeleton visible before the data lands. The same reasoning applies to the layout: an awaited read in layout.tsx delays every route beneath it, so shared chrome should either be fast or wrapped in its own boundary — a slow global fetch at the root is the highest-leverage place to find one blocking await.
A last practical note on error handling under streaming: if the slow component rejects after the shell has been sent, React swaps in the nearest error.tsx for that region instead of failing the whole document. Boundaries therefore do double duty — they decide where the skeleton goes and where the failure lands — which is one more reason to draw them around independently failing regions rather than around the entire page. And note what is streaming behind the fallback: server components that awaited their data, exactly as Fetching Data in Async Components describes, plus the form results and revalidated content that Defining Server Actions will deliver through the same pipeline.
Quiz
1. A page awaits a 1.2s API before returning any JSX. Where does Suspense need to be, and what does the user see?
2. What does app/dashboard/loading.tsx actually do?
3. Two independent slow widgets currently sit inside one Suspense boundary. What is the effect of giving each its own?
4. Your response times out before any HTML arrives even though a loading.tsx exists. What do you check first?
Flashcards
Question
What HTML does the browser receive for a Suspense-wrapped slow component, and in what order?
Click to reveal answer
Answer
First the shell: fast regions plus the boundary's fallback (skeleton) — this sets TTFB and paints before hydration. Later, a chunk with the resolved markup plus a script that commits it. The slow region costs a placeholder, not a blank document.
Question
loading.tsx vs explicit <Suspense> vs template.tsx?
Click to reveal answer
Answer
loading.tsx: a file-based Suspense boundary around the whole segment, all-or-nothing, used on first load and Link navigation. Explicit <Suspense>: a boundary around one region so the rest of the page renders. template.tsx: remounts every navigation (good for animations), not a loading boundary — layouts persist.
Question
Where must the await live for streaming to work?
Click to reveal answer
Answer
Inside the async child rendered under the Suspense boundary. An await above the boundary — before the JSX, or in an ancestor layout — blocks the shell and fallback too, so TTFB equals the slow API latency and nothing streams.
Question
How do you get parallel resolution for multiple slow sections?
Click to reveal answer
Answer
Give each its own boundary: sibling <Suspense> regions start their work independently and stream when each resolves. Inside one boundary, awaits still serialize — use Promise.all there for independent reads.
Question
How do you verify streaming is actually happening?
Click to reveal answer
Answer
Network tab: TTFB of the document well below the slowest data request, response arriving in chunks with shell + skeleton in the first chunk. If TTFB matches the API latency, an await is above the boundary. Also compare skeleton dimensions to the real content to avoid layout shift.
Revision Notes
Key Takeaways
- 1. Suspense turns one slow region's cost into a placeholder instead of a blocked document — the shell and skeleton stream first
- 2. loading.tsx is literally a Suspense boundary around a segment's children, active for both first load and client navigation
- 3. Boundaries give parallelism across regions; within a region awaits still serialize, so keep Promise.all for sibling reads
- 4. The most common streaming bug is an await placed above the boundary, which delays the fallback together with the content
- 5. Skeletons should mirror the final layout's dimensions or streaming trades a spinner for layout shift
Interview Tips
- • "Explain what loading.tsx does" — name the mechanism (file-based Suspense boundary), when its fallback shows (first load and Link nav), and how nesting composes with layouts
- • "How does streaming improve TTFB?" — the shell flushes without the slow data; describe the chunk order and note it works pre-hydration
- • "My page still blocks — why?" — locate the await above the boundary (page before JSX, or ancestor layout); this diagnosis question has a single correct answer and you should reach it fast
- • "Skeleton or spinner?" — a shaped skeleton matching final dimensions avoids layout shift and sets expectations; a bare spinner in a full-page boundary gives up streaming's benefit
Cheat Sheet
Suspense and Loading States
Mechanism:
<Suspense fallback={<Skeleton />}>
<RecentActivity /> {/* async child: awaits inside */}
</Suspense>
Order of bytes: shell HTML (fast regions + fallback) -> TTFB -> later chunk with resolved markup + commit script. Works before hydration.
loading.tsx = sugar:
app/dashboard/loading.tsx -> <Suspense fallback={<Loading/>}>{children}</Suspense>
Covers the whole segment, on first load and Link navigation; nested loading files = nested boundaries; <Link> prefetch of a dynamic route prefetches this fallback.
Placement rules:
- one boundary per independently slow region; skeleton shaped like the final layout (no CLS)
- independent slow widgets get separate boundaries -> parallel requests (inside one, awaits serialize)
awaitmust be under the boundary; an await in the page/ancestor before the JSX blocks everything- keep shell (nav, primary action) outside boundaries so it is interactive first
Verify: document TTFB << slowest API; chunked body with skeleton first; if TTFB == API latency, find the await above the boundary.
Related files: layout persists across navigation · template remounts · loading streams · error bounds failures.