When Next.js Streaming Doesn't Work: Designing Suspense and 'use client' Boundaries
I've been there myself — slapping 'use client' at the top of a layout and spending ages wondering "why isn't Streaming working?" I'd used <Suspense>, built skeletons, but when I actually looked at the Network tab, the HTML was arriving all at once in one big chunk. I knew something was wrong, but I couldn't figure out where things had gone sideways. Once I found the root cause, the perceived page load speed changed dramatically.
The problem is that understanding <Suspense>, 'use client', and Streaming individually is an entirely different matter from understanding how the three need to fit together. Each concept is well covered in the official docs, but how all three should relate to each other isn't obvious until you've bumped into it firsthand.
Where you place <Suspense> boundaries and 'use client' boundaries is the difference between merely using two APIs and actually having Streaming work. In this post, we'll walk through the placement principles and the traps you'll commonly encounter in practice, with code examples. The baseline environment is Next.js App Router + React 18/19.
Core Concepts
How Streaming Actually Works
The key idea behind Streaming is that the server doesn't wait until the HTML is fully assembled before sending it — it pipes chunks to the browser as each part becomes ready, via HTTP Chunked Transfer Encoding. React 18's renderToPipeableStream made this possible. The old renderToString waited for the entire render to complete and returned the HTML string as one big blob; renderToPipeableStream can push ready chunks to the browser as a Node.js stream. Next.js App Router uses this by default.
[Server] [Browser]
Layout + Static Shell ─────────────→ Received immediately, painting starts
(TTFB minimized)
<Suspense fallback={<Skeleton/>}> → Skeleton rendered
<SlowDataComponent/> → Swapped in when data is ready
</Suspense>Each <Suspense> boundary becomes an independent streaming unit — but there are conditions for this to actually work. The component inside <Suspense> must either be an async Server Component or suspend via use(promise). Simply wrapping something in <Suspense> alone doesn't split the chunks.
Once data is ready, React sends the serialized component tree along with DOM pointers so the client can insert it at the exact right location. This process is tied to Selective Hydration.
What is Streaming? A technique where HTML is sent in pieces so the browser can receive content and paint it to the screen even before the server has finished rendering. As a result, the time to first byte (TTFB) becomes independent of your slowest data query.
How a 'use client' Boundary Kills Streaming
'use client' isn't simply a declaration that "this component runs on the client." It splits the component tree into server and client at that boundary, and every component below it is included in the client JS bundle.
This is where the mistake happens. If you put 'use client' at the top of a layout, the entire subtree beneath it becomes a client component. With no Server Components, Streaming has no chance to activate properly.
// 🚫 This makes Streaming effects disappear
'use client';
export default function Layout({ children }: { children: React.ReactNode }) {
return <main>{children}</main>; // Entire subtree goes into the client bundle
}
// ✅ Apply selectively only to interactive leaf components
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<main>
<NavBar /> {/* Server Component — not included in bundle */}
{children}
</main>
);
}What Changed with the React 19 use() Hook
With the official release of the use() hook in React 19, the pattern for passing data from Server to Client Components became much cleaner. You create a Promise in a Server Component without await and pass it to a Client Component, which receives it with use(promise) and integrates it with Suspense.
// Server Component — streams a Promise without awaiting it
export default function Page() {
const dataPromise = fetchData(); // Not awaited!
return (
<Suspense fallback={<Skeleton />}>
<ClientComponent dataPromise={dataPromise} />
</Suspense>
);
}
// Client Component — triggers Suspense via use()
'use client';
import { use } from 'react';
interface Data {
title: string;
}
export function ClientComponent({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // Suspends until ready
return <div>{data.title}</div>;
}Components with event handlers like onClick cannot be Server Components — event handling only works in the browser environment. That's why components that need interactivity require 'use client', and this is exactly where the use() pattern fits perfectly.
Why must the Promise be created in a Server Component? Writing
use(fetchData())inside a Client Component creates a new Promise on every render, causing an infinite loop. A Promise created in a Server Component remains stable across re-renders.
Practical Application
Example 1: Dashboard — Handling Multiple Sections with Parallel Streaming
This is the most common pattern in production. It's especially effective for screens like dashboards that need multiple independent data fetches.
// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { DashboardStats } from './DashboardStats';
import { RecentActivity } from './RecentActivity';
import { StatsSkeleton, ActivitySkeleton } from './Skeletons';
export default function DashboardPage() {
// Start both Promises simultaneously — no await
const statsPromise = fetchStats();
const activityPromise = fetchRecentActivity();
return (
<main>
{/* Shell — streams immediately regardless of data */}
<h1>Dashboard</h1>
<QuickActions /> {/* Server Component, no data needed */}
{/* Two independent streaming units — processed in parallel */}
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats promise={statsPromise} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity promise={activityPromise} />
</Suspense>
</div>
</main>
);
}// app/dashboard/DashboardStats.tsx (Client Component)
'use client';
import { use } from 'react';
interface StatsData {
totalUsers: number;
}
// Refresh handler — replace with router.refresh() or revalidatePath in real implementations
function refreshStats() {
window.location.reload();
}
export function DashboardStats({ promise }: { promise: Promise<StatsData> }) {
const stats = use(promise);
return (
<div className="card">
<span>{stats.totalUsers.toLocaleString()}</span>
<button onClick={refreshStats}>Refresh</button>
</div>
);
}| Point | Description |
|---|---|
Promise creation without await |
Both fetches start simultaneously, not dependent on the slowest one |
Separate <Suspense> boundaries |
Stats and activity stream independently |
'use client' only on leaves |
Declared only on components that need interactivity (buttons) |
Example 2: Preventing useSearchParams CSR Bailout
Honestly, I remember being blindsided by this one late at night. If you don't wrap a component that uses useSearchParams() in <Suspense>, the entire page is force-switched to Client-Side Rendering. One search page kills Streaming for your entire app.
// 🚫 CSR Bailout occurs — entire page switches to client rendering
'use client';
export default function SearchPage() {
const params = useSearchParams(); // This one line is the problem
return <div>Search results: {params.get('q')}</div>;
}// ✅ Correct pattern — isolate useSearchParams logic and wrap in Suspense
// SearchContent.tsx (Client Component — isolated to a small unit)
'use client';
function SearchContent() {
const params = useSearchParams();
return <div>Search results: {params.get('q')}</div>;
}
// page.tsx (Server Component)
import { Suspense } from 'react';
export default function SearchPage() {
return (
<>
<h1>Search</h1> {/* Renders immediately */}
<Suspense fallback={<div>Searching...</div>}>
<SearchContent /> {/* useSearchParams isolated here */}
</Suspense>
</>
);
}What is CSR Bailout? The behavior where Next.js detects certain dynamic APIs (
useSearchParams,cookies,headers, etc.) and force-converts that entire page from static rendering to client-side rendering. On Next.js 15.2 and above, you can use thenext build --debug-prerenderflag to track which pages it occurs on.
Example 3: Designing Sequential Content Loading with Nested Suspense
Useful when there's a visual priority order between content, like on a product detail page. You can deliver a progressively filling experience: core info → reviews → recommended products.
// app/products/[id]/page.tsx
export default function ProductPage() {
return (
<>
{/* LCP elements — placed outside Suspense, renders immediately */}
<ProductHero />
<ProductInfo />
{/* First stream — reviews */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
{/* Second stream — fallback resolves after Reviews resolve */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
</>
);
}The order in which nested Suspense fallbacks resolve can be counterintuitive. The inner <Suspense> doesn't even mount in the DOM until the outer <Suspense> resolves. That means fetching for Recommendations only starts after Reviews is fully ready and the outer boundary resolves. This is a good pattern if sequential loading is intentional, but if you want both to start simultaneously in parallel, you need to separate the two <Suspense> boundaries into sibling nodes.
Example 4: 2-Tier Strategy with loading.tsx + Component-Level Suspense
This approach combines a full-page fallback for route transitions with fine-grained per-component loading. It's become the standard pattern in the modern Next.js ecosystem.
app/
dashboard/
loading.tsx ← Full-page fallback on route transition (auto Suspense wrapping)
page.tsx ← Individual sections granularized with <Suspense>
components/
SlowWidget.tsx ← Has its own <Suspense> boundary// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardPageSkeleton />; // Displayed on route transition
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
<StaticHeader /> {/* Renders immediately */}
{/* Separate from loading.tsx — granularized per section */}
<Suspense fallback={<WidgetSkeleton />}>
<SlowWidget />
</Suspense>
</div>
);
}Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Reduced TTFB | Static shell is sent immediately, so time to first byte is independent of the slowest query |
| Improved FCP | Browser paints the shell immediately, decoupling FCP from data fetching time |
| Selective Hydration | Each Suspense boundary hydrates independently, responding faster to user interaction |
| Waterfall prevention | Multiple slow components stream in parallel, no serial waiting |
| Better UX | Progressive content display with skeleton UI, no layout shift (CLS) |
What is Selective Hydration? A React feature that treats each Suspense boundary as an independent hydration unit. When a user tries to interact with a specific component, that part is prioritized for hydration, allowing interaction even before the full hydration is complete.
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
Misuse of 'use client' scope |
Declaring it on a parent layout includes the entire subtree in the client bundle, eliminating Streaming effects | Apply selectively only to interactive leaf components |
| LCP elements inside Suspense | If LCP elements like hero images or large text are inside Suspense, LCP becomes dependent on data delays | Place LCP elements in the static shell or outside Suspense |
| Single Suspense boundary | Wrapping an entire page in one boundary lets the slowest component block everything | Design separate boundaries for each independent data unit |
| Over-granularized Suspense | Too fine-grained causes content to "pop in" sequentially, which is visually jarring | Group related sections under one boundary to show them together |
use() hook Promise recreation |
Creating a new Promise per render as in use(fetchData()) causes infinite loops |
Create Promises in a Server Component or outside the component and pass them in |
notFound() status code issue |
notFound() calls may return 200 OK in Streaming mode |
Keep Server Components that use notFound() outside of Suspense |
Most Common Mistakes in Practice
- Declaring
'use client'inapp/layout.tsxor a shared layout file, converting the entire Server Component tree to client - Not wrapping components that use
useSearchParams()in<Suspense>, causing the entire page to CSR Bailout - Placing LCP-critical elements like hero banners and main product images inside a
<Suspense>that waits on slow data
Closing Thoughts
Intentionally designing
<Suspense>and'use client'boundaries is the difference between simply using two APIs and actually activating Streaming.
Here are 3 steps you can start with right now.
- You can start by auditing where
'use client'appears in your current project. Usegrep -r "'use client'" ./appto see where it's declared, and if it's attached to a layout or large container, consider moving it down to interactive leaf components. - It's also worth wrapping Server Components with slow data fetches in
<Suspense>and removingawait. Switch to theconst dataPromise = fetchData()form and apply the pattern of consuming it withuse(dataPromise)in a child Client Component — you'll be able to see chunks arriving separately in the Network tab. - If you have components using
useSearchParams(), isolate them and wrap them in<Suspense>. If you're on Next.js 15.2 or above, using thenext build --debug-prerenderflag to find pages where CSR Bailout occurs first is an efficient starting point.
References
- React Official Docs —
<Suspense> - React Official Docs —
use()Hook - React Official Docs — Server Components
- Next.js Official — Streaming Guide
- Next.js Official — loading.js File Convention
- Next.js Official — useSearchParams CSR Bailout Fix
- React 18 Suspense SSR Architecture Discussion (GitHub)
- Next.js 15 Streaming Handbook — freeCodeCamp
- React Server Components: Concepts and Patterns — Contentful
- Data Fetching in 2025: Streaming, Suspense, Deferred Fetching — Medium