Skip to content

Suspense Internals

advanced11 min read

The Throw-Catch Mechanism

Suspense works through a mechanism that feels illegal: components throw promises. Not errors — promises. React catches them and knows to wait.

// Simplified version of what a Suspense-compatible data fetcher does
let cache = new Map();

function fetchUser(id) {
  if (cache.has(id)) return cache.get(id);

  // Throw a promise — React will catch it
  throw fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(data => cache.set(id, data));
}

function UserProfile({ userId }) {
  // This THROWS a promise on first render
  // React catches it, shows the fallback, then retries when resolved
  const user = fetchUser(userId);
  return <h1>{user.name}</h1>;
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

When UserProfile first renders, fetchUser throws a promise. React unwinds the render, shows <Spinner />, and subscribes to the promise. When it resolves, React re-renders UserProfile, which now gets the cached data and renders normally.

The Mental Model

Mental Model

Think of Suspense as a try-catch for loading states. In synchronous JavaScript, if a function can't complete because of an error, it throws and the nearest catch block handles it. Suspense works the same way but for async data:

If a component can't render because data isn't ready yet, it throws a promise. The nearest <Suspense> boundary catches it and shows the fallback. When the promise resolves, React "retries" — like a retry after an exception, but automatic.

The hierarchy matters: just like try-catch, the nearest Suspense boundary handles the thrown promise. You can nest Suspense boundaries to control which parts of the UI show loading states independently.

Fiber-Level Implementation

When React encounters a thrown promise during the render phase:

// Simplified from React source
function renderRootConcurrent(root, lanes) {
  try {
    workLoopConcurrent();
  } catch (thrownValue) {
    // Check if it's a promise (Suspense)
    if (typeof thrownValue === 'object' && typeof thrownValue.then === 'function') {
      // It's a thenable — this is Suspense
      const wakeable = thrownValue;

      // Find the nearest Suspense boundary fiber
      const suspenseBoundary = getNearestSuspenseBoundaryFiber(workInProgress);

      // Mark the Suspense boundary to show fallback
      suspenseBoundary.flags |= ShouldCapture;

      // Attach a listener to retry when the promise resolves
      attachPingListener(root, wakeable, lanes);

      // Unwind — React will re-render the Suspense subtree with fallback
      unwindWork(workInProgress, suspenseBoundary);
    } else {
      // It's a real error — propagate to error boundary
      throw thrownValue;
    }
  }
}
Execution Trace
Render:
React renders UserProfile
Component function calls fetchUser(42)
Throw:
fetchUser throws Promise
Data not cached yet. Throws the fetch promise
Catch:
React catches the thenable
Checks: is it a promise? Yes → Suspense path
Unwind:
Find nearest `Suspense`
Walk up fiber tree via return pointers to find Suspense boundary
Fallback:
Render fallback UI
React renders `<Spinner />` instead of the suspended subtree
Listen:
promise.then(ping)
React attaches a callback to the promise to trigger re-render
Resolve:
Promise resolves
Data is now cached. React schedules a retry render
Retry:
Re-render UserProfile
fetchUser returns data (cached). Component renders normally

Suspense Boundaries: Nesting and Granularity

Multiple Suspense boundaries control loading granularity:

function Dashboard() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />
      <div className="grid">
        {/* Each panel loads independently */}
        <Suspense fallback={<PanelSkeleton />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<PanelSkeleton />}>
          <UserStats />
        </Suspense>
        <Suspense fallback={<PanelSkeleton />}>
          <ActivityFeed />
        </Suspense>
      </div>
    </Suspense>
  );
}

If RevenueChart suspends:

  • Only the RevenueChart panel shows <PanelSkeleton />
  • UserStats and ActivityFeed render normally (or show their own skeletons if they suspend too)
  • The outer Suspense boundary is not triggered because an inner one caught it

Without inner boundaries, any single suspension would replace the entire dashboard with <PageSkeleton />.

Common Trap

If a component inside a Suspense boundary throws during a transition, React does NOT show the Suspense fallback. Instead, it keeps showing the old content (the transition behavior). The fallback only shows for the initial render or non-transition updates. This is a common source of confusion: "Why isn't my Suspense fallback showing?"

// With transition: old content stays visible, no fallback shown
startTransition(() => setTab('analytics'));

// Without transition: Suspense fallback shows immediately
setTab('analytics'); // If AnalyticsTab suspends, fallback shows

React.lazy: Code-Level Suspense

React.lazy is the original Suspense use case — code splitting:

// This creates a component that suspends while its code loads
const AnalyticsTab = lazy(() => import('./AnalyticsTab'));

function TabContainer({ activeTab }) {
  return (
    <Suspense fallback={<TabSkeleton />}>
      {activeTab === 'analytics' && <AnalyticsTab />}
      {activeTab === 'settings' && <SettingsTab />}
    </Suspense>
  );
}

