Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
18 minLesson 21 of 33
Server & Client Components

Streaming & Suspense Boundaries

Streaming & Suspense Boundaries

Streaming is how Next.js sends HTML to the browser progressively — the page structure arrives immediately, and content fills in piece by piece as data becomes available. No more waiting for the slowest database query before showing anything.

Why Streaming Exists

Traditional SSR is all-or-nothing: the server waits until all data is ready, renders the full HTML, and sends it. The user sees a blank screen while waiting.

Traditional SSR:
Request ──── [wait for all data] ──── Full HTML ──── User sees page
              200ms + 800ms = 1000ms

Streaming SSR:
Request ──── Shell ──── [Suspense 1 resolves] ──── [Suspense 2 resolves]
              50ms         200ms                       800ms

With streaming, the user sees the page structure at 50ms. Content fills in as each data source resolves. The total time is the same, but the perceived experience is dramatically faster.

The Technical Mechanism

When React encounters a Suspense boundary wrapping an async component:

  1. It renders a placeholder (the fallback) and flushes that HTML to the browser immediately
  2. React continues rendering other parts of the tree
  3. When the suspended component resolves, React sends an HTML chunk + a tiny inline <script> that replaces the fallback in-place
  4. No additional requests from the browser — it's all one HTTP response that stays open
HTTP/1.1 200 OK
Transfer-Encoding: chunked

<!-- Chunk 1 (immediate): shell + skeleton -->
<!DOCTYPE html><html><body>
<div id="header">AiTechWorlds</div>
<div id="suspense-1"><div class="skeleton animate-pulse">...</div></div>
<div id="suspense-2"><div class="skeleton animate-pulse">...</div></div>
</body>

<!-- Chunk 2 (after 200ms): first Suspense resolves -->
<div hidden id="suspense-1-content">
    <div>Course title: React Complete</div>
</div>
<script>
    // Swap skeleton for real content
    document.getElementById("suspense-1").replaceWith(
        document.getElementById("suspense-1-content")
    )
</script>

<!-- Chunk 3 (after 800ms): second Suspense resolves -->
...

Implementation Patterns

Pattern 1: The Wrapper Pattern

Create a Server Component that fetches and renders data, wrap it in Suspense:

// src/app/courses/[slug]/page.tsx

export default function CoursePage({ params }: { params: { slug: string } }) {
    return (
        <article>
            {/* Renders immediately — no data needed */}
            <Breadcrumbs slug={params.slug} />
            
            {/* Streams in when course data is ready */}
            <Suspense fallback={<CourseHeroSkeleton />}>
                <CourseHero slug={params.slug} />
            </Suspense>
            
            {/* Streams in independently */}
            <Suspense fallback={<LessonListSkeleton />}>
                <LessonList slug={params.slug} />
            </Suspense>
            
            {/* Lowest priority — loads last */}
            <Suspense fallback={<ReviewsSkeleton />}>
                <ReviewSection slug={params.slug} />
            </Suspense>
        </article>
    );
}

// Each component is responsible for its own data
async function CourseHero({ slug }: { slug: string }) {
    const course = await db.course.findUnique({ where: { slug } });
    if (!course) notFound();
    return (
        <div>
            <h1 className="text-4xl font-bold">{course.title}</h1>
            <p className="text-gray-600 mt-3">{course.description}</p>
        </div>
    );
}

async function ReviewSection({ slug }: { slug: string }) {
    // Deliberately slow — simulating an external review service
    const reviews = await fetchExternalReviews(slug);   // 800ms
    return <ReviewList reviews={reviews} />;
}

Pattern 2: Promise Props (Streaming Data)

In Next.js 15, you can pass promises as props — the child unwraps them:

// src/app/courses/page.tsx
export default function CoursesPage() {
    // Start the fetch immediately, don't await
    const coursesPromise = fetchCourses();
    const featuredPromise = fetchFeaturedCourse();
    
    return (
        <div>
            <Suspense fallback={<FeaturedSkeleton />}>
                <FeaturedCourse promise={featuredPromise} />
            </Suspense>
            
            <Suspense fallback={<CourseGridSkeleton />}>
                <CourseGrid promise={coursesPromise} />
            </Suspense>
        </div>
    );
}

// Both fetches start in parallel, each resolves independently
async function CourseGrid({ promise }: { promise: Promise<Course[]> }) {
    const courses = await promise;   // React suspends here until resolved
    return (
        <div className="grid grid-cols-3 gap-6">
            {courses.map(c => <CourseCard key={c.id} course={c} />)}
        </div>
    );
}

Pattern 3: use() Hook (React 19)

"use client";

import { use } from "react";

// Client Component can unwrap a server-passed promise with use()
function CourseTitle({ titlePromise }: { titlePromise: Promise<string> }) {
    const title = use(titlePromise);   // Suspends the component until resolved
    return <h1>{title}</h1>;
}

use() works inside Client Components — unlike await which only works in Server Components. The surrounding Suspense boundary handles the loading state.

Skeletons That Match Your UI

The fallback should look like the content it's replacing:

function CourseHeroSkeleton() {
    return (
        <div className="animate-pulse space-y-4">
            {/* Title */}
            <div className="h-10 bg-gray-200 rounded-lg w-2/3" />
            {/* Subtitle */}
            <div className="h-5 bg-gray-200 rounded w-1/2" />
            {/* Stats row */}
            <div className="flex gap-6">
                <div className="h-5 bg-gray-200 rounded w-20" />
                <div className="h-5 bg-gray-200 rounded w-24" />
                <div className="h-5 bg-gray-200 rounded w-16" />
            </div>
            {/* Description */}
            <div className="space-y-2 pt-4">
                <div className="h-4 bg-gray-200 rounded w-full" />
                <div className="h-4 bg-gray-200 rounded w-full" />
                <div className="h-4 bg-gray-200 rounded w-3/4" />
            </div>
        </div>
    );
}

A good skeleton reduces layout shift — the page doesn't jump around as content loads. Match the height and proportions of the real content.

Parallel Suspense vs Sequential

Both CourseHero and ReviewSection start fetching simultaneously:

// ✅ Parallel — both start at the same time
<Suspense fallback={<HeroSkeleton />}>
    <CourseHero slug={params.slug} />     {/* starts fetch immediately */}
</Suspense>

<Suspense fallback={<ReviewsSkeleton />}>
    <ReviewSection slug={params.slug} />   {/* also starts immediately */}
</Suspense>

They're independent Suspense boundaries, so they don't wait for each other. Whichever resolves first streams in first.

If one needs data from the other, nest the Suspense:

// Sequential — ReviewSection only fetches after CourseHero resolves
async function CourseHero({ slug }: { slug: string }) {
    const course = await getCourse(slug);
    return (
        <>
            <h1>{course.title}</h1>
            <Suspense fallback={<ReviewsSkeleton />}>
                <Reviews courseId={course.id} />   {/* needs courseId from above */}
            </Suspense>
        </>
    );
}

This is a data dependency waterfall — only acceptable when the data is genuinely dependent. Otherwise, keep them parallel.

Next lesson: Next.js caching strategies explained — request memoization, data cache, route cache, and when each applies.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!