React

React Suspense Patterns for Streaming Data Fetching

Learn React Suspense patterns that eliminate request waterfalls and layout shift. Use the use() hook and streaming SSR to build faster, cleaner data-fetching flows.

By Laxaar Engineering Team Aug 4, 2026 11 min read
React Suspense Patterns for Streaming Data Fetching

Most React apps that feel slow aren't slow because of expensive computations. They're slow because data requests happen one after another when they could happen in parallel, and because loading states are wired up manually with useState flags that are easy to get wrong. The result is a cascade of spinners, layout shifts, and code that's harder to read than the problem it solves.

React Suspense was designed to fix this. The mental model shift is real, though. Suspense isn't a loading-state API bolted onto existing patterns. It's a way to declare where your tree can pause and what to show while it waits. Placed correctly, Suspense boundaries let the browser start rendering useful content immediately while deferred data loads in parallel. Placed wrong, they serialize your requests and make things worse.

The use() hook, stable since React 19, makes this practical to write. You pass it a Promise and React suspends the component until that Promise resolves: no useEffect, no isLoading booleans, no manual state transitions. At Laxaar we've migrated several production applications to this model, and the code reduction alone is worth the switch, before you factor in the performance improvements.

What you'll learn

Why effect-based loading flags compound your problems

The classic pattern looks like this: useState for data, useState for loading, useState for error, then a useEffect that fires on mount, fetches, and sets all three. You've written this dozens of times. It works. It also has a failure mode that's easy to miss.

When two sibling components each follow this pattern, they fetch in parallel by accident of the event loop, but they each render their own spinner independently. If a parent conditionally renders based on both being loaded, you've now got hidden ordering dependencies in your useEffects. Add a third component and you're one dependency-array mistake away from a stale-closure bug that surfaces only in production.

The deeper issue is that effect-based fetching is a client-only pattern. It can't participate in server rendering, can't stream partial HTML to the browser, and forces the browser to wait for JavaScript to execute before any data request begins. On a slow network, that's hundreds of milliseconds of nothing.

Effect-based fetching isn't wrong. It's just the wrong level of abstraction for what most pages actually need.

How Suspense boundaries actually work

A Suspense boundary is a React Suspense component that wraps part of your tree and provides a fallback for when any descendant suspends. A component suspends by throwing a Promise. The boundary catches it, renders the fallback, and re-renders the suspended subtree once the Promise resolves.

import { Suspense } from 'react';

export default function UserProfile({ userId }: { userId: string }) {
  return (
    <div className="profile-page">
      <h1>Profile</h1>
      <Suspense fallback={<AvatarSkeleton />}>
        <UserAvatar userId={userId} />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <UserActivity userId={userId} />
      </Suspense>
    </div>
  );
}

Two things matter in this example. First, the boundaries are separate: UserAvatar and UserActivity can suspend independently. If UserActivity takes longer to load, UserAvatar can finish and show real content without waiting. Second, both suspensions start at the same time because both components render in the same pass. No waterfall.

The fallback renders immediately when a child suspends. It's not a loading state you manage. It's a declaration of what the boundary shows while it waits. That distinction changes how you think about loading UX: you design the skeleton at the boundary, not inside each component.

Using the use() hook to read promises

The use() hook is the React 19 primitive that makes Suspense practical for data fetching. It accepts a Promise and returns the resolved value, suspending the component if the Promise is still pending.

import { use } from 'react';

// This promise is created outside the component,
// typically in a data layer or passed as a prop
async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to fetch user');
  return res.json();
}

// Create the promise at the call site — not inside the component
const userPromise = fetchUser('123');

function UserCard() {
  // use() suspends until userPromise resolves
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

export default function App() {
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserCard />
    </Suspense>
  );
}

The critical rule: create the Promise outside the component. If you call fetchUser() inside the render function, you create a new Promise on every render, and React suspends on a new Promise every time. Infinite loop. The Promise has to be stable across renders. A data library like SWR, TanStack Query, or React's built-in cache handles this for you. If you're rolling your own, store the Promise in a module-level cache keyed by the request parameters.

