Throwing Promises: The Mechanism
What you'll learn
- The exact 8-step sequence that happens when a component suspends
- How the
use()hook turns a promise into a synchronous-looking value - Why the promise must be created and cached outside the render path, and the infinite-loop bug that bites everyone who forgets
- Why throwing mid-render is perfectly safe (render purity, again)
- How
lazy()runs on the exact same machinery
Last chapter gave you the sentence: "if anything inside this box isn't ready yet, show this fallback instead." This chapter opens the box. The mechanism underneath Suspense is genuinely surprising the first time you hear it, your component throws a promise like an exception, and then genuinely obvious, because it's the only design that fits everything you already know about render purity.
The 8-step mechanism
Here is the entire lifecycle of a suspend, from first render to real content on screen. Replay this mentally until it's automatic:
- During render, a component calls
use(promise)(or renders alazycomponent whose code hasn't loaded). - The promise is pending, so the component can't finish. It throws the promise, literally
throw promise, like an exception. - React catches the thrown promise and walks up the tree to the nearest Suspense boundary.
- React discards the render attempt in progress. This is safe, renders are pure calculations with no visible effects (you learned this in Part 2).
- React commits the boundary's fallback UI to the screen.
- React attaches a
.thencallback to the thrown promise. - When the promise resolves, that callback schedules a re-render of the boundary's children.
- This time
use(promise)finds the promise fulfilled and returns the value synchronously. The render completes, and React commits the real content.
Eight steps, one idea: render is allowed to fail in a very specific, resumable way.
Jargon: "suspend". What a component does when it throws a pending promise during render. "The component suspended" means "the render couldn't finish because data wasn't there, and React knows exactly what to wait for."
Seeing it in a complete component
Here's a full, runnable-shaped example, a user page that reads data during render:
import { Suspense, use } from 'react';
import { Spinner } from './Spinner';
// --- The cache lives OUTSIDE any component (more on why below) ---
const userCache = new Map();
function fetchUserCached(id) {
if (!userCache.has(id)) {
const promise = fetch(`/api/users/${id}`)
.then((res) => res.json());
userCache.set(id, promise); // store the PROMISE, not the result
}
return userCache.get(id);
}
// --- A component that reads data during render ---
function UserProfile({ userId }) {
const user = use(fetchUserCached(userId));
return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
// --- The boundary decides what "waiting" looks like ---
export default function UserPage() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
);
}
What happens:
- React renders
UserPage→UserProfile. UserProfilecallsfetchUserCached(1), which starts the fetch and returns the pending promise.use(promise)sees a pending promise → the component throws it.- React catches it at the
<Suspense>boundary, throws away the half-rendered profile, and commits<Spinner />. - React subscribes to the promise. The fetch finishes; the promise resolves with the user JSON.
- React re-renders
UserProfile. This timefetchUserCached(1)returns the same, now-resolved promise from the cache. use(promise)sees a fulfilled promise → returns the user object immediately, synchronously.- The render completes; the profile replaces the spinner.
Step 7 deserves a second look. use() is not a normal hook that "fetches". It's a promise un-wrapper: fulfilled → hand back the value right now; pending → throw; rejected → throw the error (which an error boundary catches, chapter 5). That's why the finished component contains zero asynchronous code.
Jargon: "
use()". A hook that reads the value of a promise (or a context) during render. Unlike other hooks it can be called conditionally, because its whole job is to either return a value now or suspend the render until it can.
The bug everyone writes once: the infinite suspend loop
Look again at where the promise comes from. It's stored in a Map outside the component, keyed by id. This is not a stylistic choice, it's load-bearing. Watch what happens if you create the promise inside render:
// BROKEN — do not copy
function UserProfile({ userId }) {
const user = use(fetch(`/api/users/${userId}`).then((r) => r.json()));
return <h1>{user.name}</h1>;
}
What happens (the classic bug):
- Render starts. A brand-new promise is created. It's pending.
use()throws it. React shows the fallback and waits.- The promise resolves. React retries the render.
- Render starts... and creates another brand-new promise. Also pending.
- Suspend. Retry. New promise. Suspend. Retry. New promise. Forever.
The spinner never goes away; the network tab fills with hundreds of identical requests. Each retry creates a fresh promise, so from React's perspective the data never arrives, you keep handing it a different unanswered question.
The rule, memorized:
The promise passed to
use()must be stable across renders. Create it outside render, cache it, or get it from something that does (a router loader, a Suspense-aware library, a module-level cache).
Pseudocode model, not real source:
// Conceptually, use() does something like this:function use(promise) {if (promise.status === 'fulfilled') return promise.value;if (promise.status === 'rejected') throw promise.reason;// pending: tell React what to wait for, then bail out of renderthrow promise;}
React can't stop you from creating promises in render, JavaScript allows it, so this footgun is yours to avoid. Frameworks and data libraries exist largely to manage this caching for you correctly (including deduping, invalidation, and cleanup).
Why throwing is safe: render purity strikes again
"Throw an exception out of my component?!" sounds reckless until you remember the ground rule from Part 2:
Rendering is a pure calculation. It produces element descriptions and nothing else.
A render that gets thrown away halfway hasn't done anything: no DOM was mutated, no subscriptions created, no state committed. Effects don't run for a tree that never commits. The discarded attempt is like a half-filled form you toss in the recycling bin, the world never saw it.
This is why the "throw and retry" strategy works at all:
- Discard is free. The thrown-away render left no trace, so there's nothing to clean up.
- Retry is correct. Rendering is a function of props, state, and the promise's status. On retry, the promise is fulfilled, so the same function now returns a finished tree.
- No torn UI. React commits the fallback atomically and later commits the real content atomically. Users never see half a profile.
If renders had side effects, if calling your component could fire a network request AND mutate the screen, throwing would be dangerous, because the discarded attempt's effects would linger. Purity is what makes the whole trick sound. (It's also why React is so strict about keeping event handlers, not render bodies, as the place for side effects.)
lazy() is the same machine wearing a different coat
Code splitting looks like a separate feature, but it's the identical mechanism:
import { Suspense, lazy } from 'react';
import { Spinner } from './Spinner';
const AdminPanel = lazy(() => import('./AdminPanel'));
export default function App() {
return (
<Suspense fallback={<Spinner />}>
<AdminPanel />
</Suspense>
);
}
What happens:
- The first time React tries to render
AdminPanel, the module hasn't loaded. lazyinternally holds a promise for the dynamicimport(). While it's pending, renderingAdminPanelthrows that promise, exactly likeuse()does.- The boundary catches it, shows the spinner, waits.
- The bundle arrives; the promise resolves with the module.
- React retries the render; this time
lazyhas the real component and renders it normally.
One machinery, two payloads. "The code isn't here yet" and "the data isn't here yet" are the same sentence to Suspense: a promise I'm waiting on.
A trap of your own making: don't swallow React's throw
Because suspension is implemented with throw, your try/catch can interfere:
// BROKEN — do not copy
function UserProfile({ userPromise }) {
let user;
try {
user = use(userPromise);
} catch (anything) {
return <Spinner />; // oops: caught the promise React threw
}
return <h1>{user.name}</h1>;
}
What happens: on the first render, use() throws the pending promise, and your catch grabs it before React ever sees it. React thinks the render completed and commits your hand-rolled spinner. No boundary is notified, nobody subscribes to the promise, nobody schedules a retry. The UI is stuck on a spinner that nothing will ever replace.
The rule: never wrap use() (or a suspending child render) in your own catch-all. If you need real error handling around data reads, use an error boundary (chapter 5), that's what it's for. Let promises fly past your code to the boundary that's actually listening.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
use()fetches data. Reality:use()unwraps a promise someone else created, fulfilled means "return now", pending means "throw", rejected means "throw the error". - Misconception: Throwing a promise crashes the app. Reality: React explicitly catches thrown promises at Suspense boundaries; it's a designed control-flow path, not a failure.
- Misconception: The discarded render leaves side effects behind. Reality: renders are pure, nothing commits, no effects run, so discarding is clean and retrying is safe.
- Misconception: Creating the promise inside the component is fine if you memoize the component. Reality: the promise identity is what matters; it must survive across renders via a cache, a loader, or module scope.
- Misconception:
lazy()anduse()are different systems. Reality: both throw a promise to the nearest boundary; code-splitting and data-reading share one mechanism. - Misconception: You can catch the thrown promise yourself to show custom loading UI. Reality: catching it hides the signal from React, no retry is scheduled and the UI wedges. Use a boundary.
Why it works this way
- Throw beats returning. If
use()returnednullwhile pending, every component would needif (!data)branches again, the exact boolean bookkeeping Suspense exists to delete. Throwing skips all remaining render work in one motion, no matter how deep the component sits. - The promise is the perfect receipt. It already knows how to notify on completion (
.then), it already has a failure channel (rejection → error boundaries), and it's ordinary JavaScript anyone can create. - Purity makes retry trivial. Because a render is just a function call, "try again later" is the cheapest correct recovery strategy imaginable, no rollback protocol needed.
- One mechanism scales better than two. Code splitting and data fetching have identical UX needs (placeholder now, content when ready). Sharing the machinery means one set of boundary semantics to learn, place, and compose.
Try it yourself
- Build the
UserProfileexample with a deliberately slow endpoint (or wrap the fetch in a 2-secondsetTimeout). Log inside the component body. Expected: the log fires twice, once for the discarded render, once for the completing one. - Deliberately introduce the classic bug: create the promise inline in render. Expected: the fallback never disappears and the network tab shows the request repeating forever. Then fix it with a
Mapcache and watch it settle. - Wrap
use(promise)intry/catchand return your own spinner from the catch. Expected: the spinner sticks forever even after the data arrives, you intercepted React's signal. - Make the promise reject instead of resolve (e.g., fetch a 404 URL) with no error boundary. Expected: the error propagates past Suspense and crashes the render, proof that Suspense handles pending, not failure.
Recap
- A component suspends by throwing a pending promise during render; React catches it at the nearest Suspense boundary.
- The 8 steps: call
use()→ throw → catch → discard render → commit fallback →.then→ retry → commit real content. - Discarding the render is safe because renders are pure; retrying is correct because the same inputs now include a fulfilled promise.
- The promise must be stable across renders, cached outside the render path, or you get an infinite suspend loop.
lazy()throws its module-loading promise through the very same machinery.- Never catch-and-swallow around
use(): yourtry/catchcan steal the promise before React sees it.