Skip to main content

Streaming HTML and Progressive Enhancement with Suspense

Suspense streaming is the killer feature that makes Suspense valuable for server-side rendering. Instead of waiting for all data to arrive before sending HTML, React sends partial HTML progressively: the page shell and critical fallbacks first, then filled-in sections as data resolves on the server. This dramatically improves perceived performance and Core Web Vitals metrics.

Streaming works best with Next.js Server Components, but the principles apply to any renderToReadableStream setup (Node.js servers, edge runtimes, etc.).

How Streaming Works with Suspense

Traditional server-side rendering (SSR) requires all data to be fetched and processed before HTML is sent:

1. Server starts rendering
2. Server fetches all data (wait for slowest request)
3. Server completes HTML
4. Browser receives complete HTML
5. Browser parses and paints

If one fetch takes 2 seconds, the entire page waits. With Suspense streaming:

1. Server starts rendering
2. Server sends shell HTML + fallbacks immediately
3. Browser receives and paints the shell (with skeletons)
4. Browser becomes interactive while data fetches
5. Server sends completed sections as data arrives (in-stream HTML chunks)
6. Browser updates each section without re-rendering (seamless)

Users see content much faster because the browser can paint and begin interacting long before all data arrives.

Next.js Server Components and Streaming

In Next.js 14+, streaming is automatic with Server Components. Here's the pattern:

// app/dashboard/page.js (Server Component)
import { Suspense } from 'react';
import { fetchUserData, fetchStats, fetchPosts } from '@/lib/data';

async function UserSection() {
const user = await fetchUserData();
return (
<section>
<h2>{user.name}'s Dashboard</h2>
</section>
);
}

async function StatsSection() {
const stats = await fetchStats();
return (
<aside>
<h3>Stats</h3>
<p>Views: {stats.views}</p>
</aside>
);
}

async function PostsSection() {
const posts = await fetchPosts();
return (
<section>
<h3>Recent Posts</h3>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</section>
);
}

export default function Dashboard() {
return (
<main>
<Suspense fallback={<div className="skeleton">Loading user...</div>}>
<UserSection />
</Suspense>

<Suspense fallback={<div className="skeleton">Loading stats...</div>}>
<StatsSection />
</Suspense>

<Suspense fallback={<div className="skeleton">Loading posts...</div>}>
<PostsSection />
</Suspense>
</main>
);
}

Next.js automatically:

  1. Sends the layout shell HTML with fallbacks.
  2. Streams each <Suspense> boundary's content as its async operation completes.
  3. The browser displays the skeleton, then replaces it with real content.

No client-side JavaScript is needed to coordinate this. It happens at the HTTP level via streaming.

Progressive Enhancement: Skeleton to Content

A well-designed skeleton improves the perceived loading experience. The skeleton should match the shape and dimensions of the final content:

// app/post/[id]/page.js
import { Suspense } from 'react';
import { fetchPost } from '@/lib/data';

function PostSkeleton() {
return (
<article className="post-container">
<div className="skeleton-title" style={{ height: '2rem', background: '#e0e0e0', marginBottom: '1rem' }} />
<div className="skeleton-body">
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton-line" style={{ height: '1rem', background: '#e0e0e0', marginBottom: '0.5rem' }} />
))}
</div>
</article>
);
}