use() also works inside loops and conditionals, unlike most hooks. That's intentional: it's a primitive, not a hook that relies on call-order stability. You can call use(promiseA) in one branch and use(promiseB) in another without violating rules of hooks.

Placing boundaries to avoid request waterfalls

A waterfall happens when request B can't start until request A completes. The classic React waterfall: a parent fetches its data, renders children once done, and each child then fetches its own data. Three sequential round trips when one concurrent batch would do.

The fix is to start all the Promises before any component renders, then let each component read its resolved value via use().

// routes/dashboard.tsx (Next.js App Router)
import { Suspense } from 'react';
import { fetchUser, fetchOrders, fetchAnalytics } from '@/lib/data';

export default async function DashboardPage({ params }: { params: { userId: string } }) {
  // All three promises start NOW, in parallel.
  // Nothing awaits here — we pass the promises down.
  const userPromise = fetchUser(params.userId);
  const ordersPromise = fetchOrders(params.userId);
  const analyticsPromise = fetchAnalytics(params.userId);

  return (
    <div className="dashboard">
      <Suspense fallback={<HeaderSkeleton />}>
        <DashboardHeader userPromise={userPromise} />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <OrdersList ordersPromise={ordersPromise} />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsPanel analyticsPromise={analyticsPromise} />
      </Suspense>
    </div>
  );
}
// components/DashboardHeader.tsx
import { use } from 'react';

