A Next.js App Router page shows a fast product summary and a slow reviews list. The segment has a loading.tsx, so the whole page area sits behind the skeleton until the reviews query finishes. How do you make the summary appear while only the reviews keep loading?
answer
- one boundary per segment, by design
- granularity has to come from inside the page
- wrap only the slow subtree
- the await must live under the boundary
- prop-drilling awaited data defeats it
basics
~20 sloading.tsx is segment-granular: one fallback for the entire page. Move the reviews query into its own component, render it inside a <Suspense> boundary in page.tsx, and keep page.tsx itself free of that await so the summary can flush immediately.
solid answer
~50 sThe `loading.tsx` convention gives you exactly one boundary per segment, wrapping the whole page — so if anything the page awaits is slow, everything behind that boundary waits with it. The fix is finer granularity inside `page.tsx`: extract the reviews into their own async Server Component that performs its own `await`, and wrap just that component in `<Suspense>` with a small local fallback. The critical detail is where the `await` lives. If `page.tsx` awaits the reviews and passes the result down as a prop, the page cannot render at all and the extra boundary buys nothing — the slow work has to sit *inside* the suspended subtree. Once it does, the summary is part of the shell and flushes first, and the reviews HTML streams in when the query resolves. The segment's `loading.tsx` still covers whatever the page needs before that point, so the two coexist at different granularities.
code
tsx · 28 linesimport { Suspense } from 'react';
async function Reviews({ productId }: { productId: string }) {
const res = await fetch(`https://example.com/api/reviews/${productId}`);
const reviews: { id: string; body: string }[] = await res.json();
return (
<ul>
{reviews.map((r) => (
<li key={r.id}>{r.body}</li>
))}
</ul>
);
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const res = await fetch(`https://example.com/api/products/${id}`);
const product: { name: string } = await res.json();
return (
<main>
<h1>{product.name}</h1>
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews productId={id} />
</Suspense>
</main>
);
}go deeper
Know that the segment's loading.tsx is one fallback for the whole page, and that finer control means writing <Suspense> yourself around the slow part inside the page.
Explain the mechanic precisely: the slow await must execute inside the suspended component, because a boundary can only defer work below it. Show the broken version where data is awaited in page.tsx and passed down.
Demonstrate judgment about how many boundaries a page should have, why an over-sliced page reads as jank, and how fallback dimensions relate to layout stability once the streamed chunk lands.
Frame it as a policy question: which slow dependencies are acceptable to stream around versus which should be fixed, cached, or moved off the critical path entirely, and what the page owes the user in the first paint.
## Why the skeleton is all-or-nothing `loading.tsx` creates one Suspense boundary, positioned by convention around the segment's `page.tsx` and everything nested below it. That is deliberately coarse — it is a free, zero-config default, not a tuning knob. Suspense semantics do the rest: when anything inside a boundary is pending, that boundary shows its fallback, so a single slow query behind a page-wide boundary hides the whole page, including parts whose data was ready in ten milliseconds. So the question is not "how do I make loading.tsx smarter" — it has no granularity to give. It is "where do I want the boundaries." ## The shape of the fix Split the page into a fast part that renders synchronously into the shell and a slow part behind its own boundary: ```tsx // app/products/[id]/page.tsx import { Suspense } from 'react'; export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const product = await getProduct(id); // fast return ( <main> <ProductSummary product={product} /> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> </Suspense> </main> ); } async function Reviews({ productId }: { productId: string }) { const reviews = await getReviews(productId); // slow — awaited *inside* the boundary return <ul>{reviews.map((r) => <li key={r.id}>{r.body}</li>)}</ul>; } ``` Now the server can render and flush `<main>`, the summary, and the reviews skeleton as the shell, then stream the reviews markup in when `getReviews` resolves. ## The mistake that makes the boundary useless This version looks almost identical and does nothing: ```tsx const reviews = await getReviews(id); // ⛔ awaited in page.tsx return ( <Suspense fallback={<ReviewsSkeleton />}> <Reviews reviews={reviews} /> </Suspense> ); ``` The `await` runs before `page.tsx` returns any JSX at all, so nothing — including the boundary you just added — exists to be flushed. A boundary can only defer work that happens *underneath* it. Whenever someone reports "I added Suspense and TTFB didn't move," this is the first thing to check. ## Do the two conventions conflict? No, and you generally want both. Think of them as two levels: - `loading.tsx` covers whatever the page itself must resolve before it can render anything — the product lookup in the example above — and covers client navigation into the route. - In-page `<Suspense>` covers individual slow subtrees so they do not hold the rest hostage. If the page has *no* blocking work of its own, you may find the segment-level fallback never appears in practice on the server render; it still matters for the navigation transition. Some teams drop `loading.tsx` for such routes precisely so the shell paints with real content instead of a skeleton, and rely on per-subtree boundaries only. That is a legitimate choice, not a mistake. ## Choosing how fine to slice Granularity has a cost. Each boundary is an independent reveal, and a page with eight of them can flicker its way to completion in a way that reads as jank even though every individual number improved. Practical guidance: - Put a boundary around a **unit the user recognizes** — a reviews block, a recommendations rail, an activity feed — not around every component. - Group siblings that are meaningless apart under one boundary so they appear together. - Give the fallback roughly the **final dimensions** of the content, or the streamed content will shove the page around when it lands. - Do not put a boundary around something the page is fundamentally *about*. Streaming a shell that says nothing but "loading" is a worse first paint than waiting, because the user still has nothing to read. ## What this does not change The reviews query still takes as long as it took. Streaming reorders when bytes arrive; it does not make the database faster, and it does not reduce total transfer. If the reviews query is slow because it is genuinely expensive, boundaries buy you a better-feeling page while you go fix the query — they are not the fix.
- Can a segment keep its loading.tsx and still use in-page Suspense boundaries?Yes, and that is the normal setup. They operate at different granularities: `loading.tsx` covers work the page must finish before it renders anything, plus the client-navigation transition, while in-page boundaries defer individual subtrees. The nearest boundary above a pending component decides which fallback the user sees.
- The team added six boundaries and the page now feels janky. What went wrong?Each boundary is an independent reveal, so the page assembles in six visible steps and shifts layout at each one. Group fragments that are meaningless apart under a single boundary, and size fallbacks like the content they replace so nothing jumps when the real markup lands.
- Does streaming the reviews reduce the total time until the page is complete?No. The slow query takes exactly as long either way, and total bytes are unchanged. What moves is first byte and first paint: the user reads the summary while the reviews resolve. Treat it as a perceived-performance tool, not a substitute for fixing an expensive query.
saying these in an interview costs you the question
- Thinks loading.tsx can be scoped to part of a page
- Awaits the slow data in page.tsx and wraps the child in Suspense
- Believes adding boundaries makes the total load faster
- Assumes loading.tsx and in-page Suspense are mutually exclusive
- Wraps every component in its own boundary and calls it optimized