Streaming SSR: Progressive Page Rendering for Better Perceived Performance
The Problem: Three Seconds of White Screen
Opening the dashboard page shows a blank screen for three seconds. Then suddenly, all content appears at once. From a user's perspective, it's impossible to tell if the page is slow or completely broken.
Traditional SSR works like this: the server waits until all data is fetched, then sends the complete HTML in one shot. Imagine a restaurant that refuses to serve any dishes until your entire order is ready. Your pasta is done in 5 minutes, but the steak takes 15? You stare at an empty table for 20 minutes.
This all-or-nothing approach felt wrong. That's when I discovered Streaming SSR: send what's ready first, add the rest later. Serve the pasta when it's ready, bring the steak later.
The Insight: All-or-Nothing Was the Real Problem
Looking at my SSR code, the issue became obvious.
// app/dashboard/page.tsx (Before)
export default async function DashboardPage() {
const userData = await fetchUserData(); // 1 second
const analytics = await fetchAnalytics(); // 2 seconds
const notifications = await fetchNotifications(); // 3 seconds
return (
<div>
<Header user={userData} />
<Analytics data={analytics} />
<Notifications items={notifications} />
</div>
);
}
The code waits for data sequentially: 1 + 2 + 3 = 6 seconds. And only after 6 seconds does any HTML reach the browser. All-or-nothing rendering.
The bigger problem was psychological. The Header could be shown in 1 second, but we make users stare at a blank screen for 6 seconds waiting for everything else. Users don't think "this page is slow" - they think "is this page dead?"
That's when it clicked. Perceived speed is determined by when the first content appears. Showing a 100% complete page at 6 seconds feels slower than showing 30% at 1 second and progressively filling in the rest.
Streaming SSR solves exactly this. Stream ready HTML chunks to the browser immediately, wrap slow parts in React Suspense, and send them later.
Implementation: Improving Perceived Speed with Streaming
1. Creating Streaming Boundaries with Suspense
The core of Streaming SSR is React Suspense. Components inside Suspense boundaries show a fallback until ready, while the server streams the rest of the HTML first.
// app/dashboard/page.tsx (After)
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
{/* Fast parts: stream immediately */}
<Header />
{/* Slow parts: wrap in Suspense for later */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsAsync />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<NotificationsAsync />
</Suspense>
</div>
);
}
// Separate async components
async function AnalyticsAsync() {
const data = await fetchAnalytics(); // 2 seconds
return <Analytics data={data} />;
}
async function NotificationsAsync() {
const items = await fetchNotifications(); // 3 seconds
return <Notifications items={items} />;
}
Now rendering happens progressively:
- 0s: Header HTML streams to browser. User sees something immediately.
- 0s: Skeleton UI displays. Clear "loading" signal.
- 2s: Analytics HTML chunk streams in, replaces skeleton.
- 3s: Notifications HTML chunk streams in, complete.
Physical completion time is still 3 seconds, but perceived speed is 0 seconds. Because something appears immediately.
2. Automatic Streaming with Next.js loading.tsx
Next.js App Router creates automatic Suspense boundaries with loading.tsx. Extending the restaurant metaphor, it's like having an automated system for serving courses in sequence.
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-20 bg-gray-200 rounded mb-4" />
<div className="h-64 bg-gray-200 rounded mb-4" />
<div className="h-48 bg-gray-200 rounded" />
</div>
);
}
With this file, Next.js automatically wraps your page in <Suspense fallback={<Loading />}>. Simpler than manual Suspense.
3. Eliminating Waterfalls: Parallel Data Fetching
Another problem with the original code: sequential data fetching creates network waterfalls. Like waiting for pasta to finish before starting the steak.
// Bad: Sequential fetching (6 seconds)
const userData = await fetchUserData(); // 1 second
const analytics = await fetchAnalytics(); // 2 seconds
const notifications = await fetchNotifications(); // 3 seconds
Suspense makes these run in parallel automatically.
// Good: Parallel fetching (3 seconds - slowest request wins)
<Suspense fallback={<Skeleton />}>
<UserDataAsync /> {/* 1 second */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<AnalyticsAsync /> {/* 2 seconds */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<NotificationsAsync /> {/* 3 seconds */}
</Suspense>
Each component fetches data independently, so all three run simultaneously. Total time is determined by the slowest (3 seconds). Cut from 6 seconds to 3 seconds - 50% reduction.
4. Selective Hydration: Interactive While Streaming
Traditional SSR only starts hydration (running JS) after all HTML arrives. Like refusing to give diners their forks until every dish is on the table.
Streaming SSR supports selective hydration. Each HTML chunk hydrates immediately upon arrival.
// High-priority interactive component
<Suspense fallback={<Skeleton />}>
<SearchBar priority /> {/* hydrates first */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<HeavyChart /> {/* hydrates later */}
</Suspense>
When users try to click the SearchBar, it's already hydrated and responds immediately. The HeavyChart might still be hydrating, but it doesn't affect user experience.
5. TTFB vs Perceived Performance
Streaming SSR's biggest advantage is improved TTFB (Time To First Byte). Traditional SSR waits for all data, making TTFB slow.
Traditional SSR:
- TTFB: 6s (wait for all data)
- FCP: 6s
- Perceived: very slow
Streaming SSR:
- TTFB: 100ms (shell HTML sent immediately)
- FCP: 100ms (Header shows immediately)
- Perceived: very fast
Physically still 6 seconds, but psychologically feels like 100ms. Think of it like elevator feedback: traditional SSR is pressing a button with no response for 6 seconds, then the door opens. Streaming SSR lights up immediately when pressed, showing floor numbers changing - clear progress indication.
6. Edge Runtime for Faster First Chunks
Setting export const runtime = 'edge' in Next.js runs your code on Edge. Since the first HTML chunk comes from servers physically closer to users, TTFB improves.
// app/dashboard/page.tsx
export const runtime = 'edge';
export default function DashboardPage() {
return (
<Suspense fallback={<Skeleton />}>
<DashboardContent />
</Suspense>
);
}
A Seoul user hitting a US server sees 200ms TTFB. With Edge, servers near Seoul respond in 20ms.
7. When Streaming Helps and When It Doesn't
Streaming isn't always the answer. Knowing when it works matters.
Streaming helps:
- Slow data fetching (1+ seconds)
- Mixed fast/slow page sections
- Users consume content while scrolling
- Dashboards, feeds, profile pages
Streaming doesn't help:
- All data is fast (
<100ms) - Entire page is one component
- Above-the-fold depends on slow data
- Login pages, landing pages
My dashboard was clearly a good fit. The Header was fast (cached), but Analytics and Notifications were slow (database queries).
8. Measuring Real Results
Theory aside, how much did it actually improve? I measured with Lighthouse.
Before (Traditional SSR):
- TTFB: 3200ms
- FCP: 3300ms
- LCP: 3500ms
- Lighthouse Score: 65
After (Streaming SSR):
- TTFB: 120ms
- FCP: 180ms
- LCP: 2800ms (slow content is LCP element)
- Lighthouse Score: 92
TTFB improved dramatically: 3200ms → 120ms. The moment users perceive "something appeared" dropped from 3.3s to 0.18s. That's perceived speed.
But LCP is still 2.8 seconds because the largest content (Analytics chart) depends on slow data. Physical completion time didn't decrease much, but perceived speed improved dramatically.
Conclusion: Progressive Beats Perfect
Traditional SSR is a perfectionist. It shows nothing until everything is ready. Streaming SSR is a pragmatist. It shows what's ready and progressively fills in the rest.
The key insight: perceived speed is determined by when the first content appears. A 0.1-second initial render that completes over 3 seconds feels much faster than a perfect page appearing at 3 seconds.
Key takeaways:
- Wrap slow parts in React Suspense
- Use Skeleton UI to clarify loading states
- Run data fetching in parallel
- Use Edge Runtime to reduce TTFB
- Distinguish perceived performance (FCP) from actual performance (LCP)
Cases like TTFB dropping from 3.2s to 0.12s have been reported with Streaming SSR. The page doesn't get physically faster - it just feels faster. And ultimately, what users perceive is all that matters.