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?
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>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
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

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?
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?
Question 3 of 6
Which single change makes both requests start before either resolves?
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?
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?
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?
Go deeper
Pro tips
- 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: …, thenafter Retry (boundary key 0 -> 1): Q3 chart: 5,6. - Status: RAN (React 19.3.0, Node 24.19.0). A new
keygives a fresh boundary with no error state; the child renders again.
- 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
createRootreference (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 bystartTransitionoruseDeferredValue". 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 notry/catcharounduse. 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 saysgetDerivedStateFromErroris 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 assetTimeout, with one exception: "the usage of thestartTransitionfunction returned by theuseTransitionHook". TheuseTransitionpage 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.
Related lessons
Checked against the tools and docs as of 2026-09-27.