React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

suspensestreamingssrloadinghydrationttfbfcpprogressive-rendering

Suspense & Streaming Performance — Granular loading boundaries for faster perceived performance

Recipe

// app/dashboard/page.tsx — Granular Suspense boundaries per section
import { Suspense } from "react";
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      {/* Each section streams independently */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
 
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
 
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
 
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}
 
// Each async Server Component fetches its own data
async function StatsPanel() {
  const stats = await fetchStats(); // 50ms
  return <div>{/* render stats */}</div>;
}
 
async function RevenueChart() {
  const data = await fetchChartData(); // 200ms
  return <div>{/* render chart */}</div>;
}
 
async function RecentOrders() {
  const orders = await fetchOrders(); // 150ms
  return <div>{/* render orders */}</div>;
}
 
async function ActivityFeed() {
  const feed = await fetchActivityFeed(); // 300ms
  return <div>{/* render feed */}</div>;
}

When to reach for this: For any page with multiple data sources or sections that load at different speeds. Granular Suspense boundaries let fast sections appear immediately while slow sections show skeletons.

Working Example

// ---- BEFORE: Single top-level Suspense — 1200ms blank screen ----
 
// app/dashboard/page.tsx
import { Suspense } from "react";
 
export default function DashboardPage() {
  return (
    // ANTI-PATTERN: One giant boundary — nothing shows until ALL data loads
    <Suspense fallback={<FullPageLoader />}>
      <DashboardContent />
    </Suspense>
  );
}
 
// This component awaits ALL data before rendering anything
async function DashboardContent() {
  // These run sequentially (waterfall!) — total: 50+200+150+300+500 = 1200ms
  const stats = await fetchStats();              // 50ms
  const chart = await fetchChartData();          // 200ms
  const orders = await fetchOrders();            // 150ms
  const feed = await fetchActivityFeed();        // 300ms
  const recommendations = await fetchRecommendations(); // 500ms
 
  return (
    <div className="grid grid-cols-2 gap-6">
      <StatsPanel data={stats} />
      <RevenueChart data={chart} />
      <OrderTable data={orders} />
      <ActivityFeed data={feed} />
      <Recommendations data={recommendations} />
    </div>
  );
}
 
function FullPageLoader() {
  return (
    <div className="flex items-center justify-center h-screen">
      <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent" />
    </div>
  );
}
 
// ---- AFTER: Granular Suspense + parallel fetching — 50ms first paint, 500ms full ----
 
// app/dashboard/page.tsx
import { Suspense } from "react";
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      {/* Priority: Stats appear first (50ms) */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
 
      {/* Chart section (200ms) */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
 
      {/* Orders table (150ms) */}
      <Suspense fallback={<TableSkeleton />}>
        <OrderTable />
      </Suspense>
 
      {/* Activity feed (300ms) */}
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
 
      {/* Low priority: Recommendations (500ms) — nested Suspense */}
      <div className="col-span-2">
        <Suspense fallback={<RecommendationsSkeleton />}>
          <Recommendations />
        </Suspense>
      </div>
    </div>
  );
}
 
// Each component fetches its own data independently
async function StatsPanel() {
  const stats = await fetchStats(); // 50ms — first to stream
 
  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="p-4 bg-white rounded-lg shadow">
        <p className="text-sm text-gray-500">Revenue</p>
        <p className="text-2xl font-bold">${stats.revenue.toLocaleString()}</p>
      </div>
      <div className="p-4 bg-white rounded-lg shadow">
        <p className="text-sm text-gray-500">Orders</p>
        <p className="text-2xl font-bold">{stats.orders}</p>
      </div>
      <div className="p-4 bg-white rounded-lg shadow">
        <p className="text-sm text-gray-500">Customers</p>
        <p className="text-2xl font-bold">{stats.customers}</p>
      </div>
    </div>
  );
}
 
async function RevenueChart() {
  const data = await fetchChartData(); // 200ms
  return (
    <div className="bg-white p-4 rounded-lg shadow">
      <h3 className="font-semibold mb-2">Revenue Over Time</h3>
      {/* Chart component renders here */}
      <div className="h-64">{/* ... */}</div>
    </div>
  );
}
 
