Next.js Streaming and Progressive Rendering

Stream content progressively with Suspense to improve perceived performance and UX.

TL;DR

  1. Use Suspense to stream content as it's ready.
  2. Show loading states while slow components render.
  3. Stream improves perceived performance and Core Web Vitals.

Suspense Boundaries

  • Use Suspense to show loading states while data loads.
    import { Suspense } from "react";
    import SlowComponent from "./SlowComponent";
    
    export default function Page() {
      return (
        <Suspense fallback={<p>Loading...</p>}>
          <SlowComponent />
        </Suspense>
      );
    }
  • Content is streamed as soon as it's ready.
  • Users see the page progressively instead of waiting.
  • Use a loading.tsx file as a route-level Suspense boundary.
    // app/dashboard/loading.tsx
    export default function Loading() {
      return <div className="skeleton">Loading dashboard...</div>;
    }
  • Suspense boundaries catch async server components automatically in App Router.
    // Any async server component inside Suspense is streamed
    async function SlowData() {
      const data = await fetchSlowAPI(); // streamed when ready
      return <ul>{data.map(d => <li key={d.id}>{d.name}</li>)}</ul>;
    }

Nested Suspense Boundaries

  • Create multiple boundaries for granular control.
    export default function Dashboard() {
      return (
        <div>
          <Header />
          <Suspense fallback={<p>Loading posts...</p>}>
            <Posts />
          </Suspense>
          <Suspense fallback={<p>Loading sidebar...</p>}>
            <Sidebar />
          </Suspense>
        </div>
      );
    }
  • Each boundary loads independently.
  • Fast components render immediately.
  • Slow components show loading states separately.
  • Nest boundaries to control exactly which sections block each other.
    <Suspense fallback={<HeaderSkeleton />}>
      <Header />
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />
      </Suspense>
    </Suspense>
  • Outer boundary shows while inner boundaries resolve independently.

Server Components with Streaming

  • Server components naturally stream their data.
    // app/page.tsx
    import { Suspense } from "react";
    import Header from "./components/Header";
    import Post from "./components/Post";
    
    export default function Home() {
      return (
        <>
          <Header />
          <Suspense fallback={<p>Loading posts...</p>}>
            <Post />
          </Suspense>
        </>
      );
    }
    
    // components/Post.tsx (server component)
    async function Post() {
      const posts = await fetch("https://api/posts").then(r => r.json());
      return posts.map(post => <article key={post.id}>{post.title}</article>);
    }
  • Server components fetch data directly.
  • Data streams as soon as it's ready.
  • Parallel data fetching inside server components speeds up streaming.
    async function Page() {
      // Both fetches start at the same time
      const [user, posts] = await Promise.all([
        fetchUser(),
        fetchPosts()
      ]);
      return <Profile user={user} posts={posts} />;
    }
  • Avoid waterfalls by fetching all needed data in parallel.

Error Boundaries While Streaming

  • Wrap a streamed Suspense boundary with the nearest error.tsx so a thrown component swaps its fallback for an error UI instead of crashing the page.
    // app/dashboard/error.tsx — catches errors thrown by streamed children
    "use client";
    export default function Error({ error, reset }: { error: Error; reset: () => void }) {
      return (
        <div>
          <p>Failed to load this section.</p>
          <button onClick={reset}>Retry</button>
        </div>
      );
    }
    
  • A component that throws inside a <Suspense> boundary already streamed to the client gets replaced in place by the nearest error boundary's UI.
    async function Reviews({ id }) {
      const reviews = await fetchReviews(id); // throws on network failure
      return <ul>{reviews.map(r => <li key={r.id}>{r.text}</li>)}</ul>;
    }
    // If Reviews throws after streaming starts, error.tsx output
    // replaces only the Reviews chunk -- sibling boundaries stay intact.
    
  • Only the failed boundary's fallback is replaced; sibling Suspense boundaries that already streamed in keep their resolved content.
  • A thrown error after the initial shell has flushed swaps in via an inline script, so streamed error recovery silently no-ops if JavaScript is disabled.
  • Use try/catch inside an async Server Component to return a fallback value instead of throwing, when inline UI is better than delegating to error.tsx.
    async function Reviews({ id }) {
      try {
        const reviews = await fetchReviews(id);
        return <ReviewList reviews={reviews} />;
      } catch {
        return <p>Reviews are unavailable right now.</p>; // no error boundary triggered
      }
    }
    
  • Place error.tsx at the same route segment as the Suspense boundary it should protect — a parent segment's error.tsx also catches throws from streamed children further down.

Prioritizing Stream Order

  • Load critical content first, then push secondary content behind its own boundary so it streams in later.
    export default function Product({ id }) {
      return (
        <div>
          <Suspense fallback={<p>Loading product...</p>}>
            <BasicProduct id={id} />
          </Suspense>
          <Suspense fallback={<p>Loading reviews...</p>}>
            <Reviews id={id} />
          </Suspense>
          <Suspense fallback={<p>Loading recommendations...</p>}>
            <Recommendations id={id} />
          </Suspense>
        </div>
      );
    }
    
  • Render above-the-fold content synchronously, outside any Suspense boundary, so it never waits on a fallback.
    <main>
      <HeroSection /> {/* renders synchronously, no Suspense */}
      <Suspense fallback={<Skeleton />}>
        <BelowFoldContent /> {/* streams in after hero */}
      </Suspense>
    </main>
    
  • React flushes resolved boundaries in the order they finish, not the order they appear in JSX, so a slow first boundary never blocks a fast later one.
  • Race a slow data source against a timeout and fall back to cached or partial data, so one flaky dependency can't stall its entire boundary.
    async function withTimeout(promise, ms, fallback) {
      const timeout = new Promise(resolve => setTimeout(() => resolve(fallback), ms));
      return Promise.race([promise, timeout]);
    }
    
  • Deferred, lower-priority sections streaming in after the hero improve Largest Contentful Paint without delaying it.

State Transition Updates

  • Keep UI responsive during state changes.
    import { useTransition } from "react";
    
    export default function Page({ id }) {
      const router = useRouter();
      const [isPending, startTransition] = useTransition();
      
      function handleNavigate() {
        startTransition(() => {
          router.push(`/product/${id}`);
        });
      }
      
      return (
        <>
          {isPending && <p>Loading...</p>}
          <button onClick={handleNavigate}>
            View Product
          </button>
        </>
      );
    }
  • useTransition keeps UI responsive during async operations.
  • Show loading state without blocking interaction.
  • Mark expensive state updates as non-urgent with startTransition.
    const [isPending, startTransition] = useTransition();
    
    function handleFilter(value) {
      setInputValue(value); // urgent: update input immediately
      startTransition(() => {
        setFilteredList(filterItems(value)); // non-urgent: can wait
      });
    }
  • useDeferredValue defers a value update to keep UI responsive.
    const deferredQuery = useDeferredValue(query);
    // deferredQuery lags behind query, preventing expensive re-renders
    return <Results query={deferredQuery} />;

Tips

  1. Use streaming to show partial content quickly — users perceive the page as faster even if all data isn't ready yet.

Warnings

  1. Don't create too many Suspense boundaries — keep them at logical boundaries to avoid confusing loading states.

FAQ