Skip to content

Suspense and error boundaries: what waits and what fails together

Boundaries decide what waits and what fails together: the closest one above swaps its whole subtree for a fallback.

Predict first

Before you read: predict

ExamHall candidate app, 09:00. The ECON-201 paper is still loading. paperPromise comes from a cache (created once, outside render) and has not resolved yet.

What does the candidate see while the paper is still loading?

Code for this question
function QuestionList() {  const paper = use(paperPromise);  return <ol>{paper.questions.map(q => <li key={q}>{q}</li>)}</ol>;}function Timer() { return <p>Time left 90:00</p>; } <main>  <h1>ECON-201</h1>  <Suspense fallback={<p>Loading paper…</p>}>    <QuestionList />    <Timer />  </Suspense></main>
Pick one

The 30-second version

A component suspends when it reads a pending promise with use() or renders unloaded lazy code. The closest <Suspense> above it shows its fallback instead of all its children; content outside that boundary still shows. An error boundary likewise replaces its subtree when a descendant throws while rendering, but not in event handlers or timers.

Boundaries decide what waits and what fails together: the closest one above swaps its whole subtree for a fallback.

How it works

Step 1 of 9

Reading a pending promise suspends the component. use(promise), or a lazy component whose code has not loaded, stops that render; nothing is returned.

Analogy: A stage manager and section curtains (operations: theatre stage management)

A play runs on a stage split into sections, each with its own curtain. The stage manager opens a section only when every set piece behind that curtain is in place.

In the analogy In React
A section's curtain stays shut until every piece behind it is set A <Suspense> boundary shows its fallback until every descendant is ready
While it is shut, the audience sees only the curtain, not the pieces already set The fallback replaces the whole subtree, ready siblings included
Sections with no shut curtain play on Content outside the boundary commits
Where the curtains hang decides what the audience waits for together Boundary placement decides what loads together
Mid-show, the manager keeps the current scene lit until the next one is fully set startTransition keeps the old UI while the new one suspends
A set collapses in one section: that curtain closes behind a "scene unavailable" card, and the show goes on An error boundary shows its fallback for its own subtree only
A spill in the lobby is front-of-house's job, not a stage incident Event-handler and timer errors are not render errors; the boundary never sees them

Breaks down when:

  • Crews build sets in parallel; a child component cannot start its fetch until its parent gets past use(). Wrong inference: "nested components load in parallel."
  • A curtain can be half drawn; a fallback is all or nothing. Wrong inference: "the ready half of the subtree shows early."
  • A half-built set survives the wait; a subtree that suspended before mounting keeps no state. Wrong inference: "its state is kept."

Contrasting case: a component that fetches in useEffect and shows its own "Loading…" flag. No boundary is involved: Suspense does not detect fetches in effects or event handlers.

In real terms: use() or lazy suspends a component, the closest <Suspense> renders its fallback for its whole subtree, and an error boundary does the same for errors thrown while rendering its subtree.

Examples, run for real

Example 1: ExamHall EXH-457, "one broken chart question blanks the whole paper"

Q3's chart data arrives as null, so QuestionWidget throws a TypeError while rendering.

no boundary: (blank)
one boundary around the paper: ECON-201 | Exam unavailable
one boundary per question (MR !781): ECON-201 | Q1 chart: 1,2 | Q2 chart: 3 | Q3: could not load, tell your proctor | Q4 chart: 4

With no boundary, React removes the whole UI. One boundary around the paper keeps the header but loses every question. A boundary per question loses only Q3 (steps 8, 3).

Example 2: what an error boundary catches

Each case throws once inside <ErrorBoundary><Widget/></ErrorBoundary>:

render                      fallback shown: yes | caught by a boundary: boom in render
useEffect                   fallback shown: yes | caught by a boundary: boom in useEffect
componentDidMount           fallback shown: yes | caught by a boundary: boom in componentDidMount
use(rejected promise)       fallback shown: yes | caught by a boundary: boom in rejected promise
lazy() load rejects         fallback shown: yes | caught by a boundary: boom in lazy load
onClick handler             fallback shown: no  | screen: widget ok | window error: boom in onClick
setTimeout callback         fallback shown: no  | screen: widget ok | uncaught exception (process): boom in setTimeout
useTransition start()       fallback shown: yes | caught by a boundary: boom in useTransition start
standalone startTransition  fallback shown: no  | screen: widget ok | window error: boom in standalone startTransition

A rejected use() promise or lazy load goes to the error boundary, not the Suspense fallback. Handler and timer errors leave the widget on screen, and so does a throw inside the standalone startTransition; only useTransition's version reaches the boundary (step 9). react.dev documents render errors; the effect and lifecycle rows were observed in React 19.3, and only the legacy docs name lifecycle methods.

Example 3: ExamHall, moving to a section that is still loading

Section reads a cached per-section promise with use(); Section B's promise is pending.

