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:

  1. Server: fetch product metadata (title, price, SEO) — no client JS required for first paint.
  2. Suspense boundary A: stream reviews from a slower API.
  3. Suspense boundary B: stream recommendations (often ML-ranked, higher latency).
  4. 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.

  1. Move product fetch to a Server Component; HTML includes title, price, and hero image in the first chunk.
  2. Wrap <Reviews /> in Suspense with a three-row skeleton; reviews fetch runs in parallel on the server and streams when ready.
  3. Wrap <Recommendations /> in a separate boundary so a slow ML service does not delay reviews.
  4. Add useTransition on the “sort reviews” dropdown so re-sorting 400 reviews does not block scroll.
  5. 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.all on the server; sequential await in 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 useTransition for 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; useTransition keeps 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