Skip to content

React · Rendering and Performance

React Suspense and Concurrent Rendering — A Complete Guide

6 min read Updated 2026-06-24 Share:

Practice Suspense and Concurrent Rendering interview questions

What concurrent rendering actually means

In React 17 and below every render was synchronous and blocking. Once React started computing a new tree it ran to completion — nothing else on the main thread could run until it finished. A slow render meant a frozen UI.

React 18's concurrent mode changes this by making rendering interruptible. React works in small time slices and regularly yields control back to the browser. If a higher-priority update arrives mid-render — say a keypress while a large list is filtering — React can pause the low-priority work, handle the urgent event, and then restart the deferred render from scratch.

import { createRoot } from 'react-dom/client'

// One-line opt-in to concurrent mode
const root = createRoot(document.getElementById('root'))
root.render(<App />)
// Legacy ReactDOM.render stays in blocking mode — no concurrent features

The important consequence for developers: render functions must be pure. React may call your component function multiple times for the same update during concurrent work. Side effects still belong in useEffect, which runs exactly once per commit regardless of how many times React speculatively rendered.

Suspense boundaries

A <Suspense> boundary is a declarative loading fence. When any component inside it is not ready to render — because a code chunk is still downloading, or a data fetch is in-flight — React replaces the boundary's children with its fallback until every suspended component is ready.

import { Suspense, lazy } from 'react'

const HeavyChart = lazy(() => import('./HeavyChart'))

function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <HeavyChart />
    </Suspense>
  )
}

Nesting boundaries gives independent loading states to different regions. The <Sidebar> and <Feed> below each show their own skeleton without blocking each other, and without blocking the header, which renders immediately.

<>
  <Header />                                  {/* renders right away */}

  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />                               {/* resolves on its own timeline */}
  </Suspense>

  <Suspense fallback={<FeedSkeleton />}>
    <Feed />                                  {/* resolves independently */}
  </Suspense>
</>

The golden rule: one Suspense boundary per independently loading region, not one per component.

Suspense for data fetching and React.use()

React 18 extended Suspense beyond lazy-loaded code to cover data — any Promise a component declares it depends on. The mechanism is the same: the component throws a Promise, React catches it, shows the fallback, and re-renders when the Promise resolves.

React.use() (stable in React 19, experimental in React 18.3) is the standard way to trigger this:

import { use, Suspense } from 'react'

// Kick off the fetch BEFORE rendering — "render-as-you-fetch"
const userPromise = fetch('/api/me').then(r => r.json())

function UserCard() {
  const user = use(userPromise)   // suspends if pending; throws if rejected
  return <h2>Hello, {user.name}</h2>
}

function App() {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserCard />
    </Suspense>
  )
}

The critical pattern above is render-as-you-fetch: the Promise is created outside the component, before React renders it. This eliminates the mount-then-fetch waterfall where a component has to render once just to know what to fetch.

useTransition for non-urgent updates

useTransition lets you classify a state update as low priority so React keeps the current UI interactive while computing the new one.

import { useState, useTransition } from 'react'

function SearchPage({ items }) {
  const [query, setQuery]     = useState('')
  const [results, setResults] = useState(items)
  const [isPending, startTransition] = useTransition()

  function handleChange(e) {
    setQuery(e.target.value)                          // urgent — input stays snappy
    startTransition(() => {
      setResults(items.filter(i =>
        i.name.toLowerCase().includes(e.target.value)
      ))                                              // non-urgent — can be deferred
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating…</span>}
      <ul>{results.map(i => <li key={i.id}>{i.name}</li>)}</ul>
    </>
  )
}

isPending is true while the deferred render is in progress, making it easy to show a subtle indicator. Because startTransition wraps the setter, you control the deferral at the source of the update.

useDeferredValue for derived state

useDeferredValue is the complement to useTransition. Instead of wrapping the setter, it wraps a received value — useful when the state is owned by a parent you cannot modify.

import { useDeferredValue } from 'react'

function ExpensiveList({ query }) {
  // query comes from a parent — we can't add startTransition there
  const deferredQuery = useDeferredValue(query)

  const filtered = heavyFilter(deferredQuery)   // runs with the stale value first
  return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>
}

React renders the parent immediately with the latest query (for the input), then re-renders ExpensiveList in the background with deferredQuery catching up. The user sees a snappy input and a briefly stale list — which is almost always preferable to a janky UI.

When to choose which:

ScenarioAPI
You own the state setteruseTransition
Value arrives as a prop/contextuseDeferredValue
Need to show a pending indicatoruseTransition (has isPending)
Deferring an expensive child renderEither; useDeferredValue is simpler

Streaming SSR with Suspense in Next.js

Next.js App Router runs React Server Components on the server and uses HTTP streaming to send HTML to the browser in chunks. Each <Suspense> boundary is a streaming flush point: the shell HTML (layout, headings, skeleton fallbacks) arrives instantly, and as each async Server Component resolves its await, Next.js streams the finished HTML chunk and a hydration script that swaps the skeleton out.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats }    from './UserStats'     // awaits a DB query
import { RecentOrders } from './RecentOrders'  // awaits a different DB query

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>

      {/* Shell arrives immediately; each chunk streams as its query resolves */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>

      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />
      </Suspense>
    </main>
  )
}

Without Suspense boundaries the server would wait for all data before sending any HTML, making Time to First Byte as slow as your slowest query. With boundaries the page is progressively useful from the first byte.

Choosing the right API

  • React.lazy + <Suspense> — always the right call for route-level code splitting.
  • React.use(promise) + <Suspense> — data fetching in React 19 / Next.js App Router.
  • useTransition — any expensive client-side state update you own (navigation, filtering).
  • useDeferredValue — expensive render triggered by a prop or context you do not own.
  • createRoot — the single switch that unlocks all of the above; migrate from ReactDOM.render as a first step.

Concurrent features are additive. You can adopt them incrementally: upgrade to createRoot, wrap a slow route in <Suspense>, add useTransition to the one search box that feels sluggish. Each step is independent and reversible.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel