Technology · Next.js

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.

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.

Progressive Enhancement

  • Load critical content first, enhance with additional data.
    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>
      );
    }
  • Show essential content immediately.
  • Load secondary content in the background.
  • Prioritize above-the-fold content with the first Suspense boundary.
    <main>
      <HeroSection /> {/* renders synchronously, no Suspense */}
      <Suspense fallback={<Skeleton />}>
        <BelowFoldContent /> {/* streams in after hero */}
      </Suspense>
    </main>
  • Deferred content improves Largest Contentful Paint score.

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

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

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

Next.js Performance Optimization