DevgainsDevgainsDevgains
All articles

Suspense and Streaming: Render UI Before the Data Arrives

·5 min read
Suspense and Streaming: Render UI Before the Data Arrives

Photo: Unsplash

The classic React loading pattern is all-or-nothing: a top-level isLoading flag, a full-page spinner, and a page that snaps into existence only once every piece of data has arrived. The slowest query on the page sets the speed for everything else. Your fast-loading header waits politely behind a recommendations widget that's hitting a cold cache.

Suspense and streaming exist to break that coupling. Suspense lets a component "wait" for something and declaratively show a fallback while it does. Streaming lets the server send HTML in chunks as those pieces become ready, instead of buffering the whole document. Put them together and you can paint the shell instantly, then let each section fill in independently.

Suspense: a boundary for "not ready yet"

<Suspense> is a component that catches the "I'm not ready" signal from anything rendering below it and shows a fallback until that thing is ready. The Suspense reference describes it as displaying a fallback "until its children have finished loading."

import { Suspense } from "react";
 
function Page() {
  return (
    <main>
      <Header />                          {/* renders immediately */}
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />                          {/* may suspend on data */}
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Recommendations />               {/* suspends independently */}
      </Suspense>
    </main>
  );
}

The crucial property: each boundary is independent. Header shows right away. Feed and Recommendations each show their own skeleton and reveal themselves whenever they are ready, in any order. The slow recommendations widget no longer holds the feed hostage. You're describing what to show while waiting declaratively, in the tree, instead of threading loading booleans through props.

A component "suspends" when it reads a resource that isn't ready — typically a Server Component that's awaiting data, or a Client Component reading a value through a Suspense-enabled data source. You don't throw promises by hand; the framework or use hook wires that up. Your job is to decide where the boundaries go.

Streaming: send the HTML in pieces

Suspense on its own improves the client experience. Streaming extends it to the initial server render. Instead of the server computing the entire page and sending one big HTML response, it sends the parts that are ready immediately — the shell and any resolved boundaries — and then streams the rest as each suspended boundary resolves.

Concretely: the user gets a real first paint with the header and skeletons almost instantly. The server keeps the connection open, finishes the feed query, and streams down the feed HTML plus a tiny script that slots it into place. No client-side round trip, no spinner-then-snap. The renderToReadableStream and Node's renderToPipeableStream APIs are what make this possible on the server; frameworks built on the App Router model wire them up for you.

Streaming changes the meaning of "Time to First Byte vs. Time to Interactive." With a buffered render, your slowest data query delays the entire first byte. With streaming, the first byte is the shell — fast and constant — and slow data only delays its own section. You're trading one long wait for several short, parallel ones.

Picking where boundaries go

The skill in Suspense isn't the API — it's boundary placement. Too few boundaries and you're back to all-or-nothing; too many and the page flickers with a confetti of skeletons popping in at different times. Some guidelines:

  • Wrap content that fetches, not content that's instant. Don't put a boundary around your static nav.
  • Group things that should appear together. If a chart and its legend look broken apart, put them in one boundary so they reveal as a unit.
  • Match the fallback's shape to the real content. A skeleton that occupies the same space prevents layout shift when the data lands. This is also where you avoid hurting Cumulative Layout Shift.
  • Don't nest boundaries you don't need. Each one is a separate reveal; more reveals means more visual churn.

Transitions: keeping the page from flashing back to a skeleton

There's a subtle trap. Once content is on screen and the user does something that triggers new data — switching a tab, paginating — naively that can re-suspend the boundary and flash the skeleton back over content the user was already reading. That's a regression, not a loading state.

The fix is to mark the update as a transition so React keeps the old UI visible while the new content loads in the background:

import { useState, useTransition } from "react";
 
function Tabs() {
  const [tab, setTab] = useState("home");
  const [isPending, startTransition] = useTransition();
 
  function select(next) {
    startTransition(() => setTab(next)); // don't flash the fallback
  }
 
  return (
    <>
      <nav data-pending={isPending}>
        <button onClick={() => select("home")}>Home</button>
        <button onClick={() => select("posts")}>Posts</button>
      </nav>
      <Suspense fallback={<Skeleton />}>
        <TabPanel tab={tab} />
      </Suspense>
    </>
  );
}

Inside a transition, React shows the already-rendered content and only swaps to the new panel when it's ready, optionally dimming the UI via isPending. The useTransition hook is the bridge between "first load, show a skeleton" and "subsequent update, keep what's there." Without it, every navigation feels like a cold start.

Takeaways

  • Suspense lets a component declaratively show a fallback while it waits, replacing top-level isLoading booleans with boundaries in the tree.
  • Each Suspense boundary is independent — fast sections paint immediately and slow ones reveal on their own, in any order.
  • Streaming sends HTML in chunks as boundaries resolve, so your slowest query no longer delays the entire first byte.
  • Boundary placement is the real skill: wrap fetching content, group things that belong together, and shape fallbacks to prevent layout shift.
  • Use useTransition for updates to already-visible content so React keeps the old UI instead of flashing the skeleton back.
5 min read

Read next