Skip to content

Streaming and Suspense — Progressive HTML Delivery in React

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Streaming and Suspense. We cover key concepts, practical examples, and best practices to help you master this topic.

Streaming lets the server send HTML to the browser progressively as each piece of content becomes ready, while Suspense provides fallback UI for loading states.

What You'll Learn

You will understand how streaming works in React, how to use Suspense boundaries, how to create loading fallbacks, and how streaming improves Core Web Vitals.

Why It Matters

Without streaming, the server sends the entire page only after all data is fetched. With streaming, the browser displays content sooner, improving Largest Contentful Paint and perceived performance.

Real-World Use

DodaTech's tutorial platform streams article content progressively. The navigation and sidebar render first, followed by the article body, while comments load last from a slower database query.

flowchart LR
    subgraph Server[Server]
        A[Request] --> B[Stream Start]
        B --> C[Send Shell HTML]
        C --> D[Stream Fast Section]
        D --> E[Stream Slow Section]
        E --> F[Stream Complete]
    end
    subgraph Browser[Browser]
        G[Render Shell]
        H[Show Fast Content]
        I[Show Slow Content]
    end
    C --> G
    D --> H
    E --> I
    style Server fill:#1e293b,color:#fff
    style Browser fill:#0f172a,color:#fff

How Streaming Works

When a page contains Suspense boundaries, React renders the content outside boundaries immediately. Each Suspense boundary resolves independently and streams its content as data becomes available.

import { Suspense } from 'react';

async function SlowContent() {
  await new Promise(resolve => setTimeout(resolve, 2000));
  return <p>This content arrived after 2 seconds</p>;
}

async function FastContent() {
  await new Promise(resolve => setTimeout(resolve, 500));
  return <p>This content arrived after 500ms</p>;
}

export default function Page() {
  return (
    <div>
      <h1>Streaming Demo</h1>
      <p>This renders immediately (no Suspense needed)</p>
      <Suspense fallback={<p>Loading fast content...</p>}>
        <FastContent />
      </Suspense>
      <Suspense fallback={<p>Loading slow content...</p>}>
        <SlowContent />
      </Suspense>
    </div>
  );
}

Expected output: The heading and first paragraph appear instantly. After 500ms, the fast content replaces its fallback. After 2 seconds, the slow content replaces its fallback. The page improves progressively.

Nested Suspense Boundaries

Suspense boundaries can be nested. Inner boundaries resolve before outer ones, creating a granular streaming hierarchy.

<Suspense fallback={<FullPageSkeleton />}>
  <Header />
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
</Suspense>

Expected output: The full page skeleton shows immediately. As each section resolves, its skeleton is replaced with actual content. The outer Suspense only matters if the entire page fails.

Creating Effective Fallbacks

Fallbacks should match the size and layout of the actual content to prevent layout shift. Use skeleton components for the best user experience.

function ProductCardSkeleton() {
  return (
    <div style={{ padding: '16px', border: '1px solid #eee' }}>
      <div style={{ width: '100%', height: '200px', background: '#f0f0f0' }} />
      <div style={{ width: '60%', height: '20px', background: '#f0f0f0', marginTop: '8px' }} />
      <div style={{ width: '40%', height: '20px', background: '#f0f0f0', marginTop: '4px' }} />
      <div style={{ width: '80%', height: '16px', background: '#f0f0f0', marginTop: '4px' }} />
    </div>
  );
}

async function ProductList() {
  const products = await db.products.findAll();
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}

export default function ProductsPage() {
  return (
    <Suspense fallback={<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
      {[1, 2, 3, 4, 5, 6].map(i => <ProductCardSkeleton key={i} />)}
    </div>}>
      <ProductList />
    </Suspense>
  );
}

Expected output: While products load, six skeleton cards display in a grid matching the expected layout. When data arrives, the skeletons are replaced with actual product cards. No layout shift occurs because the skeleton dimensions match the real cards.

Streaming with Client Components

Client Components inside a Suspense boundary stream their server-rendered HTML and then hydrate on the client.

'use client';
function InteractiveProductCard({ product }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <div onClick={() => setExpanded(!expanded)}>
      <h3>{product.name}</h3>
      {expanded && <p>{product.description}</p>}
    </div>
  );
}

async function ProductsPage() {
  const products = await db.products.findAll();
  return (
    <Suspense fallback={<p>Loading products...</p>}>
      <div>
        {products.map(p => <InteractiveProductCard key={p.id} product={p} />)}
      </div>
    </Suspense>
  );
}

Expected output: The HTML for all product cards streams to the browser after the database query completes. Each card then hydrates, attaching click handlers to toggle the description.

Common Mistakes

  1. Putting everything in one Suspense boundary: Use multiple granular boundaries so independent sections stream separately instead of waiting for the slowest component.

  2. Creating fallbacks that cause layout shift: Always match the fallback dimensions to the real content. Use skeleton components with the same size and structure.

  3. Nesting Suspense unnecessarily deep: Deep nesting without reason adds complexity. Use enough boundaries to isolate slow data sources but not more.

  4. Forgetting that streamed content needs hydration: Server-rendered HTML from Suspense boundaries still needs React hydration on the client for interactivity.

  5. Using Suspense with non-async content: Suspense is only needed for async operations. Static content renders immediately and does not need a boundary.

Practice Questions

  1. What problem does streaming solve?

Streaming reduces time-to-first-content by sending HTML progressively instead of waiting for the entire page to render on the server.

  1. What is the purpose of a Suspense fallback?

The fallback shows placeholder UI while the wrapped async component loads. It is replaced when the data is ready.

  1. How do you prevent layout shift with streaming?

Create skeleton fallbacks that match the dimensions of the real content. This preserves the layout while content streams in.

  1. Can multiple Suspense boundaries resolve in any order?

Yes. Each boundary resolves independently as its data becomes ready. Fast data sources display before slow ones.

  1. What happens if a Suspense boundary's component throws an error?

The error propagates to the nearest error boundary. The fallback is removed and the error UI displays.

Challenge

Build a dashboard page with four Suspense boundaries: one for a header (fast API call), one for a chart (medium DB query), one for a table (slow DB query), and one for a footer (static). Each should have a matching skeleton fallback.

Frequently Asked Questions

Does streaming work with all hosting providers?

Yes. Streaming uses standard HTTP transfer encoding (chunked) or server-sent events. Most hosting platforms including Vercel, Netlify, and Node.js servers support it.

Can I stream from edge functions?

Yes. Edge functions support streaming. However, the response must start streaming before the edge function's timeout is reached.

Does streaming affect SEO?

No. Streaming sends complete HTML to the client. Search engines receive the full content once all boundaries resolve, just like a non-streamed page.

How does streaming affect server load?

Streaming keeps the server connection open until all content is sent. Long-running queries can hold connections longer, so optimize slow queries or cache aggressively.

Can I use streaming with React 17?

No. Streaming with Suspense requires React 18 or later. React 17 does not support the streaming architecture.

Mini Project

Create a news homepage that streams three sections independently: breaking news (fast API), local news (medium API), and sports scores (slow API). Each section has a skeleton fallback matching its layout.

What's Next

Learn about Server Actions to handle mutations and form submissions directly from Server Components.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro