Skip to content

Suspense Internals

advanced18 min read

Suspense as a Rendering Primitive

Let's clear up the biggest misconception about Suspense right now: it is not a data-fetching library. It is a rendering primitive — a mechanism that lets React pause rendering a subtree when it encounters something that is not ready yet, show a fallback, and resume when the pending resource resolves.

<Suspense fallback={<Spinner />}>
  <UserProfile userId={42} />
</Suspense>

If UserProfile is not ready (still loading data, code, or any async resource), React shows <Spinner />. When the resource resolves, React replaces the spinner with the actual content. The component does not need loading states, error flags, or conditional rendering — Suspense handles the async boundary.

Mental Model

Suspense is a try/catch for rendering. When a component "throws" a promise, Suspense catches it — just like a try/catch catches an error. Instead of showing an error message, Suspense shows a fallback UI while the promise is pending. When the promise resolves, Suspense "retries" rendering the component. The throw/catch analogy is not a metaphor — it is literally how the mechanism works internally.

The Thrown Promise Mechanism

And this is where it gets wild. When a component is not ready, it literally throws a promise. Not a return. Not a callback. A throw. React's reconciler catches the throw in a try/catch, finds the nearest Suspense boundary, and renders the fallback.

// Simplified: what a Suspense-compatible data source does
const cache = new Map();

function fetchUser(id) {
  if (cache.has(id)) {
    return cache.get(id);  // Data ready — return it
  }

  const promise = fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(data => {
      cache.set(id, data);  // Cache the result
    });

  throw promise;  // Data not ready — throw the promise
}

// Inside the component
function UserProfile({ userId }) {
  const user = fetchUser(userId);  // Throws if not cached
  return <div>{user.name}</div>;   // Only reached when data is ready
}

What React Does When It Catches the Promise

Execution Trace
Render
React calls UserProfile()
Component calls fetchUser() which throws a Promise
Catch
React catches the thrown Promise in the work loop
The throw unwinds the render — no JSX is returned
Find Boundary
React walks up the fiber tree to find nearest `<Suspense>`
If no Suspense boundary exists, the root catches it (error)
Show Fallback
React renders the fallback prop (`<Spinner />`) instead of the subtree
The pending subtree is hidden, not destroyed
Subscribe
React attaches a .then() to the thrown promise
When resolved, React schedules a re-render of the suspended subtree
Resolve
Promise resolves — fetchUser now returns cached data
React triggers a re-render of the Suspense boundary's children
Complete
UserProfile renders successfully — fallback replaced with real content
Transition from spinner to content, potentially with a transition
Quiz
A component inside a Suspense boundary throws a Promise. What happens to the component's siblings inside the same boundary?

Nested Suspense Boundaries

Here's where you start to see the real power. Suspense boundaries can nest, and each boundary catches suspensions from its subtree independently.

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments />
    </Suspense>
  </Suspense>
</Suspense>

If Comments suspends, only the inner <CommentsSkeleton /> shows. MainContent, Sidebar, and Header remain visible. If MainContent suspends, the <ContentSkeleton /> replaces both MainContent and the Comments Suspense boundary. If Header suspends, the entire page shows <PageSkeleton />.

The key principle: a Suspense boundary catches the nearest suspension below it. Each level of nesting provides finer control over what shows a loading state.

Quiz
In nested Suspense boundaries, a deeply nested component suspends. Which fallback shows?

React.lazy and Dynamic Imports

React.lazy wraps a dynamic import() to create a component that suspends until the module loads.

const LazyEditor = React.lazy(() => import('./CodeEditor'));

function App() {
  return (
    <Suspense fallback={<EditorSkeleton />}>
      <LazyEditor />
    </Suspense>
  );
}

Internally, React.lazy works like this:

// Simplified React.lazy implementation
function lazy(loader) {
  let status = 'pending';
  let result;
  let thenable = null;

  return function LazyComponent(props) {
    if (status === 'resolved') {
      const Component = result.default;
      return <Component {...props} />;
    }

    if (status === 'pending') {
      thenable = loader().then(
        module => { status = 'resolved'; result = module; },
        error => { status = 'rejected'; result = error; }
      );
      status = 'loading';
    }

    if (status === 'rejected') {
      throw result; // Error — caught by ErrorBoundary
    }

    throw thenable; // Promise — caught by Suspense
  };
}

The pattern is the same: if the resource is not ready, throw a promise. When it resolves, the next render succeeds.

The Waterfall Problem

This is the trap most teams fall into. Naive Suspense usage creates waterfalls — sequential loading where each resource waits for the previous one:

// WATERFALL: Comments only starts loading after MainContent resolves
function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <MainContent />     {/* Fetches article — 500ms */}
      <Suspense fallback={<Spinner />}>
        <Comments />       {/* Fetches comments — 300ms */}
      </Suspense>
    </Suspense>
  );
}

// MainContent suspends → Suspense shows spinner
// After 500ms: MainContent resolves, React renders MainContent + tries Comments
// Comments suspends → inner Suspense shows spinner
// After 300ms more: Comments resolves
// Total: 800ms sequential instead of 500ms parallel

The problem: React only encounters Comments after MainContent resolves. It cannot render Comments (and trigger its fetch) until MainContent is done.

Fix: Parallel Fetching

Start all fetches before rendering. Let the render tree read from already-in-flight promises.

// Start both fetches immediately
function Page({ articleId }) {
  const articlePromise = prefetchArticle(articleId);
  const commentsPromise = prefetchComments(articleId);

  return (
    <Suspense fallback={<Spinner />}>
      <MainContent dataPromise={articlePromise} />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments dataPromise={commentsPromise} />
      </Suspense>
    </Suspense>
  );
}
// Both fetches start simultaneously
// Total: max(500ms, 300ms) = 500ms parallel

