Implementing Suspense Boundaries in Next.js 14
Engineers working with the Next.js App Router streaming patterns frequently hit a concrete wall: the page streams, but TTFB is still high, hydration stalls, or the viewport goes blank on a data error. The symptom is almost always incorrect <Suspense> boundary placement β too coarse, too nested, or missing an ErrorBoundary wrapper altogether. This guide walks through the exact diagnostic and implementation steps needed to place boundaries correctly, verify the result with measurable targets, and fix the three most common failure modes.
Prerequisites
Suspense boundary placement and the streaming chunk model
Before stepping through implementation, it helps to see exactly how <Suspense> boundaries map to chunks in the React Flight stream. The diagram below shows the server-to-client delivery sequence for a page with two independent async Server Components, each behind its own boundary.
Each <Suspense> wrapper emits a <!--$?--> placeholder in the initial flush. When the promise settles, the server streams the resolved markup and a script tag that swaps the node in-place. Boundaries are independent β boundary B does not wait for boundary A.
Diagnostic / Implementation Steps
Step 1 β Map slow data-fetching components
Goal: identify which async Server Components need explicit boundaries rather than relying solely on loading.tsx inheritance.
Open React DevTools > Profiler and record a production-like render (next build && next start). Sort by βself timeβ and flag any component whose await duration exceeds 300 ms. These are your boundary candidates.
// app/dashboard/page.tsx β before: single coarse boundary
import { Suspense } from 'react';
// These two fetches run sequentially because they share one boundary
export default async function DashboardPage() {
return (
// Single boundary β both components must settle before anything streams
<Suspense fallback={<DashboardSkeleton />}>
<MetricsPanel /> {/* 600 ms fetch */}
<ActivityFeed /> {/* 200 ms fetch */}
</Suspense>
);
}
Expected output: The Profiler flame graph will show MetricsPanel and ActivityFeed rendered in the same commit. Both are blocked behind the slowest fetch (600 ms).
Step 2 β Assign one boundary per independent async subtree
Goal: decouple fast and slow fetches so each streams as soon as its own data resolves.
// app/dashboard/page.tsx β after: independent boundaries
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<main>
{/* Shell renders and flushes immediately β no async data here */}
<h1>Dashboard Overview</h1>
{/* Boundary A β MetricsPanel streams at ~600 ms */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
{/* Boundary B β ActivityFeed streams at ~200 ms, independent of A */}
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</main>
);
}
Keep the number of nesting tiers at three or fewer. Each extra level fragments the Flight payload and increases client-side reconciliation work.
Step 3 β Parallelise fetches inside a single component
Goal: prevent sequential waterfall blocking when one component depends on multiple independent data sources.
// lib/fetchDashboard.ts
export async function fetchDashboardData() {
// Promise.all initiates both requests concurrently on the server
const [analytics, inventory] = await Promise.all([
fetch('/api/analytics', { next: { tags: ['analytics'], revalidate: 60 } }).then(r => r.json()),
fetch('/api/inventory', { next: { tags: ['inventory'], revalidate: 60 } }).then(r => r.json()),
]);
return { analytics, inventory };
}
// app/dashboard/metrics/page.tsx
import { Suspense } from 'react';
import { fetchDashboardData } from '@/lib/fetchDashboard';
async function MetricsPanel() {
// Single await β both requests resolve in parallel, no waterfall
const { analytics, inventory } = await fetchDashboardData();
return <div className="metrics-grid">{/* render data */}</div>;
}
export default function MetricsPage() {
return (
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
);
}
Expected output: Chrome DevTools > Network shows two simultaneous XHR requests firing, not a sequential chain. The boundary resolves at the duration of the slowest request, not their sum.
Step 4 β Wrap boundaries with an ErrorBoundary
Goal: an unhandled throw inside an async Server Component silently closes the React Flight stream. An ErrorBoundary catches the rejection and streams a fallback instead of a blank viewport.
// components/ErrorBoundary.tsx
'use client'; // ErrorBoundary must be a Client Component β class lifecycle required
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props { children: ReactNode; fallback: ReactNode }
interface State { hasError: boolean }
export class ErrorBoundary extends Component<Props, State> {
state = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Log to your APM (Datadog, Sentry, etc.) β never swallow errors silently
console.error('[Stream error]', error.message, info.componentStack);
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
// app/dashboard/page.tsx β boundary + error recovery
import { Suspense } from 'react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
export default function DashboardPage() {
return (
<main>
<h1>Dashboard Overview</h1>
{/* ErrorBoundary outside Suspense: catches both render errors and stream rejections */}
<ErrorBoundary fallback={<MetricsErrorCard />}>
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<ActivityErrorCard />}>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
</main>
);
}
Step 5 β Defer hydration for heavy client-only islands
Goal: prevent heavy interactive components from blocking the critical streaming SSR path. Use next/dynamic with ssr: false to exclude the component from the server render phase entirely.
// components/DeferredChart.tsx
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// ssr: false β component is excluded from RSC render; bundle executes client-side only
// loading β static skeleton shown server-side AND during client chunk download
const Chart = dynamic(() => import('./ChartImpl'), {
ssr: false,
loading: () => (
// Dimensions must match ChartImpl's rendered output exactly β eliminates CLS
<div className="h-64 bg-gray-100 animate-pulse" aria-busy="true" aria-label="Loading chart" />
),
});
export default function DeferredChart() {
return (
// Suspense here guards the dynamic() promise itself during RSC streaming
<Suspense fallback={<div className="h-64 bg-gray-50" />}>
<Chart />
</Suspense>
);
}
Expected output: In the Network tab, the chart bundle (ChartImpl-*.js) loads after the HTML document, decoupled from the first paint. The main thread is free for above-the-fold hydration during the download.
Verification
After applying the steps above, confirm the results with these measurable checks:
TTFB (Time to First Byte)
curl -w "TTFB: %{time_starttransfer}s\n" -o /dev/null -s http://localhost:3000/dashboard
# Target: < 200 ms on a production-like server
Hydration CPU time
Open Chrome DevTools > Performance > Record a page load. Filter the flame chart by React and Hydration. Target: under 150 ms of main-thread work attributed to hydration.
Streaming chunk verification
In DevTools > Network, filter by Doc and open the response body of the HTML document. You should see <!--$?--> comment markers as placeholders, followed later in the stream by <!--$--> and <!--/$--> pairs wrapping the resolved markup. If you only see a single large HTML block with no markers, boundaries are not active.
Core Web Vitals
lighthouse http://localhost:3000/dashboard --view --only-categories=performance
| Metric | Target with streaming + Suspense | Verification method |
|---|---|---|
| TTFB | < 200 ms | curl -w "%{time_starttransfer}" |
| FCP | < 1.2 s (mobile Fast 3G) | Lighthouse > First Contentful Paint |
| Hydration CPU | < 150 ms | React DevTools Profiler > Hydration |
| CLS | < 0.01 | Lighthouse > Layout Shifts |
Troubleshooting
Stream closes and the viewport is blank after a data error
Root cause: an unhandled throw or rejected promise inside an async Server Component terminates the React Flight stream. The client receives an incomplete HTML document and React cannot recover.
Fix: add try/catch inside every data-fetching function and wrap the <Suspense> in an ErrorBoundary as shown in Step 4. Log the error to your APM before returning a fallback value, so silent failures surface in production monitoring.
// lib/fetchMetrics.ts
export async function fetchMetrics() {
try {
const res = await fetch('/api/metrics', { next: { revalidate: 30 } });
if (!res.ok) throw new Error(`metrics fetch failed: ${res.status}`);
return res.json();
} catch (err) {
console.error('[fetchMetrics]', err);
return null; // Return null rather than throwing β ErrorBoundary handles the UI
}
}
Hydration warning: "Initial UI does not match server-rendered output"
Root cause: the fallback DOM structure differs from the resolved componentβs DOM structure. Mismatched wrapper tags, different class names, or missing aria-* attributes cause React to detect a mismatch and re-render, inflating hydration CPU time and generating console warnings.
Fix: ensure the skeleton/fallback element uses the same wrapper tag and className dimensions as the resolved output. Use CSS aspect-ratio or explicit min-height rather than hard-coded pixel heights to keep both consistent.
// Mismatched: fallback is a <div>, resolved is an <article> β triggers warning
<Suspense fallback={<div className="h-40 skeleton" />}>
<ArticleCard /> {/* renders <article> at root */}
</Suspense>
// Correct: wrapper tags match
<Suspense fallback={<article className="h-40 skeleton" aria-busy="true" />}>
<ArticleCard />
</Suspense>
Apply suppressHydrationWarning only on verified non-deterministic leaf nodes (e.g., a <time> element rendered with Date.now()). Using it at the boundary level masks genuine structural mismatches.
Suspense boundary around a `use client` component causes double render or race condition
Root cause: wrapping a 'use client' component in <Suspense> without ssr: false causes the server to attempt rendering a component that calls browser-only APIs or hooks, producing a hydration race between the server render and the client activation.
Fix: use next/dynamic with ssr: false for any component that cannot run on the server (calls window, document, localStorage, or mounts third-party DOM libraries). This removes the component from the server render entirely.
// Before: Suspense around a client-only map component β hydration race
import LeafletMap from './LeafletMap'; // calls window.L internally
<Suspense fallback={<MapSkeleton />}>
<LeafletMap />
</Suspense>
// After: dynamic import skips server render, no race
import dynamic from 'next/dynamic';
const LeafletMap = dynamic(() => import('./LeafletMap'), {
ssr: false,
loading: () => <MapSkeleton />,
});
// No Suspense wrapper needed β dynamic() handles it internally
<LeafletMap />
Related
- Next.js App Router Streaming Patterns β covers the full streaming architecture, Flight protocol mechanics, and production optimization targets that this pageβs boundary placements feed into.
- Fallback UI and Skeleton Strategies β detailed guidance on designing skeletons that match resolved output dimensions and avoid CLS.
- How to Calculate Hydration Overhead in React β measurement workflow for quantifying the main-thread cost of hydration before and after applying boundary isolation.
β Back to Next.js App Router Streaming Patterns