async function PostContent({ id }) {
const post = await fetchPost(id);
return (
<article className="post-container">
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}

export default function PostPage({ params }) {
return (
<Suspense fallback={<PostSkeleton />}>
<PostContent id={params.id} />
</Suspense>
);
}

When the page loads:

  1. Browser renders the skeleton (dimensions match the final article).
  2. User sees a placeholder immediately (no blank screen).
  3. As server fetches data, it streams the real <PostContent>.
  4. Browser replaces the skeleton with real content without layout shift (because dimensions match).

This is progressive enhancement: show a placeholder fast, then the real thing.

Cascading Suspense Boundaries for Content Priority

Order your boundaries by importance. Critical content (header) should appear first, lower-priority content (ads, related items) later:

// app/news/[id]/page.js
import { Suspense } from 'react';
import { fetchArticle, fetchComments, fetchRelated } from '@/lib/data';

async function ArticleHeader() {
const article = await fetchArticle();
return <header><h1>{article.title}</h1></header>;
}

async function ArticleBody() {
const article = await fetchArticle();
return <main>{article.body}</main>;
}

async function CommentSection() {
const comments = await fetchComments();
return (
<section>
<h2>Comments ({comments.length})</h2>
{/* render comments */}
</section>
);
}

async function RelatedArticles() {
const related = await fetchRelated();
return (
<aside>
<h3>Related</h3>
{/* render related */}
</aside>
);
}

export default function NewsPage({ params }) {
return (
<article>
{/* Critical: header and body load first */}
<Suspense fallback={<div>Loading article...</div>}>
<ArticleHeader />
<ArticleBody />
</Suspense>

{/* Secondary: comments load next */}
<Suspense fallback={<div>Loading comments...</div>}>
<CommentSection />
</Suspense>

{/* Tertiary: related content loads last */}
<Suspense fallback={<div>Loading related...</div>}>
<RelatedArticles />
</Suspense>
</article>
);
}

Streaming sends content in priority order, allowing users to read the main article while comments load.

Client-Side Streaming with renderToReadableStream

For Node.js apps or custom setups, use renderToReadableStream:

// server.js (Node.js/Express)
import { renderToReadableStream } from 'react-dom/server';
import App from './App';

app.get('/', async (req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');

const stream = await renderToReadableStream(<App />, {
onError(error) {
console.error(error);
res.statusCode = 500;
res.send('Server error');
},
});

// Pipe the readable stream to the response
stream.pipe(res);
});

The stream automatically sends Suspense boundaries as they resolve. The browser receives partial HTML and can begin rendering before the full page is ready.

Avoiding Waterfall Patterns in Streaming

Even with streaming, waterfalls can occur if you fetch data inside a component that's inside a Suspense boundary:

// Bad: forces sequential loading
async function Dashboard() {
const user = await fetchUser(); // Waits for user first

return (
<Suspense>
<Stats userId={user.id} /> {/* Only starts after user is fetched */}
</Suspense>
);
}

// Good: fetch in parallel
async function Dashboard() {
// Both fetches start immediately
const userPromise = fetchUser();
const statsPromise = fetchStats();

return (
<>
<Suspense>
<UserSection userPromise={userPromise} />
</Suspense>
<Suspense>
<StatsSection statsPromise={statsPromise} />
</Suspense>
</>
);
}

Initiate all fetches upfront, then wrap components with Suspense boundaries. This enables true parallel streaming.

Core Web Vitals Improvements with Streaming

Streaming improves three Core Web Vitals:

LCP (Largest Contentful Paint): The first paint of meaningful content happens earlier because the skeleton is sent immediately, not waiting for all data.

CLS (Cumulative Layout Shift): Skeletons that match final dimensions prevent layout shift when real content replaces them.

TTFB (Time to First Byte): Server sends HTML immediately (the shell), reducing perceived wait time.

Benchmark improvement (typical app): LCP 3.2s → 1.8s (44% faster), CLS 0.15 → 0.02 (87% better).

Handling Errors in Streaming

Errors in streaming Server Components are trickier because HTML is already sent. Use Error Boundary:

// app/dashboard/page.js
'use client';

import { Suspense } from 'react';
import ErrorBoundary from '@/components/ErrorBoundary';

async function Content() {
// If this throws, Error Boundary catches it
const data = await fetchData();
return <div>{data.value}</div>;
}

export default function Page() {
return (
<ErrorBoundary fallback={<div>Error loading content</div>}>
<Suspense fallback={<div>Loading...</div>}>
<Content />
</Suspense>
</ErrorBoundary>
);
}

If the async function throws after HTML is streamed, the error is caught and an error message is sent to the browser (it replaces the skeleton).

Real-World Example: E-Commerce Product Page

Here's a typical e-commerce pattern using streaming:

// app/products/[id]/page.js
import { Suspense } from 'react';
import { fetchProduct, fetchReviews, fetchRecommendations } from '@/lib/data';

async function ProductInfo({ id }) {
const product = await fetchProduct(id);
return (
<section>
<h1>{product.name}</h1>
<p>${product.price}</p>
<button>Add to Cart</button>
</section>
);
}

async function ProductReviews({ id }) {
const reviews = await fetchReviews(id);
return (
<section>
<h2>Reviews ({reviews.length})</h2>
{/* render reviews */}
</section>
);
}

async function RecommendedProducts({ id }) {
const recommended = await fetchRecommendations(id);
return (
<section>
<h2>You Might Also Like</h2>
{/* render recommendations */}
</section>
);
}

export default function ProductPage({ params }) {
return (
<main>
{/* Critical: product info first (user needs to see price and buy button) */}
<Suspense fallback={<div>Loading product...</div>}>
<ProductInfo id={params.id} />
</Suspense>

{/* Secondary: reviews next */}
<Suspense fallback={<div>Loading reviews...</div>}>
<ProductReviews id={params.id} />
</Suspense>

{/* Tertiary: recommendations last (engagement content) */}
<Suspense fallback={<div>Loading recommendations...</div>}>
<RecommendedProducts id={params.id} />
</Suspense>
</main>
);
}

Streaming order: Product Info → Reviews → Recommendations. Users see the buy button within 500ms (critical), reviews load while they read, and recommendations appear last (non-critical).

Key Takeaways

  • Streaming sends HTML progressively: Shell and fallbacks first, content as data arrives.
  • Skeletons must match final dimensions: Prevents layout shift; improves perceived performance.
  • Priority ordering matters: Critical content (header, product info) should stream first.
  • Initiate all fetches upfront: Avoid waterfalls by starting all async operations at the component level.
  • Core Web Vitals improve: LCP, CLS, and TTFB all improve with streaming and skeletons.

Frequently Asked Questions

Does streaming work in older browsers?

Yes. Streaming is HTTP chunked encoding, supported since IE 7. Modern browsers display content as chunks arrive (streaming), older browsers buffer and display once complete.

Can I control which sections stream first?

Component order determines stream order. Render critical components first, lower-priority ones later. Boundaries are resolved in top-to-bottom order.

What if a Suspense boundary never resolves?

The browser will show the fallback indefinitely. Use a timeout or error boundary to handle hung requests:

async function WithTimeout() {
try {
return await Promise.race([
fetchData(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000))
]);
} catch (error) {
return <div>Error: {error.message}</div>;
}
}

Can I combine streaming with ISR (Incremental Static Regeneration)?

Yes. Next.js supports both: statically generate the shell, then stream dynamic sections. This combines caching benefits with fresh data.

Does streaming add server load?

Slightly, because the server keeps connections open longer. However, users perceive the app as faster, so the tradeoff is usually worth it.

Further Reading