When AnalyticsTab is first rendered:

  1. lazy calls import('./AnalyticsTab') — returns a promise
  2. The lazy wrapper throws that promise
  3. Suspense catches it, shows <TabSkeleton />
  4. When the module loads, the promise resolves
  5. React retries, lazy returns the loaded component
  6. AnalyticsTab renders normally

The chunk is only fetched when the component is first rendered, not when the page loads. Subsequent renders use the cached module.

Streaming SSR with Suspense

Suspense changes server rendering from "render everything, then send" to "send what's ready, stream the rest":

// Server component tree
function App() {
  return (
    <html>
      <body>
        <Header />   {/* Renders immediately */}
        <Suspense fallback={<ArticleSkeleton />}>
          <Article />  {/* Needs database query */}
        </Suspense>
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />  {/* Needs API call */}
        </Suspense>
      </body>
    </html>
  );
}

The server streams HTML progressively:

1. Send immediately:
   <html><body>
     <Header rendered />
     <div id="article-boundary"><ArticleSkeleton /></div>
     <div id="comments-boundary"><CommentsSkeleton /></div>
   </body></html>

2. Article data ready (200ms later):
   <script>
     // Replace ArticleSkeleton with actual Article HTML
     $RC("article-boundary", "<article>...</article>")
   </script>

3. Comments data ready (500ms later):
   <script>
     // Replace CommentsSkeleton with actual Comments HTML
     $RC("comments-boundary", "<div class='comments'>...</div>")
   </script>

The browser receives and renders the page shell immediately. Skeletons are progressively replaced as data becomes available. Each replacement triggers selective hydration — React hydrates the new content without re-rendering the entire page.

Selective hydration

When streaming SSR sends a replacement chunk, React doesn't hydrate the entire page. It selectively hydrates only the new content:

  1. The initial HTML arrives with skeletons. React hydrates the static parts (Header).
  2. Article HTML streams in. React inserts it into the DOM and hydrates just the Article subtree.
  3. If the user clicks on the Article before Comments has loaded, React prioritizes hydrating the Article to make it interactive.
  4. Comments HTML streams in later. React hydrates it independently.

This means the most important parts of the page become interactive first, even if other parts are still loading. React uses the same lane-based priority system to schedule hydration work.

Production Scenario: The Waterfall Killer

A profile page fetches three pieces of data sequentially:

// BUG: Sequential data waterfall
function ProfilePage({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);
  const [friends, setFriends] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  useEffect(() => {
    if (user) fetchPosts(user.id).then(setPosts); // Waits for user
  }, [user]);

  useEffect(() => {
    if (user) fetchFriends(user.id).then(setFriends); // Waits for user
  }, [user]);

  if (!user) return <Spinner />;
  return (
    <>
      <UserHeader user={user} />
      {posts ? <PostList posts={posts} /> : <Spinner />}
      {friends ? <FriendList friends={friends} /> : <Spinner />}
    </>
  );
}

With Suspense, all fetches start simultaneously:

function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserHeader userId={userId} />
      <Suspense fallback={<PostsSkeleton />}>
        <PostList userId={userId} />
      </Suspense>
      <Suspense fallback={<FriendsSkeleton />}>
        <FriendList userId={userId} />
      </Suspense>
    </Suspense>
  );
}

// Each component uses a Suspense-compatible data fetcher
function UserHeader({ userId }) {
  const user = use(fetchUser(userId)); // Suspends until ready
  return <h1>{user.name}</h1>;
}

function PostList({ userId }) {
  const posts = use(fetchPosts(userId)); // Suspends independently
  return posts.map(p => <PostCard key={p.id} post={p} />);
}

All three fetches start when their components first render. Each component suspends independently. The header might show at 100ms, posts at 200ms, friends at 300ms — each progressively replacing its skeleton.

Common Mistakes

Common Mistakes
  • Wrong: Using Suspense as a general-purpose loading state manager Right: Use Suspense with Suspense-compatible data fetching libraries (React Query, Next.js, use())

  • Wrong: Wrapping individual components in Suspense when they should load together Right: Group related components under a single Suspense boundary to prevent partial loading states

  • Wrong: Expecting Suspense to catch errors from failed fetches Right: Use Error Boundaries for error handling. Suspense only handles loading states

  • Wrong: Placing Suspense boundaries too high in the tree Right: Place boundaries around the smallest independently-loadable section

Challenge

Design the Suspense boundaries

Quiz

Quiz
What mechanism does Suspense use to detect that a component is loading data?

Key Rules

Key Rules
  1. 1Suspense catches thrown promises during render. When a component throws a promise, the nearest Suspense boundary shows its fallback.
  2. 2When the thrown promise resolves, React retries the render. The component should now have data and render normally.
  3. 3Suspense boundaries control loading granularity. Nest them to allow independent loading of different sections.
  4. 4During transitions, Suspense fallbacks are suppressed. React shows old content instead of replacing it with a skeleton.
  5. 5Streaming SSR uses Suspense boundaries to send HTML progressively. Each boundary can stream independently as its data resolves.
  6. 6Suspense is for loading states only. Use Error Boundaries for error handling — failed promises propagate as errors, not suspensions.