Next.js Streaming and Progressive Rendering

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

TL;DR

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

Tips

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

Warnings

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

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} />;

FAQ