Guide
React Suspense and concurrent rendering explained
Harbor Commerce’s product detail page blocked the entire layout for 1.8 seconds
while a single useEffect fetched reviews, inventory, and cross-sell
recommendations in sequence. Users saw a blank white screen; Lighthouse reported a
2.4 s
LCP
dominated by JavaScript hydration. Splitting the page into three
Suspense boundaries — hero shell, reviews panel, and
“You may also like” rail — let React paint the product title,
price, and buy button in 320 ms while slower sections streamed in behind
skeleton fallbacks. React Suspense is the API that lets components
“wait” for async data without blocking siblings, and
concurrent rendering is the scheduler underneath that keeps input
responsive during those waits. This guide explains Suspense boundaries and fallbacks,
useTransition and useDeferredValue, how streaming
SSR
pairs with Suspense in Next.js, a Harbor Commerce refactor worked example, a decision
table, common pitfalls, and a production checklist.
Why blocking renders hurt perceived performance
Classic React data fetching often follows one of two patterns: fetch in
useEffect after mount (blank screen, then waterfall) or fetch at the
route level and pass props down (fast first paint, but one slow query blocks
everything). Both treat the page as a single atomic unit.
Real product pages are not atomic. The hero image and price are critical; review stars and “customers also bought” are secondary. Users can decide to purchase before reviews finish loading — if you let them see the hero first.
Suspense inverts the default: declare where loading states belong, let React coordinate partial UI, and use concurrent features so typing in a search box does not stutter while a heavy list re-renders behind a transition.
How Suspense boundaries work
A Suspense boundary wraps a subtree that may suspend (pause
rendering) while waiting for data or code. React shows the boundary’s
fallback until the subtree is ready, then swaps in the real UI without
unmounting unrelated siblings outside the boundary.
<Suspense fallback={<ProductReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
Key mechanics:
- Granularity. One boundary per independently loaded region (reviews, recommendations, comments) — not one giant boundary around the whole page unless you want a full-page spinner.
- Nested boundaries. Outer boundaries can show coarse skeletons; inner boundaries refine detail as nested data resolves.
- No data fetching API by itself. Suspense is a coordination
primitive. Data must come from a Suspense-aware source: React Server Components
async components, frameworks like Next.js
loading.js, or libraries that throw promises (TanStack Query v5 suspense mode, Relay). - Error boundaries are separate. Wrap Suspense subtrees with error boundaries so a failed review fetch does not crash the buy button.
On the server, Suspense enables streaming HTML: send the static shell first, flush fallbacks for slow segments, then stream replacement markup as each segment resolves — improving TTFB and LCP versus waiting for all queries to finish.
Concurrent rendering: transitions and deferred values
React 18’s concurrent renderer can interrupt, pause, and resume work. Two hooks expose that power in application code:
useTransition
Marks a state update as non-urgent. Urgent updates (keystrokes, clicks) commit immediately; the transition update renders in the background and commits when ready, keeping the old UI visible with optional pending indicators.
const [isPending, startTransition] = useTransition();
function onSearch(q) {
setInput(q); // urgent — update the text field now
startTransition(() => setFilter(q)); // deferred — re-filter the big list later
}
useDeferredValue
Defers re-rendering a value derived from props or state. Useful when you
cannot wrap the setter in startTransition (e.g. value comes from a
parent). Pair with memoized child lists so expensive renders trail the input by a
frame or two.
Concurrent features complement Suspense: Suspense handles initial async gaps; transitions handle updates that would otherwise block the main thread. They pair naturally with virtual scrolling on large filtered lists.
Next.js and React Server Components
In the App Router, async Server Components can await fetches directly.
Wrap client islands in <Suspense> at the page or layout level;
use loading.tsx for route-level fallbacks. Client components still
suspend when using suspense-enabled data libraries.
Typical layering for a product page:
- Server: fetch product metadata (title, price, SEO) — no client JS required for first paint.
- Suspense boundary A: stream reviews from a slower API.
- Suspense boundary B: stream recommendations (often ML-ranked, higher latency).
- Client island: cart button with optimistic updates — outside Suspense so it never waits on reviews.
See Next.js fundamentals for routing, layouts, and server/client component boundaries.
Worked example: Harbor Commerce product page
Problem. Sequential useEffect fetches: product (120 ms),
reviews (680 ms), recommendations (940 ms). Total time-to-interactive
felt like 940 ms even though the hero was ready at 120 ms.
Refactor.
- Move product fetch to a Server Component; HTML includes title, price, and hero image in the first chunk.
- Wrap
<Reviews />in Suspense with a three-row skeleton; reviews fetch runs in parallel on the server and streams when ready. - Wrap
<Recommendations />in a separate boundary so a slow ML service does not delay reviews. - Add
useTransitionon the “sort reviews” dropdown so re-sorting 400 reviews does not block scroll. - Keep add-to-cart in a client component with optimistic cart state — never inside a suspending subtree.
Results. LCP dropped from 2.4 s to 0.9 s (hero in first stream chunk). Reviews appeared at ~1.1 s with skeleton visible from 0.3 s. Bounce rate on mobile product pages fell 11% in A/B over two weeks.
Decision table: when to use what
| Scenario | Recommended approach | Avoid |
|---|---|---|
| Initial page load with slow secondary sections | Multiple Suspense boundaries + streaming SSR | Single global loading spinner for the whole route |
| Search/filter over a large client list | useTransition + memoized list (optionally virtualized) |
Synchronous filter on every keystroke without transition |
| Deferred prop from parent you do not control | useDeferredValue on the prop |
Blocking the parent’s render tree |
| Mutation feedback (cart, like button) | Optimistic UI outside Suspense | Suspending the entire page on POST |
| Code splitting a heavy chart library | React.lazy + Suspense boundary around the chart |
Importing chart lib in the root bundle |
| Static marketing page | SSG without Suspense complexity | Client-only fetch for content available at build time |
Common pitfalls
- One boundary for the entire app. A single top-level Suspense fallback recreates the full-page spinner you were trying to escape.
- Suspending critical UI. Never wrap the primary CTA or checkout flow in the same boundary as experimental widgets.
- Waterfalls inside a boundary. Parallelize fetches with
Promise.allon the server; sequentialawaitin one component still serializes work. - Missing error boundaries. A rejected promise in a suspending component without an error boundary white-screens the subtree.
- Layout shift when fallback swaps. Size skeletons to match final content height to protect CLS.
- Transitions on urgent work. Do not wrap navigation that must
feel instant, or form validation that blocks submit, in
startTransition. - Mixing legacy useEffect fetch with Suspense. Components that fetch after mount do not suspend; you get fallbacks that flash away immediately then blank sections.
Production checklist
- Map the page into critical (hero) vs deferrable (reviews, recs) regions before writing boundaries.
- One Suspense boundary per independent async data source.
- Pair each boundary with an error boundary and a sized skeleton fallback.
- Parallelize server fetches; profile with React DevTools “Timings” tab.
- Use
useTransitionfor expensive list updates triggered by input. - Keep cart, auth, and payment actions in non-suspending client islands.
- Verify LCP element is in the first HTML stream chunk, not behind Suspense.
- Test slow 3G: fallbacks should appear within one frame of shell paint.
- Document which data libraries support Suspense in your stack (Query, Relay, RSC).
- Monitor CLS when fallbacks resolve — adjust skeleton dimensions if needed.
Key takeaways
- Suspense coordinates partial loading — siblings outside a boundary render without waiting.
- Boundaries should match product priority — hero first, nice-to-have sections stream later.
- Concurrent hooks handle updates — Suspense handles
initial async gaps;
useTransitionkeeps typing smooth. - Streaming SSR multiplies the benefit — users see real HTML before slow APIs finish.
- Optimistic actions stay outside Suspense — never block purchase on reviews loading.
Related reading
- React fundamentals explained — components, hooks, and the render cycle Suspense builds on
- Next.js fundamentals explained — App Router, Server Components, and streaming patterns
- SSR, CSR, SSG and ISR explained — when to stream versus pre-render statically
- Optimistic UI updates explained — instant feedback for mutations alongside Suspense loading