Skip to content

Streaming & Suspense

Progressive loading for better user experience.

The Problem

Traditional page rendering waits for all data before showing anything:

┌──────────────────────────────────────┐
│ Request                              │
├──────────────────────────────────────┤
│ Wait for ALL data... (3 seconds)     │
├──────────────────────────────────────┤
│ Render everything at once            │
└──────────────────────────────────────┘

With streaming, show content as it becomes available:

┌──────────────────────────────────────┐
│ Request                              │
├──────────────────────────────────────┤
│ Send shell immediately               │
│ Stream content 1 (fast)      ────►   │
│ Stream content 2 (medium)    ────►   │
│ Stream content 3 (slow)      ────►   │
└──────────────────────────────────────┘

React Suspense

Basic Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function SlowComponent() {
  const data = await fetchSlowData(); // Takes 3 seconds
  return <div>{data.content}</div>;
}

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Show immediately */}
      <QuickStats />

      {/* Stream when ready */}
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

Multiple Suspense Boundaries

export default function Dashboard() {
  return (
    <div className="dashboard">
      <h1>Dashboard</h1>

      <div className="grid">
        {/* Each loads independently */}
        <Suspense fallback={<CardSkeleton />}>
          <RevenueCard />
        </Suspense>

        <Suspense fallback={<CardSkeleton />}>
          <UsersCard />
        </Suspense>

        <Suspense fallback={<TableSkeleton />}>
          <RecentOrders />
        </Suspense>

        <Suspense fallback={<ChartSkeleton />}>
          <AnalyticsChart />
        </Suspense>
      </div>
    </div>
  );
}

Nested Suspense

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* Product info streams first */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductInfo id={params.id} />

        {/* Reviews stream after product */}
        <Suspense fallback={<ReviewsSkeleton />}>
          <ProductReviews id={params.id} />
        </Suspense>
      </Suspense>
    </div>
  );
}

Loading States

Loading.tsx (Route-Level)

// app/posts/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="space-y-3">
        <div className="h-4 bg-gray-200 rounded" />
        <div className="h-4 bg-gray-200 rounded w-5/6" />
        <div className="h-4 bg-gray-200 rounded w-4/6" />
      </div>
    </div>
  );
}

Custom Skeleton Components

// components/skeletons.tsx
export function CardSkeleton() {
  return (
    <div className="card animate-pulse">
      <div className="h-6 bg-gray-200 rounded w-1/2 mb-2" />
      <div className="h-8 bg-gray-200 rounded w-3/4" />
    </div>
  );
}

export function TableSkeleton({ rows = 5 }: { rows?: number }) {
  return (
    <div className="space-y-2">
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="flex gap-4 animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-1/4" />
          <div className="h-4 bg-gray-200 rounded w-1/3" />
          <div className="h-4 bg-gray-200 rounded w-1/4" />
        </div>
      ))}
    </div>
  );
}

Streaming Data

Server Component with Async Data

// components/RecentOrders.tsx
async function RecentOrders() {
  // This fetch happens on the server
  const orders = await fetch('https://api.example.com/orders', {
    next: { revalidate: 60 }, // Cache for 60 seconds
  }).then(res => res.json());

  return (
    <table>
      <thead>
        <tr>
          <th>Order</th>
          <th>Customer</th>
          <th>Total</th>
        </tr>
      </thead>
      <tbody>
        {orders.map((order) => (
          <tr key={order.id}>
            <td>{order.id}</td>
            <td>{order.customer}</td>
            <td>${order.total}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Parallel Data Fetching

async function Dashboard() {
  // These run in parallel
  const [revenue, users, orders] = await Promise.all([
    fetchRevenue(),
    fetchUsers(),
    fetchOrders(),
  ]);

  return (
    <div>
      <RevenueCard data={revenue} />
      <UsersCard data={users} />
      <OrdersTable data={orders} />
    </div>
  );
}

Sequential with Streaming

async function ProductPage({ id }: { id: string }) {
  // Fetch product first (needed for reviews query)
  const product = await fetchProduct(id);

  return (
    <div>
      <ProductInfo product={product} />

      {/* Reviews stream separately, depend on product */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={product.id} />
      </Suspense>
    </div>
  );
}

Error Handling

Error Boundaries

// app/dashboard/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="error-container">
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Component-Level Error Handling

import { ErrorBoundary } from 'react-error-boundary';

export default function Dashboard() {
  return (
    <div>
      <ErrorBoundary fallback={<div>Failed to load revenue</div>}>
        <Suspense fallback={<CardSkeleton />}>
          <RevenueCard />
        </Suspense>
      </ErrorBoundary>

      <ErrorBoundary fallback={<div>Failed to load users</div>}>
        <Suspense fallback={<CardSkeleton />}>
          <UsersCard />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Streaming with useTransition

'use client';

import { useState, useTransition } from 'react';

export function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value);

    startTransition(async () => {
      const data = await searchPosts(value);
      setResults(data);
    });
  }

  return (
    <div>
      <input
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
      />

      {isPending ? (
        <div className="opacity-50">Searching...</div>
      ) : (
        <ul>
          {results.map(result => (
            <li key={result.id}>{result.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

useDeferredValue

'use client';

import { useState, useDeferredValue, memo } from 'react';

const ExpensiveList = memo(function ExpensiveList({ query }: { query: string }) {
  // Expensive filtering/rendering
  const items = filterItems(allItems, query);
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});

export function Search() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />

      {/* Input stays responsive, list updates are deferred */}
      <ExpensiveList query={deferredQuery} />
    </div>
  );
}

Partial Prerendering (PPR)

Next.js experimental feature for combining static and dynamic content:

// next.config.js
module.exports = {
  experimental: {
    ppr: true,
  },
};

// app/page.tsx
export default function Page() {
  return (
    <main>
      {/* Static shell */}
      <Header />
      <StaticContent />

      {/* Dynamic content streams in */}
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>

      <Suspense fallback={<FeedSkeleton />}>
        <PersonalizedFeed />
      </Suspense>
    </main>
  );
}

Best Practices

Practice Benefit
Wrap slow components in Suspense Progressive loading
Use meaningful skeletons Better perceived performance
Fetch in parallel when possible Faster total load
Handle errors at boundaries Graceful degradation
Use useTransition for updates Responsive UI
Place Suspense close to data Granular loading states

Anti-Patterns

// ❌ Bad: One Suspense for everything
<Suspense fallback={<Loading />}>
  <SlowComponent1 />
  <SlowComponent2 />
  <SlowComponent3 />
</Suspense>

// ✅ Good: Independent Suspense boundaries
<Suspense fallback={<Skeleton1 />}>
  <SlowComponent1 />
</Suspense>
<Suspense fallback={<Skeleton2 />}>
  <SlowComponent2 />
</Suspense>
<Suspense fallback={<Skeleton3 />}>
  <SlowComponent3 />
</Suspense>

// ❌ Bad: Waterfall fetches
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);

// ✅ Good: Parallel where possible
const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts(),
]);