[plain setSection] B pending:        Next section | Loading section…
[startTransition] B pending:        Next section (loading…) | A1 Demand
[startTransition] B resolved:       Next section | B1 Tariffs, B2 Quotas

A plain update hides Section A behind the fallback. A Transition from useTransition keeps Section A up, and its isPending is true meanwhile (step 7).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

(p1-payments-declined.mjs, 50 runs, 1 distinct output, sequential and 8 parallel)

<ErrorBoundary fallback={<p>Payment failed, try again</p>}><PayButton /></ErrorBoundary>. PayButton renders <button onClick={pay}>{status === 'idle' ? 'Pay €40' : 'Paid'}</button>, with async function pay() { await charge(); setStatus('paid'); }. The customer clicks; charge() rejects with "card declined".

What is on screen?

Pick one

Question 2 of 6

(p2-streaming-waterfall.mjs, 50 runs, 1 distinct output, sequential and 8 parallel)

ArtistPage calls use(fetchArtist()) and then renders <Albums />, which calls use(fetchAlbums()). Both fetches go through a cache (one promise per key); requests are resolved by hand. The user opens the page.

Before anything resolves, which requests have started?

Pick one

Question 3 of 6

Which single change makes both requests start before either resolves?

Pick one

Question 4 of 6

(p3-chat-channel-transition.mjs, 50 runs, 1 distinct output, sequential and 8 parallel)

#general is shown (one message, "ana: standup at 10"). Clicking #design runs startTransition(() => setChannel('design')). <Messages channel={channel} /> sits inside <Suspense fallback={<p>Loading messages…</p>}> and reads a cached, still-pending promise for #design with use().

While it loads, what is on screen below the #design button?

Pick one

Question 5 of 6

A product page renders <h1>Desk lamp</h1> and then <Suspense fallback={<p>Loading reviews…</p>}><Reviews sku="LAMP-7" /></Suspense>. To avoid a new request on every render, the author memoized the promise: Reviews runs const promise = useMemo(() => fetchReviews(sku), [sku]) and then const reviews = use(promise). The server answers every request quickly, yet the page stays on "Loading reviews…" and the network panel keeps filling with review requests.

What is the bug?

Pick one

Question 6 of 6

A checkout page shows <h1>Checkout</h1>, a <PriceTable /> and a <SeatMap />. Each of the two reads its own cached promise with use(); the prices arrive about a second before the seats. The header must show at once, and the price table and the seat map must appear in the same frame, never one without the other.

Which arrangement does that?

Pick one

Go deeper

Pro tips
  1. Retry a failed region by remounting its error boundary with a new key.
    • When it helps: a per-question fallback with a "Retry" button (step 8; EXH-457).
    • It prints first load: Retry Q3 | caught by a boundary: …, then after Retry (boundary key 0 -> 1): Q3 chart: 5,6.
    • Status: RAN (React 19.3.0, Node 24.19.0). A new key gives a fresh boundary with no error state; the child renders again.
  2. Log what boundaries caught with createRoot(container, { onCaughtError, onUncaughtError }).
    • When it helps: telling a boundary-caught render error from an uncaught one in monitoring (steps 8–9).
    • Verify: every script here records errors through these options.
    • Status: RAN. The option names and meanings match react.dev's createRoot reference (fetched 2026-09-27). Browser reporting of handler errors (window.onerror) was seen only in jsdom here.
Sources
  • react.dev, <Suspense>: https://react.dev/reference/react/Suspense. Covers what activates a boundary (lazy, use), that effect and handler fetches are not detected, that the closest boundary shows the fallback, that no state is kept for a render that suspended before its first mount, and that already-shown content hides again "unless the update causing it was caused by startTransition or useDeferredValue". Fetched and confirmed; valid as of 2026-09-27.
  • react.dev, use: https://react.dev/reference/react/use. Covers suspending while the promise is pending, caching ("Promises created during render are recreated on every render"), rejected promises going to the nearest error boundary, and no try/catch around use. Fetched and confirmed; valid as of 2026-09-27.
  • react.dev, Component, "Catching rendering errors with an error boundary": https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary. It says getDerivedStateFromError is called when a child "throws an error during rendering", and lists what is not caught: event handlers, server rendering, errors in the boundary itself, and asynchronous code such as setTimeout, with one exception: "the usage of the startTransition function returned by the useTransition Hook". The useTransition page adds that the standalone function "is not associated with a component", so an Error Boundary cannot handle its errors. It does not mention effects or lifecycle methods. Fetched and confirmed; valid as of 2026-09-27.
  • Supporting: https://react.dev/reference/react/lazy (rejections go to the nearest error boundary), https://react.dev/reference/react/useTransition ("Displaying an error to users with an error boundary"), https://react.dev/reference/react-dom/client/createRoot (onCaughtError, onUncaughtError), and the legacy page https://legacy.reactjs.org/docs/error-boundaries.html ("during rendering, in lifecycle methods, and in constructors"). All fetched 2026-09-27.

Checked against the tools and docs as of 2026-09-27.