async function OrderTable() {
  const orders = await fetchOrders(); // 150ms
  return (
    <div className="bg-white rounded-lg shadow overflow-hidden">
      <h3 className="font-semibold p-4 border-b">Recent Orders</h3>
      <table className="w-full">
        <tbody>
          {orders.map((order) => (
            <tr key={order.id} className="border-b last:border-0">
              <td className="p-3">{order.customer}</td>
              <td className="p-3">${order.total}</td>
              <td className="p-3 text-gray-400">{order.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
 
async function ActivityFeed() {
  const feed = await fetchActivityFeed(); // 300ms
  return (
    <div className="bg-white rounded-lg shadow p-4">
      <h3 className="font-semibold mb-2">Activity</h3>
      <ul className="space-y-2">
        {feed.map((item) => (
          <li key={item.id} className="text-sm">
            <span className="font-medium">{item.user}</span> {item.action}
          </li>
        ))}
      </ul>
    </div>
  );
}
 
async function Recommendations() {
  const recs = await fetchRecommendations(); // 500ms — slowest, loads last
  return (
    <div className="bg-white rounded-lg shadow p-4">
      <h3 className="font-semibold mb-2">Recommended Actions</h3>
      <div className="grid grid-cols-3 gap-4">
        {recs.map((rec) => (
          <div key={rec.id} className="p-3 bg-blue-50 rounded">
            <p className="font-medium">{rec.title}</p>
            <p className="text-sm text-gray-600">{rec.description}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
 
// Skeleton components for loading states
function StatsSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[...Array(3)].map((_, i) => (
        <div key={i} className="p-4 bg-white rounded-lg shadow animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-16 mb-2" />
          <div className="h-8 bg-gray-200 rounded w-24" />
        </div>
      ))}
    </div>
  );
}
 
function ChartSkeleton() {
  return <div className="bg-white p-4 rounded-lg shadow h-72 animate-pulse" />;
}
 
function TableSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow p-4 animate-pulse">
      {[...Array(5)].map((_, i) => (
        <div key={i} className="h-8 bg-gray-200 rounded mb-2" />
      ))}
    </div>
  );
}
 
function FeedSkeleton() {
  return (
    <div className="bg-white rounded-lg shadow p-4 animate-pulse">
      {[...Array(4)].map((_, i) => (
        <div key={i} className="h-6 bg-gray-200 rounded mb-2" />
      ))}
    </div>
  );
}
 
function RecommendationsSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[...Array(3)].map((_, i) => (
        <div key={i} className="h-24 bg-gray-100 rounded animate-pulse" />
      ))}
    </div>
  );
}

What this demonstrates:

  • Before: Single Suspense boundary with sequential fetches = 1200ms blank screen
  • After: 5 independent Suspense boundaries with parallel fetching
  • Stats appear at 50ms, orders at 150ms, chart at 200ms, feed at 300ms, recommendations at 500ms
  • User sees meaningful content in 50ms instead of 1200ms (96% improvement in first paint)
  • Each section streams independently — fast sections do not wait for slow ones

Deep Dive

How It Works

  • Streaming SSR — Next.js sends the initial HTML shell immediately, then streams each Suspense boundary's content as it resolves. The browser progressively renders content without waiting for the entire page.
  • Selective hydration — React hydrates each Suspense boundary independently. If the user interacts with a section that is already streamed, React prioritizes hydrating that section first.
  • Parallel data fetching — Each async Server Component inside a separate Suspense boundary fetches data independently. Unlike sequential await calls, these run in parallel because React starts rendering all siblings concurrently.
  • loading.tsx — Next.js automatically wraps the page content in a Suspense boundary with loading.tsx as the fallback. This provides route-level loading without manual Suspense.
  • Nested Suspense — Suspense boundaries can nest. An outer boundary shows its fallback until the outer async component resolves, then an inner boundary shows its own fallback for inner async components. This creates progressive disclosure.

Variations

loading.tsx for route-level Suspense:

// app/dashboard/loading.tsx — automatic Suspense boundary for the route
export default function DashboardLoading() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <StatsSkeleton />
      <ChartSkeleton />
      <TableSkeleton />
      <FeedSkeleton />
    </div>
  );
}
// No need for manual Suspense in page.tsx — loading.tsx wraps it automatically

Nested Suspense for progressive disclosure:

async function OrderSection() {
  const summary = await fetchOrderSummary(); // 100ms — fast
 
  return (
    <div>
      <h2>Orders: {summary.total}</h2>
      {/* Inner boundary shows skeleton while details load */}
      <Suspense fallback={<DetailsSkeleton />}>
        <OrderDetails /> {/* 400ms — slow */}
      </Suspense>
    </div>
  );
}

Streaming with error boundaries:

import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
 
function DashboardSection({ children, fallback, errorFallback }) {
  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={fallback}>{children}</Suspense>
    </ErrorBoundary>
  );
}
 
// Each section handles its own errors without breaking the page
export default function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <DashboardSection
        fallback={<StatsSkeleton />}
        errorFallback={<p>Failed to load stats</p>}
      >
        <StatsPanel />
      </DashboardSection>
      <DashboardSection
        fallback={<ChartSkeleton />}
        errorFallback={<p>Failed to load chart</p>}
      >
        <RevenueChart />
      </DashboardSection>
    </div>
  );
}

TypeScript Notes

  • Suspense accepts fallback: React.ReactNode and children: React.ReactNode.
  • Async Server Components return Promise<JSX.Element> — TypeScript handles this automatically.
  • loading.tsx must export a default component (not async).

