Tutorial

How to Fetch with React Suspense

Fetch with React Suspense so the component suspends until data is ready, and a fallback shows without a loading state in every component.

On this page

  1. Introduction
  2. Throw the promise
  3. Wrap in Suspense
  4. Set a fallback
  5. Handle errors with a boundary
  6. Stream with SSR
  7. Test Suspense
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of declarative loading is not writing the code — it is deciding what the code is allowed to reject. A Suspense setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in Suspense, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

Choosing Suspense for declarative loading is a trade, not a winner. You give up some flexibility to get a hard boundary, and the exchange pays off when the boundary catches a class of bugs that would otherwise surface at 3 a.m. Document the trade at the call site so the next reader does not undo the guard thinking it is a bug. The reader who does not know why the guard exists is the reader who removes it.

The sections below walk through the concrete decisions: where to put the suspense check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Throw the promise

Throw the promise is the first decision because it determines what the rest of the Suspense pipeline can assume. A wrong choice here turns into declarative loading under load, and the invariant you thought Suspense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Throw the promise is the one most teams skip: drive suspense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

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

The typescript above is the shape to ship for declarative loading. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Wrap in Suspense

The mechanism behind Wrap in Suspense is straightforward in Suspense, but the consequences ripple. A wrong choice here turns into declarative loading under load, and the invariant you thought Suspense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Wrap in Suspense is the one most teams skip: drive suspense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Set a fallback

Set a fallback is where most Suspense implementations leak performance. A wrong choice here turns into declarative loading under load, and the invariant you thought Suspense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Set a fallback is the one most teams skip: drive suspense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('shows fallback while pending', () => {
  render(<Suspense fallback="loading"><Profile/></Suspense>);
  expect(screen.getByText('loading')).toBeInTheDocument();
});

Pin the behavior with this test. A Suspense setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Handle errors with a boundary

The hard part of Handle errors with a boundary is not the happy path — it is what happens when suspense fails halfway. A wrong choice here turns into declarative loading under load, and the invariant you thought Suspense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Handle errors with a boundary is the one most teams skip: drive suspense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Stream with SSR

Stream with SSR should be observable without a debugger. A wrong choice here turns into declarative loading under load, and the invariant you thought Suspense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Stream with SSR is the one most teams skip: drive suspense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test Suspense

Test Suspense is a trade, not a winner. A wrong choice here turns into declarative loading under load, and the invariant you thought Suspense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test Suspense is the one most teams skip: drive suspense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in declarative loading all reduce to one mistake: treating Suspense as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the suspense boundary so declarative loading enters unvalidated.
  • Assuming Suspense is fast without measuring the tail latency under load.
  • Letting declarative loading fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the suspense failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: Suspense fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives suspense to the failing condition and assert the system degrades gracefully. Then declarative loading is a property you maintain, not a feature you hope for.

Sources: React Suspense, Suspense for data

FAQ

Suspense for data?
Yes with TanStack Query or Relay. Not with raw fetch — you need a Suspense cache.
Nested Suspense?
Yes. Each boundary has its own fallback. The closest one shows.

Related Examples