Streaming, Suspense and Loading UI in Next.js
Streaming lets Next.js send the fast parts of a page immediately while the slow parts catch up. How loading.tsx and Suspense boundaries work, and how to design good fallbacks.
Server rendering has a classic weakness: the response is only as fast as its slowest query. If the recommendations panel takes 800 milliseconds, the visitor stares at a blank tab for 800 milliseconds, even though the rest of the page was ready long ago. Streaming fixes this by sending HTML in chunks — the fast parts immediately, the slow parts as they resolve — and in the App Router it is built in, driven by Suspense.
What streaming actually does
When a component wrapped in Suspense is still awaiting data, Next.js sends the fallback in the initial HTML and keeps the connection open. As the data resolves on the server, the real markup streams down and swaps into place — no client-side fetch, no hydration-dependent spinner logic. The visitor sees a meaningful page shell in the first round trip and watches the remaining sections fill in.
Two worries can be retired immediately. Streaming does not hurt SEO — the streamed document is a single HTML response, and search engines index the complete content. And it requires no client JavaScript to work; the swap from fallback to content happens as the HTML arrives, before hydration.
loading.tsx: the zero-effort version
The simplest entry point is a loading file next to a page. Next.js wraps the route segment in a Suspense boundary automatically, showing your fallback during both the initial load and client-side navigations to that route. That navigation coverage is easy to underrate: a good route-level skeleton makes the whole app feel faster, not just first loads.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />;
}It is a good default, but it is all-or-nothing: the entire route shows the skeleton until the whole page is ready. That is exactly the granularity problem Suspense boundaries solve.
Granular boundaries for mixed-speed pages
Most real pages mix fast and slow data. Wrap only the slow sections, and move the awaits into the wrapped components so the boundaries can do their job — a page that awaits everything at the top level has nothing left to stream.
export default function ProductPage({ params }: Props) {
return (
<>
<ProductDetails params={params} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews params={params} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts params={params} />
</Suspense>
</>
);
}Suspense boundaries pair naturally with error boundaries. An error file alongside the route, or an ErrorBoundary around the same slow component, means a failing reviews service degrades one section instead of taking down the page — resilience and streaming come from drawing the same lines.
Fallbacks are a design problem
- Match the fallback's dimensions to the content it replaces, or the swap causes layout shift
- Prefer skeletons that echo the real layout over generic spinners
- Do not nest five boundaries on one screen; a page popping in piecemeal feels worse than two well-chosen regions
- Never wrap above-the-fold critical content just to stream it — the heading and primary content should be in the first chunk
When not to stream
If every query on the page resolves in tens of milliseconds, a skeleton that flashes for one frame is worse than no skeleton — it reads as flicker. Streaming pays off when there is a genuine speed gap between sections. Fix the obvious data-layer slowness first, then stream what remains legitimately slow, such as third-party calls you do not control.
Watch, too, for boundaries that never resolve quickly because the component inside them runs sequential awaits. Streaming exposes data-layer sins rather than absolving them — the skeleton just gives everyone somewhere comfortable to stare while the waterfall runs.
Perceived performance is a feature you design, not a by-product. If you would like a second pair of eyes on yours, STRCLI is around.