Gotchas

  • Single top-level Suspense boundary — Wrapping the entire page in one Suspense boundary means nothing renders until all data loads. This is the same user experience as a loading spinner. Fix: Use granular Suspense boundaries per section so fast sections appear immediately.

  • Sequential fetches inside one componentawait fetchA(); await fetchB(); creates a waterfall even with Suspense. Fix: Either use Promise.all([fetchA(), fetchB()]) or split into separate async components each with their own Suspense boundary.

  • Skeleton layout mismatch — If the skeleton's height does not match the loaded content, the page shifts when content streams in, causing CLS. Fix: Design skeletons that closely match the final content dimensions. Use fixed heights or aspect ratios.

  • Over-granular Suspense boundaries — Wrapping every single component in Suspense creates a "popcorn" loading effect where dozens of tiny sections pop in at different times. Fix: Group related content into logical sections, each with one Suspense boundary.

  • Missing error boundaries — Without an error boundary around each Suspense boundary, one failed section crashes the entire page. Fix: Wrap each Suspense in an ErrorBoundary so failed sections show an error message while the rest of the page continues working.

  • Streaming disabled by dynamic cookies/headers — Reading cookies or headers in a layout opts the entire subtree out of static rendering and may affect streaming behavior. Fix: Move cookies() and headers() calls into the specific Server Components that need them.

Alternatives

ApproachTrade-off
Granular Suspense boundariesBest streaming UX; requires skeleton design per section
Single loading.tsxSimple; blocks entire route until the page component resolves
Client-side fetching (SWR or TanStack Query)Instant navigation; shows loading states, more client JS
Static generation (SSG)Zero loading time; data may be stale
Incremental Static Regeneration (ISR)Cached static pages with periodic updates; stale-while-revalidate
Partial Prerendering (PPR)Static shell + streaming dynamic parts; experimental in Next.js

FAQs

What is Suspense streaming and how does it improve perceived performance?

Suspense streaming sends the initial HTML shell immediately, then streams each Suspense boundary's content as it resolves. The browser progressively renders content without waiting for the entire page. Fast sections appear first while slow sections show skeletons.

Why should you use granular Suspense boundaries instead of one per page?

A single top-level Suspense boundary means nothing renders until all data loads -- the same UX as a full-page spinner. Granular boundaries let each section stream independently, so a 50ms fetch displays immediately while a 500ms fetch still loads.

How do async Server Components inside separate Suspense boundaries fetch data in parallel?

React starts rendering all sibling components concurrently. Each async Server Component inside its own Suspense boundary fetches data independently. Unlike sequential await calls, these run in parallel automatically.

What is loading.tsx and how does it relate to Suspense?

loading.tsx in a Next.js route directory automatically wraps the page content in a Suspense boundary using the exported component as the fallback. It provides route-level loading without manual Suspense.

What is nested Suspense and when should you use it?

Suspense boundaries can nest. The outer boundary shows its fallback until the outer async component resolves, then inner boundaries show their own fallbacks. Use for progressive disclosure -- show a summary quickly, then load details.

async function OrderSection() {
  const summary = await fetchOrderSummary(); // 100ms
  return (
    <div>
      <h2>Orders: {summary.total}</h2>
      <Suspense fallback={<DetailsSkeleton />}>
        <OrderDetails /> {/* 400ms */}
      </Suspense>
    </div>
  );
}
Gotcha: How do sequential awaits inside one component create a waterfall even with Suspense?

await fetchA(); await fetchB(); runs sequentially regardless of Suspense boundaries. Total time is the sum, not the maximum.

Fix: Use Promise.all([fetchA(), fetchB()]) or split into separate async components each with their own Suspense boundary.

Gotcha: How can skeleton layout mismatches cause CLS problems?

If the skeleton's height does not match the loaded content, the page shifts when content streams in, causing Cumulative Layout Shift. Design skeletons that closely match the final content dimensions using fixed heights or aspect ratios.

Why should you wrap each Suspense boundary with an ErrorBoundary?

Without an error boundary, one failed section crashes the entire page. With error boundaries, failed sections show an error message while the rest of the page continues working normally.

What does "selective hydration" mean in the context of streaming?

React hydrates each Suspense boundary independently. If the user interacts with a section that is already streamed, React prioritizes hydrating that section first, making it interactive faster.

Can reading cookies or headers affect streaming behavior?

Yes. Reading cookies() or headers() in a layout opts the entire subtree out of static rendering and may affect streaming behavior. Move these calls into the specific Server Components that need them.

What is the TypeScript type for the loading.tsx export?

loading.tsx must export a default component (not async). Suspense accepts fallback: React.ReactNode and children: React.ReactNode. Async Server Components return Promise<JSX.Element> which TypeScript handles automatically.

What is the "popcorn" loading effect and how do you avoid it?

Over-granular Suspense boundaries cause dozens of tiny sections to pop in at different times. Group related content into logical sections, each with one Suspense boundary, to create a smoother loading experience.