⚛ The Course  /  Module 7 · Suspense, Errors & Activity · 7.1
Module 7 · Suspense, Errors & Activity

7.1 Suspense: throw, fallback, retry

A component needs data that hasn't arrived yet. What should React do — block the whole page, render half a screen, or show a spinner? This lesson explains the surprisingly small trick React uses to answer that: the component throws, React shows a fallback, and then it tries again when the data lands. By the end you'll be able to trace that loop by hand.

What Suspense looks like from outside

From a component author's perspective, Suspense is a boundary that shows a fallback while any descendant is "not ready":

function ProfilePage() {
  const data = useQuery('/api/profile');  // may throw a promise
  return <div>{data.name}</div>;
}

<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

A component signals "not ready" by throwing a promise — any object with a .then method, which React calls a thenable. The use(promise) hook built into React 18/19 does this for you, but any code can throw a thenable and Suspense will catch it.

The core loop: throw, rewind, fallback, ping, retry

Let's slow that down with two components, where the second one needs data that depends on the first:

function Profile() {
  const user = use(fetchUser(id));         // round 1: not ready → throws
  return <><h1>{user.name}</h1><Posts /></>;
}
function Posts() {
  const posts = use(fetchPosts(id));       // round 2: not ready → throws
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

<Suspense fallback={<Spinner />}>
  <Profile />
</Suspense>

Round 1 — hitting missing data:

  1. React renders <Suspense>, then descends into <Profile>.
  2. use(userPromise) — data isn't ready → throws the promise.
  3. React catches it, unwinds back up to the nearest <Suspense> (popping all internal bookkeeping stacks it pushed on the way down).
  4. The boundary is marked DidCapture; its next beginWork renders the fallback<Spinner />.
  5. A listener is attached to the promise: "when this resolves, ping me" (pingedLanes).
  6. Commit → user sees the spinner.

Round 2 — data arrives, retry:

  1. The promise resolves → listener fires → React schedules a fresh render of the subtree.
  2. Profile renders again; use(userPromise) now returns the user (no throw). Profile renders <Posts />.
  3. <Posts /> calls use(postsPromise) — also not ready → throws → same unwind → spinner stays.

Round 3: both promises resolved → nothing throws → commit real UI. Spinner is replaced.

Here is the same flow as a picture. Read it as: render goes down, the throw bounces back up to the net, and the resolved promise pings React to start over.

    ┌──────────────────┐
    │   <Suspense>     │  ◄── (4) unwind stops here; set DidCapture → render fallback
    └────────┬─────────┘
             │  (1) render down ↓          ▲ (4) rewind up (pop stacks)
    ┌────────▼─────────┐
    │   <Profile>      │  ──(2) use() throws the promise──┐
    └──────────────────┘                                  │
                                                          ▼
   (5) "ping me when ready"  ◄───────────  attach listener to the promise
              │
   (7) promise resolves → ping → fresh render → Profile runs again ✅

Why React finishes the current render instead of starting over

When a component throws, React has already built parts of the tree (siblings rendered before the throwing component, parent nodes). Discarding all that work just to show a spinner would be wasteful. React's rule is: no partial commits — it needs one complete tree before it can paint. So it finishes the current render by swapping the fallback in at the boundary and completing the remaining siblings.

Lane state during suspend and retry

Suspense is wired into the lanes system from earlier modules. A lane is just a bit that marks "there is work to do here." When a component suspends, its lane moves from pending to suspended. When the promise resolves (the ping), the lane moves to pinged and gets priority at the next scheduling opportunity.

The root keeps three separate sets of lanes so it always knows which state each piece of work is in:

The real code

// Three lane "buckets" on the root:
root.pendingLanes    // work that needs to happen
root.suspendedLanes  // work waiting on async data
root.pingedLanes     // suspended work that's ready to resume

// When a component suspends:
root.suspendedLanes |= workInProgressRootRenderLanes;

// When the promise resolves (inside wakeable.then()):
root.pingedLanes |= suspendedLanes;
ensureRootIsScheduled(root);      // queue a fresh render

// getNextLanes() prioritises pinged lanes:
const pingedLanes = root.pingedLanes & pendingLanes;
if (pingedLanes !== NoLanes) return pingedLanes;  // resume first

The same story, told as a timeline in milliseconds — watch one lane bit move between the three buckets:

T=0ms   User navigates → TransitionLane update
        root.pendingLanes = TransitionLane

T=1ms   <ProfilePage> throws promise
        root.suspendedLanes |= TransitionLane   // "waiting"
        root.pendingLanes   &= ~TransitionLane  // remove from pending
        Show <Spinner />

T=100ms Promise resolves
        root.pingedLanes    |= TransitionLane   // "ready"
        root.pendingLanes   |= TransitionLane   // add back
        ensureRootIsScheduled(root)             // schedule retry

T=101ms getNextLanes picks pingedLanes → re-render → real content ✅

How the work loop catches a thrown thenable

So where does the catch actually happen? Inside React's work loop, every unit of work runs in a try/catch. When something is thrown, handleError inspects it. If the thrown value has a .then method, it is a promise, and React routes it to the Suspense path instead of treating it as a crash.

The real code — handleError in ReactFiberWorkLoop.js

function handleError(root, thrownValue) {
  if (typeof thrownValue === 'object' && thrownValue !== null) {
    if (typeof thrownValue.then === 'function') {
      // ── PROMISE THROWN (Suspense) ──
      const wakeable = thrownValue;
      const sourceFiber = workInProgress;

      // mark this lane as suspended
      root.suspendedLanes |= workInProgressRootRenderLanes;

      // attach ping listener
      wakeable.then(
        () => {
          root.pingedLanes |= suspendedLanes;  // ready to resume
          ensureRootIsScheduled(root);
        },
        error => { /* promise rejected → handle as error */ }
      );

      // unwind to the nearest Suspense boundary
      throwException(root, sourceFiber, wakeable);
    }
  }
}

Three things happen here, in order: the lane is marked suspended, a ping listener is armed on the promise, and throwException kicks off the unwind back to the boundary. That's the whole catch in one function.

The simplified implementation (React 19)

The real source encodes every step into bit flags on the fiber — things like fiber.flags |= 32768 or (flags & -65537) | 128. Those are hard to read. Replacing each bit with a named boolean makes the same mechanism readable:

Real ReactSimplifiedMeaning
fiber.flags |= 32768fiber.incomplete = truethis fiber threw — don't finish it
fiber.flags |= 65536fiber.shouldCapture = truethis boundary should catch
fiber.flags |= 128fiber.didCapture = trueboundary caught → render fallback
(flags & -65537) | 128shouldCapture=false; didCapture=truethe boundary "flip"

With those names in hand, here is the entire mechanism — use(), the render loop, the unwind, and the retry — in about ninety readable lines. Don't memorize it; skim it once, then we'll trace it.

The complete mechanism in ~90 readable lines

Show the full source
// ── global work-loop state ──
let workInProgress  = null;   // fiber currently being processed
let suspendedReason = null;   // null | 'data' | 'error'
let thrownValue     = null;   // the promise or error that was thrown
let suspenseHandler = null;   // nearest <Suspense> on the stack (a cursor)

// ── use(): how a component "suspends" ──
function use(promise) {
  if (promise.status === 'fulfilled') return promise.value;   // data ready
  if (promise.status === 'rejected')  throw promise.reason;   // failed → real error
  if (!promise.status) {
    promise.status = 'pending';
    promise.then(
      v => { promise.status = 'fulfilled'; promise.value  = v; },
      e => { promise.status = 'rejected';  promise.reason = e; }
    );
  }
  throw promise;   // PENDING → throw the promise = "stop me, I'm not ready"
}

// ── the render loop (renderRootConcurrent) ──
function renderRoot(root) {
  workInProgress = root.firstChild;
  while (true) {
    try {
      if (suspendedReason !== null && workInProgress !== null) {
        const fiber = workInProgress, value = thrownValue, reason = suspendedReason;
        suspendedReason = null; thrownValue = null;

        if (reason !== 'error' && value.status === 'fulfilled') {
          replayUnitOfWork(fiber);            // FAST PATH: data already here
        } else {
          throwAndUnwind(root, fiber, value); // SLOW PATH: rewind to boundary
        }
      }
      workLoop();   // run normally until something throws or we finish
      return;
    } catch (err) {
      handleThrow(err);   // classify; loop again
    }
  }
}

function workLoop() {
  while (workInProgress !== null) performUnitOfWork(workInProgress);
}

// ── handleThrow: classify, don't unwind yet ──
function handleThrow(err) {
  suspendedReason = isPromise(err) ? 'data' : 'error';
  thrownValue = err;
  // workInProgress still points at the fiber that threw
}

// ── one unit of work (going DOWN) ──
function performUnitOfWork(fiber) {
  if (fiber.type === Suspense) pushSuspenseHandler(fiber);
  const next = beginWork(fiber);   // runs your component; use() may THROW here
  if (next === null) completeUnitOfWork(fiber);
  else workInProgress = next;
}

function beginWork(fiber) {
  if (fiber.type === Suspense) {
    return fiber.didCapture
      ? renderChild(fiber, fiber.props.fallback)   // caught → spinner
      : renderChild(fiber, fiber.props.children);  // normal → real children
  }
  if (isFunctionComponent(fiber.type)) {
    return reconcile(fiber, fiber.type(fiber.props)); // calls Profile(); use() throws here
  }
}

// ── the UNWIND (going back UP) ──
function throwAndUnwind(root, sourceFiber, value) {
  throwException(root, sourceFiber, value);  // 1. find boundary + arm retry
  unwindToBoundary(sourceFiber);             // 2. rewind stack to it
}

function throwException(root, sourceFiber, value) {
  sourceFiber.incomplete = true;
  if (isPromise(value)) {
    const boundary = suspenseHandler;         // nearest <Suspense> from cursor
    boundary.shouldCapture = true;
    attachPingListener(root, value);          // retry when promise resolves
  } else {
    let f = sourceFiber.return;               // ERROR: walk up to nearest Error Boundary
    while (f && !isErrorBoundary(f)) f = f.return;
    if (f) f.shouldCapture = true;
  }
}

function unwindToBoundary(fiber) {
  let f = fiber;
  while (f !== null) {
    const next = unwindWork(f);               // pop this fiber's stacks; is it the catcher?
    if (next !== null) {                      // found the boundary
      next.incomplete = false;
      workInProgress = next;                  // resume rendering HERE
      return;
    }
    if (f.return) f.return.incomplete = true; // not the catcher → propagate up
    f = f.return;
  }
  workInProgress = null;  // nobody caught → whole tree suspended/errored
}

function unwindWork(fiber) {
  if (fiber.type === Suspense) {
    popSuspenseHandler(fiber);
    if (fiber.shouldCapture) {
      fiber.shouldCapture = false;
      fiber.didCapture    = true;   // THE FLIP: next beginWork renders fallback
      return fiber;                 // "I'm the catcher, stop here"
    }
    return null;
  }
  popStacksFor(fiber);   // pop context / host stacks this fiber pushed
  return null;
}

// ── REPLAY (fast path: promise resolved synchronously) ──
function replayUnitOfWork(fiber) {
  const next = beginWork(fiber);   // use() now RETURNS the value
  if (next === null) completeUnitOfWork(fiber);
  else workInProgress = next;
}

// ── PING → RETRY ──
function attachPingListener(root, promise) {
  promise.then(() => pingSuspendedRoot(root));
}
function pingSuspendedRoot(root) {
  scheduleRender(root);   // start a fresh render → retry the boundary's subtree
}

The boundary "flip"

The single most important line above is inside unwindWork. When the unwind reaches a Suspense fiber that was marked shouldCapture, it flips two booleans:

fiber.shouldCapture = false;
fiber.didCapture    = true;   // THE FLIP: next beginWork renders fallback
return fiber;                 // "I'm the catcher, stop here"

Returning the fiber stops the rewind here. Setting didCapture means the very next beginWork on this boundary renders props.fallback instead of props.children. One boolean is the whole difference between "show the real content" and "show the spinner."

Trace through the waterfall

Now follow our two-component example through the simplified functions. Each round is one render attempt; the ping between rounds is what restarts the loop.

ROUND 1
  performUnitOfWork(Suspense) → pushSuspenseHandler(Suspense)
  performUnitOfWork(Profile)  → use(userPromise) pending → THROW
  catch → handleThrow         → suspendedReason='data', workInProgress=Profile
  loop: value.status==='pending' → throwAndUnwind(root, Profile, userPromise)
    throwException  → Profile.incomplete=true
                      suspenseHandler(Suspense).shouldCapture=true
                      attachPingListener(userPromise)
    unwindToBoundary → unwindWork(Profile)=null → up
                       unwindWork(Suspense): shouldCapture→didCapture → RETURN Suspense
                       workInProgress = Suspense
  workLoop: beginWork(Suspense) → didCapture → render fallback → 🌀 Spinner
  commit → user sees 🌀
  userPromise resolves → ping → scheduleRender → ROUND 2

ROUND 2  (Suspense.didCapture reset for the fresh render)
  Profile → use(userPromise) RETURNS user ✓ → renders <Posts />
  Posts   → use(postsPromise) pending → THROW  ← one level DEEPER
  → same throwAndUnwind → Suspense catches again → 🌀 stays
  postsPromise resolves → ping → ROUND 3

ROUND 3
  Suspense → Profile (user ✓) → Posts (posts ✓) → nothing throws → ✅ commit real UI

Why two listeners on the same promise

Look closely and you'll notice the thrown promise gets two separate .then callbacks. They have different jobs, and you need both:

ListenerJobAttached by
Note 1 — record valuestamp promise.status='fulfilled'; promise.value=data so it's readable laterinside use()
Note 2 — ping Reactschedule a fresh render so React comes back and reads itattachPingListener

Drop either one and the loop breaks:

Only Note 1 (record, no ping):  value saved, but React never re-renders → spinner forever 🌀
Only Note 2 (ping, no record):  React re-renders → use() has no value → throws again → infinite loop 🔁
Both:                           re-render AND use() reads the saved value → returns it ✓ → done ✅