Breaking the Waterfall: Streaming & Suspense

How to make slow databases feel fast. Understanding HTTP Streaming, Time-To-First-Byte (TTFB), and how React Suspense allows the Architect to send blueprints in chunks.

By Satish Kumar December 14, 2025

In Article 5, we mastered the Composition Pattern. We can now mix static Server content with interactive Client content.

But we have introduced a hidden performance flaw: The Blocking Request.

If your Server Page needs to fetch data that takes 3 seconds (e.g., a slow legacy database or a third-party API), the entire page will hang for 3 seconds before the user sees a single pixel.

This is the "All or Nothing" problem of traditional Server-Side Rendering (SSR). To solve it, we must embrace the physics of the web protocol itself. We need HTTP Streaming.

The Physics of HTTP

We often think of an HTTP Request as a transaction:

  1. Client asks for index.html.
  2. Server prepares the file.
  3. Server sends the file.

But HTTP/1.1 (and 2/3) supports Chunked Transfer Encoding. This means the Server can say:

Browsers are incredibly smart. They can parse and paint the top of an HTML file while the bottom is still downloading.

React Server Components utilize this native browser capability.

The New Role of Suspense

In the old React (Client-Side), <Suspense> was mostly used for Lazy Loading Code (React.lazy). It showed a spinner while downloading a JavaScript bundle.

In RSC, <Suspense> has a much more powerful job: It marks the boundaries of the Stream.

It tells the Architect (Server):

How Streaming Works (The Timeline)

Let's visualize a Dashboard with a slow widget.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import Header from './Header'; // Fast
import Sidebar from './Sidebar'; // Fast
import SlowChart from './SlowChart'; // Takes 3 seconds

export default function Page() {
  return (
    <div className='layout'>
      <Header />
      <div className='main'>
        <Sidebar />

        {/* The Magic Boundary */}
        <Suspense fallback={<div className='skeleton'>Loading Chart...</div>}>
          <SlowChart />
        </Suspense>
      </div>
    </div>
  );
}

Time: 0.1s (The Shell)

The Server executes Page. It renders Header and Sidebar immediately. It hits <Suspense>. It sees that SlowChart is awaiting a Promise. Instead of waiting, it sends the Fallback HTML immediately.

The User Sees: The Header, The Sidebar, and a "Loading Chart..." skeleton. TTFB (Time To First Byte): Immediate.

Time: 0.1s - 3.0s (The Gap)

The connection stays OPEN. The browser tab shows the "spinning" icon. On the server, SlowChart is sitting at await db.query(...).

Time: 3.1s (The Pop)

The database resolves. The Server renders SlowChart into HTML. It pushes a new chunk of data down the stream:

<div hidden id="S:1">
  <!-- The Real Chart HTML -->
  <svg>...</svg>
</div>
<script>
  // Tiny script to swap the skeleton with the real HTML
  $RC = function(b, c, e) { ... }
  $RC("S:0", "S:1")
</script>

The Browser executes this script, deletes the Skeleton, and inserts the Chart. The stream closes.

Parallelization: The Senior Pattern

A common mistake developers make with Async Components is accidental Waterfalls.

The Waterfall (Bad)

If you await inside the component body sequentially, you block execution.

// ❌ Sequential Blocking
export default async function UserProfile() {
  const user = await db.user.get(); // Wait 1s
  const posts = await db.posts.get(); // Wait 1s
  // Total time: 2s

  return <Display user={user} posts={posts} />;
}

The Fix 1: Promise.all (Better)

If the data fetches are independent, kick them off together.

// ✅ Parallel Fetching
export default async function UserProfile() {
  // Start both requests instantly
  const userPromise = db.user.get();
  const postsPromise = db.posts.get();

  // Wait for both (Time: Max(1s, 1s) = 1s)
  const [user, posts] = await Promise.all([userPromise, postsPromise]);

  return <Display user={user} posts={posts} />;
}

The Fix 2: Independent Suspense (Best Architecture)

If user is fast (50ms) but posts is slow (2s), using Promise.all slows everything down to the slowest request (2s).

The architectural fix is to split the components and stream them independently.

export default function Page() {
  return (
    <>
      <Suspense fallback={<UserSkeleton />}>
        <UserComponent /> {/* Fills in 50ms */}
      </Suspense>

      <Suspense fallback={<PostsSkeleton />}>
        <PostsComponent /> {/* Fills in 2s */}
      </Suspense>
    </>
  );
}

Summary

  1. Blocking: By default, awaiting data in a Server Component blocks the entire HTML response.
  2. Streaming: Allows the server to send the page in chunks.
  3. Suspense: Acts as the "Split Point." The server sends the fallback instantly and streams the content when the Promise resolves.
  4. UX Win: The user perceives the site as "Fast" because the UI shell loads instantly, even if the data is slow.

We have mastered reading data efficiently. Now, we must tackle the final piece of the application lifecycle: Writing Data. In the next article, we look at how to replace API routes with Server Actions.


Challenge: The Waterfall Spotter

You are reviewing a Pull Request for a Product Page. The developer complains that the page takes 4 seconds to load.

Code:

export default async function ProductPage({ id }) {
  const product = await db.product.find(id); // 1. Fast (100ms)
  const reviews = await db.reviews.find(id); // 2. Slow (3000ms)
  const related = await db.related.find(id); // 3. Medium (500ms)

  return (
    <div>
      <ProductDetails data={product} />
      <RelatedProducts data={related} />
      <Reviews data={reviews} />
    </div>
  );
}

Task:

  1. Calculate the current Total Load Time.
  2. Refactor the code using Suspense Boundaries so that the ProductDetails shows up immediately (100ms), Related pops in next, and Reviews loads last.
Click to Reveal Solution

Current Load Time: 100ms + 3000ms + 500ms = 3600ms (3.6s). The user sees a blank screen for 3.6 seconds.

Refactor Strategy: Create 3 separate components (Product, Reviews, Related) that fetch their own data.

export default function ProductPage({ id }) {
  return (
    <div>
      {/* 1. Critical Data (You might decide to block/await this one or stream it) */}
      <Suspense fallback={<ProductSkeleton />}>
        <Product id={id} />
      </Suspense>

      {/* 2. Independent Stream */}
      <Suspense fallback={<RelatedSkeleton />}>
        <Related id={id} />
      </Suspense>

      {/* 3. Independent Stream */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} />
      </Suspense>
    </div>
  );
}

New Experience:

  • 0.1s: Product Details Visible.
  • 0.5s: Related Products Visible.
  • 3.0s: Reviews Visible.
  • Perceived Load Time: 100ms (Instant).

Subscribe to my free Newsletter

Join my weekly newsletter to get the latest updates on design engineering, new projects, and articles I've written. No spam, unsubscribe at any time.