export function DashboardHeader({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <h1>Welcome, {user.name}</h1>;
}

All three network requests fire at the same time. Each Suspense boundary renders its fallback independently. As each Promise resolves, its boundary flips from skeleton to real content. The total wait time is the longest single request, not the sum of all three.

The trade-off to acknowledge: this pattern requires you to pass promises through props, which couples your route-level data fetching to your component signatures. For deep trees this gets unwieldy. React context carrying the promise, or a dedicated data library that handles caching and deduplication, is usually the right answer once you have more than two or three data dependencies per route.

Streaming SSR with React Server Components

React Suspense integrates directly with the HTML streaming mechanism in the App Router. When a Server Component suspends, Next.js sends the HTML it has so far (the page shell and any resolved content), then streams in the suspended sections as their data arrives on the server.

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/ProductDetails';
import { ProductReviews } from '@/components/ProductReviews';
import { db } from '@/lib/db';

export default async function ProductPage({ params }: { params: { id: string } }) {
  // This await blocks the entire page until product loads.
  // That's intentional — we need the product to render the shell.
  const product = await db.product.findUnique({ where: { id: params.id } });

  return (
    <main>
      {/* Product details render immediately with awaited data */}
      <ProductDetails product={product} />

      {/* Reviews stream in — they don't block the initial HTML */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
    </main>
  );
}
// components/ProductReviews.tsx — async Server Component
import { db } from '@/lib/db';

export async function ProductReviews({ productId }: { productId: string }) {
  // This fetch runs on the server after the initial HTML is sent.
  // It streams in when ready.
  const reviews = await db.review.findMany({ where: { productId } });
  return (
    <ul>
      {reviews.map(r => <li key={r.id}>{r.content}</li>)}
    </ul>
  );
}

The browser receives the product shell almost immediately. The page is interactive. Reviews appear as they load. This is what streaming SSR delivers: a first contentful paint that doesn't wait for every database query to complete.

The key insight: await at the page level means "this blocks the initial HTML." Suspense around async Server Components means "stream this in after." Use the first for content that's load-bearing for the shell. Use the second for everything else.

Preventing layout shift with boundary placement

Bad boundary placement is one of the most common sources of Cumulative Layout Shift scores. When streamed content loads in and displaces already-rendered content, the browser recalculates layout and the CLS score climbs.

The rules we apply at Laxaar:

Reserve space in your skeleton. The fallback should have the same dimensions as the content it replaces. A skeleton card that's 200px tall should replace a loaded card that's also approximately 200px tall. If the loaded content is variable height, use min-height on the container.

Don't wrap layout-critical elements in a boundary. Navigation bars, page headers, and anything that defines the page's structural columns should never suspend. Those elements need to render synchronously and hold their space.

Nest boundaries for independent sections. A single top-level boundary that covers the whole page means the entire page shows a spinner while any one piece loads. Granular boundaries (one per independent section) let content appear progressively.

// Avoid: one boundary for everything
<Suspense fallback={<PageSpinner />}>
  <Header />
  <Sidebar />
  <MainContent />
</Suspense>

// Better: independent boundaries per section
<>
  <Header /> {/* Never suspends — render synchronously */}
  <div className="layout">
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>
    <Suspense fallback={<ContentSkeleton />}>
      <MainContent />
    </Suspense>
  </div>
</>

Comparison: patterns old and new

This table shows the practical differences across the patterns you'll encounter when migrating an existing codebase.

PatternWaterfall riskSSR supportCode complexityLayout shift risk
useEffect + useStateHigh (sequential by default)None (client-only)MediumHigh
useEffect + parallel fetchLow (with care)NoneHighHigh
TanStack Query / SWRLowPartial (with hydration)LowMedium
use() + Suspense (client)LowNoneLowLow with skeletons
Server Components + SuspenseVery lowFull streamingLowLow with skeletons

The honest observation: TanStack Query and SWR are excellent libraries that handle deduplication, caching, background revalidation, and hydration in ways that rolling your own use() solution doesn't. For client-heavy applications, they're often still the right choice. The Server Components + Suspense pattern earns its place on content-heavy pages and dashboards where server-side data access is cheaper than a round-trip API call from the client.

Our recommendation at Laxaar: don't migrate working useEffect code to use() without a reason. Migrate when you're hitting waterfall problems, writing identical loading-state boilerplate across many components, or adding streaming SSR to an App Router page.

If you're building a new application, the React ecosystem guidance on our web development practice provides more context on when to reach for each pattern.

Frequently Asked Questions

What's the difference between Suspense for data fetching and Suspense for lazy loading?

Both use the same Suspense primitive, but the trigger is different. For lazy loading (React.lazy), the Promise is the dynamic import of a component module. For data fetching, the Promise is a network request. The Suspense boundary doesn't know or care which kind it is. It just catches the thrown Promise and renders the fallback. The mental model and boundary placement rules are the same either way.

Can I use the use() hook with existing TanStack Query or SWR data?

Not directly. TanStack Query and SWR have their own Suspense integration via suspense: true, and that's separate from passing a raw Promise to use(). Use the library's Suspense mode rather than reaching into its internals. Either way, components suspend until data is ready. The difference is who owns the Promise lifecycle: the library does, and that's the point.

What happens when a promise passed to use() rejects?

The rejection propagates as a render error, which the nearest Error Boundary catches. This means you should pair Suspense boundaries with Error Boundaries whenever the data fetch can fail. React's react-error-boundary package makes this straightforward. A common pattern is a wrapper component that provides both boundaries together so you don't forget to handle the error case alongside the loading case.

Does Suspense work with React Native?

Yes. Suspense boundaries work in React Native the same way they do in web React: components can suspend and boundaries render their fallbacks. Streaming SSR doesn't apply since there's no HTML stream, but the client-side pattern of using use() with parallel Promises to avoid sequential loading states works identically. It's particularly useful for screens that depend on multiple independent API calls.

Is it safe to create promises inside a Server Component?

Yes. Async Server Components are effectively async functions that run once on the server per request. You can await Promises directly or pass unawaited Promises to child components for streaming. The React runtime handles the lifetime of these Promises as part of the render cycle. The "don't create promises inside components" rule applies to client components, where the component function re-executes on every render. Server Components only run once per request.


If you're building a React application and want clean data-fetching architecture from the start, the Laxaar engineering team can help you design a fetching strategy that fits your stack: App Router streaming, a client-side query library, or a hybrid. See our custom software development work for examples of what production React applications look like when data fetching is treated as an architecture decision, not an afterthought. Our web development services cover both greenfield and migration projects.

Working on something like this?

Get a fixed scope, timeline, and price within one business day — no obligation.

React SuspenseStreaming SSRData Fetching
Grow your business with us

Take your business to the next level.

Tell us what you're building. We'll come back inside one business day with a fixed scope, timeline, and team — or an honest “this isn't a fit”.

ENGINEERING PHILOSOPHY

Code is useless if it's not comprehensible to those who maintain it. We write code the next person can actually understand.