In React 19 with Server Components, this pattern is built in — data fetching happens on the server, and components stream as their data resolves.

Common Trap

Never call fetch directly inside a component body expecting Suspense to "just work." Raw fetch returns a Promise, but React's Suspense does not magically await promises returned from components. The component must throw the promise for Suspense to catch it. You need either a Suspense-compatible library (React Query, SWR, use() hook), React.lazy for code splitting, or a framework like Next.js that integrates data fetching with Suspense.

// DOES NOT WORK — Promise is returned as JSX, not thrown
function BrokenComponent() {
  const data = fetch('/api/data').then(r => r.json()); // Returns a Promise
  return <div>{data}</div>; // Renders "[object Promise]"
}

// Works with React 19's use() hook
function WorkingComponent() {
  const dataPromise = fetchData(); // Returns a cached promise
  const data = use(dataPromise);   // Throws promise if pending, returns data if resolved
  return <div>{data.name}</div>;
}
Quiz
Two sibling components each fetch data independently inside a single Suspense boundary. Component A takes 200ms, Component B takes 500ms. When does the content appear?

Streaming SSR with Suspense

Now let's talk about where Suspense really shines. On the server, Suspense enables streaming — sending HTML to the client in chunks as data becomes available, instead of waiting for the entire page to be ready.

Traditional SSR:
  Server fetches ALL data → Renders full HTML → Sends complete response
  Time to first byte: slow (waits for slowest data source)

Streaming SSR:
  Server renders shell HTML → Sends immediately
  Data A resolves → Sends HTML chunk for component A
  Data B resolves → Sends HTML chunk for component B
  Each chunk includes <script> to swap placeholder with real content
// Server component tree
<html>
  <body>
    <Header />           {/* Renders immediately — no data deps */}
    <Suspense fallback={<ArticleSkeleton />}>
      <Article />        {/* Waits for DB query */}
    </Suspense>
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments />       {/* Waits for API call */}
    </Suspense>
  </body>
</html>

The server sends the shell (<html>, <body>, <Header />, both skeletons) immediately. As each Suspense boundary resolves, the server streams an HTML chunk with the content and a <script> tag that replaces the skeleton in-place.

The client sees content progressively. The browser can render the shell and skeletons immediately, then swap in real content as chunks arrive. Time to first meaningful paint is dramatically reduced.

Selective Hydration

And this is where it gets even more interesting. Streaming SSR also enables selective hydration. React hydrates components as they arrive, prioritizing components the user interacts with.

1. Shell HTML arrives → browser renders static HTML
2. React JS bundle loads → React starts hydrating
3. Article HTML chunk arrives → React hydrates Article
4. User clicks on Comments area (still a skeleton)
   → React PRIORITIZES hydrating Comments over other pending work
5. Comments HTML chunk arrives → React hydrates Comments immediately

If the user interacts with a still-unhydrated component, React bumps its hydration priority to SyncLane — ensuring the interaction gets a response as fast as possible.

Quiz
During streaming SSR, the user clicks a button inside a Suspense boundary that hasn't resolved yet. What does React do?

Suspense Boundary Design Guidelines

Too Few Boundaries

// BAD: One boundary for the whole page
// If ANY component suspends, the entire page shows a spinner
<Suspense fallback={<FullPageSpinner />}>
  <Header />
  <Sidebar />
  <MainContent />
  <Comments />
</Suspense>

Too Many Boundaries

// BAD: Every component wrapped — creates a "popcorn" loading experience
// Components pop in one by one at different times, causing layout shifts
<Suspense fallback={<Skeleton />}><Header /></Suspense>
<Suspense fallback={<Skeleton />}><Nav /></Suspense>
<Suspense fallback={<Skeleton />}><Sidebar /></Suspense>
<Suspense fallback={<Skeleton />}><Content /></Suspense>
<Suspense fallback={<Skeleton />}><Footer /></Suspense>

Right Granularity

// GOOD: Boundaries at natural content regions
<Header />  {/* No Suspense — always ready */}
<Suspense fallback={<MainSkeleton />}>
  <MainContent />
  <RelatedArticles />   {/* Loads together with main — same visual region */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
  <Comments />          {/* Separate region — can show skeleton independently */}
</Suspense>
<Footer />  {/* No Suspense — always ready */}
Key Rules
  1. 1Suspense catches thrown promises — just like try/catch catches errors. A suspended component literally throws a promise.
  2. 2When any child of a Suspense boundary suspends, the ENTIRE subtree is replaced by the fallback.
  3. 3Nested Suspense boundaries give finer loading granularity. The nearest ancestor boundary catches the suspension.
  4. 4React.lazy suspends on the dynamic import promise. Wrap lazy components in Suspense.
  5. 5Waterfall: sequential suspensions where child fetching only starts after parent resolves. Fix by prefetching in parallel.
  6. 6Streaming SSR sends HTML in chunks as Suspense boundaries resolve. The browser renders progressively.
  7. 7Selective hydration prioritizes hydrating the component the user interacts with, even if other boundaries are still pending.
Interview Question

Q: How does Suspense work internally, and why does it use thrown promises instead of a return-based API?

A strong answer covers: the throw mechanism (component throws a Promise, caught by nearest Suspense boundary in the fiber tree), why throw was chosen (it unwinds the render call stack immediately — there is no way for a synchronous function to "pause" and resume, but throw + catch gives clean control flow), the lifecycle of a suspension (catch → show fallback → subscribe to promise → re-render on resolve), the waterfall problem (sequential suspensions), and how streaming SSR extends the same mechanism to the server (stream HTML chunks as boundaries resolve). Bonus: contrast with async/await (which would require the render function to be async — incompatible with React's synchronous reconciliation model).