Here's a mental model that took us embarrassingly long to internalize: streaming isn't about making your database queries faster. It's about not punishing your users for your slow database queries. The data still takes the same amount of time to arrive — you're just not holding the entire page hostage while you wait for it.
Before the App Router, if you had a page with three data sources — user profile (fast), recent activity (medium), analytics dashboard (slow) — the whole page waited for the slowest thing. Every time. The user stared at a blank screen or a spinner until that analytics query finished grinding. With streaming and Suspense, you can ship the fast stuff immediately and let the slow stuff trickle in. That's the whole idea.
How Streaming Actually Works
HTTP has supported chunked transfer encoding forever. The server sends response chunks as they're ready instead of buffering everything and sending it at once. React's server rendering can take advantage of this — it renders what it can, flushes that HTML to the client, then keeps rendering async components and streaming the rest as Promises resolve.
The browser starts parsing and rendering the first chunk immediately. Scripts load, above-the-fold content appears, the page feels alive. Then the slower pieces arrive and React hydrates them in place. From the user's perspective: the page loaded fast. From a technical perspective: it loaded progressively. The total time-to-fully-loaded might be identical, but the perceived performance is night and day.
Next.js App Router uses this by default. Every async Server Component is a streaming boundary candidate. The key is where you place your Suspense boundaries and what you show while waiting.
The Basics: Suspense Boundaries
The mental model for Suspense is simple: wrap anything slow in a Suspense boundary, provide a fallback, and Next.js handles the rest. The fallback renders immediately. The actual component streams in when ready.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserProfile } from '@/components/user-profile'
import { RecentActivity } from '@/components/recent-activity'
import { AnalyticsDashboard } from '@/components/analytics-dashboard'
import { ProfileSkeleton, ActivitySkeleton, AnalyticsSkeleton } from '@/components/skeletons'
export default function DashboardPage() {
return (
<div className="dashboard">
{/* Fast: no suspense needed, renders immediately */}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<div className="grid grid-cols-2 gap-4">
{/* Medium: some latency */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
{/* Slow: heavy analytics query */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsDashboard />
</Suspense>
</div>
</div>
)
}Each of those components is an async Server Component that fetches its own data. They run in parallel — Next.js doesn't wait for one before starting the next. The page HTML starts streaming as soon as the first chunk is ready. UserProfile resolves first, streams to the client, renders. Analytics takes 800ms? Fine, the skeleton holds the space and the content swaps in when it's ready.
// components/analytics-dashboard.tsx
async function AnalyticsDashboard() {
// This query is slow. That's okay now.
const analytics = await db.query.events.findMany({
where: and(
gte(events.createdAt, startOfMonth(new Date())),
eq(events.userId, await getCurrentUserId())
),
orderBy: desc(events.createdAt),
limit: 100,
})
const aggregated = aggregateEvents(analytics)
return (
<div className="analytics-card">
<h2>This Month</h2>
<MetricsGrid data={aggregated} />
</div>
)
}loading.tsx: The Page-Level Shortcut
Next.js gives you a file-based shortcut for this. Drop a loading.tsx in any route folder and it automatically wraps the page in a Suspense boundary with that file as the fallback. This is great for routes where the entire page content depends on data — you don't want to show a partial shell, you want to show a skeleton of the whole thing.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="dashboard animate-pulse">
<div className="h-20 bg-gray-200 rounded-lg mb-4" />
<div className="grid grid-cols-2 gap-4">
<div className="h-48 bg-gray-200 rounded-lg" />
<div className="h-48 bg-gray-200 rounded-lg" />
</div>
</div>
)
}The difference between loading.tsx and granular Suspense boundaries: loading.tsx is page-level, all-or-nothing. Granular Suspense lets you stream different parts of the page independently. Both are useful — loading.tsx for simpler routes, granular boundaries for dashboards and content-heavy pages where different sections have different latency profiles.
The Parallel Fetching Trap
There's a mistake we see constantly, including in code we wrote six months ago before we knew better. Sequential awaits in Server Components. Looks harmless, completely kills streaming performance:
// ❌ Bad: Sequential — total wait = 100ms + 200ms + 400ms = 700ms
async function SlowPage() {
const user = await getUser() // 100ms
const posts = await getUserPosts() // 200ms
const comments = await getComments() // 400ms
return <PageContent user={user} posts={posts} comments={comments} />
}
// ✅ Good: Parallel — total wait = max(100ms, 200ms, 400ms) = 400ms
async function FasterPage() {
const [user, posts, comments] = await Promise.all([
getUser(),
getUserPosts(),
getComments(),
])
return <PageContent user={user} posts={posts} comments={comments} />
}
// ✅ Even better: Split into components with their own Suspense
// so fast parts render while slow parts are still fetching
export default function BestPage() {
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserSection /> {/* fetches user: 100ms */}
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsSection /> {/* fetches posts: 200ms */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection /> {/* fetches comments: 400ms */}
</Suspense>
</>
)
}The third pattern is genuinely better in most cases. Not just because fetches are parallel, but because UserSection is visible at 100ms while CommentsSection is still loading. You're converting serial rendering into concurrent rendering, and the user sees progress the whole time.
Error Boundaries: Suspense's Less Glamorous Partner
Suspense handles loading states. Error boundaries handle failure states. When a streaming component throws, React looks for the nearest error boundary and renders that instead. Without one, an error in a streamed component will bubble up and potentially crash the whole page after it's already partially rendered — which is worse than a regular error page.
// app/dashboard/error.tsx — catches errors for this route segment
'use client'
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log to your error tracking service
console.error(error)
}, [error])
return (
<div className="error-state">
<h2>Something went wrong loading the dashboard</h2>
<button onClick={reset}>Try again</button>
</div>
)
}
// For more granular control, wrap individual Suspense boundaries:
// components/analytics-dashboard.tsx
import { ErrorBoundary } from 'react-error-boundary'
export function AnalyticsSection() {
return (
<ErrorBoundary fallback={<AnalyticsError />}>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsDashboard />
</Suspense>
</ErrorBoundary>
)
}Important note: Next.js error.tsx is a Client Component (has 'use client'). The error boundary pattern in React requires class components or a library like react-error-boundary for custom logic. Next.js abstracts this for route-level errors, but for component-level boundaries inside your UI, you'll want react-error-boundary.
Every Suspense boundary should have a corresponding error boundary. If you're wrapping something in Suspense and not thinking about what happens when it fails, you're setting yourself up for a bad time at 2am.
Streaming with Server Actions and Optimistic UI
Streaming plays nicely with Server Actions and the useOptimistic hook. The pattern we use most: user takes an action, optimistic update happens immediately, streamed response confirms or corrects. The UI never feels blocked.
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [isPending, startTransition] = useTransition()
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, delta: number) => state + delta
)
function handleLike() {
startTransition(async () => {
addOptimisticLike(1) // Instant UI update
await toggleLike(postId) // Actual server call
// Server response triggers revalidation, Suspense handles the rest
})
}
return (
<button onClick={handleLike} disabled={isPending}>
♥ {optimisticLikes}
</button>
)
}The transition wrapping is key. startTransition marks the server action as non-urgent — React can interrupt it if needed, and it won't block user input. The optimistic update fires synchronously, the actual network call happens in the background, and Suspense boundaries handle re-renders when revalidation kicks in.
What Not to Do: Common Streaming Mistakes
We've shipped most of these mistakes at some point, so consider this a hard-won list:
- Wrapping everything in a single giant Suspense boundary — you've basically rebuilt the old behavior. Granular boundaries are the whole point.
- Putting auth checks inside streamed components — auth should happen in middleware or at the top of the page, not inside a component that might render or might not depending on loading state.
- Suspense inside 'use client' components — Suspense for data fetching only works with Server Components. Client-side Suspense needs a different pattern (lazy loading, or SWR/React Query with suspense: true).
- Forgetting that cookies and headers aren't available after the initial render starts — read them at the top of your Server Component tree, not deep inside streamed components.
- Making skeleton screens that don't match the actual content dimensions — this causes layout shift when content streams in, which is worse than a spinner.
The layout shift one is subtle but really matters for Core Web Vitals. If your skeleton shows a 48px tall placeholder and the actual content is 200px, you'll get a CLS hit when the content streams in. Match your skeletons to your content size as closely as possible.
Measuring Whether It's Actually Helping
The metric that matters for streaming is Time to First Byte (TTFB) vs Time to Interactive (TTI). Streaming should improve your LCP (Largest Contentful Paint) if you get your Suspense boundaries right — above-the-fold content streams first, below-the-fold can wait. Check these in Chrome DevTools Network tab with 'Slow 3G' throttling and look at the waterfall. You want to see partial HTML arriving early, not one big payload at the end.
In practice: a dashboard page that was waiting 1.2 seconds for everything to load can feel like it loads in 300ms if the header and navigation stream first. The total server work is identical. Perceived performance is what users care about, and streaming is one of the highest-leverage tools you have for improving it without touching a single database query.
Most of the peal.dev templates ship with streaming patterns already set up — Suspense boundaries around data-heavy sections, matching skeleton components, error boundaries at each level. It's the kind of thing that's tedious to wire up from scratch but obvious once you see it done right. Worth studying the structure even if you're rolling your own.
The goal isn't to make everything stream — it's to identify the one or two slow things on each page and stop making your users wait for them before they see anything.
Start with your slowest route. Add Suspense around the slowest component on that page. Ship a skeleton that approximately matches the content size. Watch your LCP improve. Then do it again. Streaming isn't a one-time architectural decision — it's a habit of not letting slow data hold fast data hostage.
