Skip to content

Suspense and Concurrent Rendering Interview Questions & Answers

15 questions Updated 2026-06-24 Share:

React Suspense and concurrent rendering interview questions — Suspense for data fetching, useTransition, useDeferredValue, startTransition, concurrent features, and React 18 rendering model.

Read the in-depth guideReact Suspense and Concurrent Rendering — A Complete Guide(opens in new tab)
15 of 15

React Suspense is a mechanism that lets a component declare that it is not ready to render yet. While the component is suspended, React renders a fallback UI in its place and picks up where it left off once the component is ready.

import { Suspense, lazy } from 'react'

const Chart = lazy(() => import('./Chart'))   // code-split chunk

function Dashboard() {
  return (
    <Suspense fallback={<p>Loading chart…</p>}>
      {/* React renders the fallback while Chart's chunk downloads */}
      <Chart />
    </Suspense>
  )
}

Suspense works by catching a special thrown Promise. When a component throws a Promise, React walks up the tree looking for the nearest <Suspense> boundary. That boundary shows its fallback until the Promise resolves, then re-attempts to render the suspended subtree.

Suspense was originally shipped only for React.lazy. In React 18 it was extended to data fetching when paired with a Suspense-compatible data source (e.g., frameworks that implement the promise-throwing protocol, or the new React.use() hook).

Rule of thumb: Think of <Suspense> like an error boundary but for loading states — place it wherever you want a loading fallback, not necessarily right above every async component.

The fallback prop accepts any React node and is rendered whenever any child inside the <Suspense> boundary is currently suspended (i.e., waiting for a lazy import or a data fetch to complete).

<Suspense fallback={<Spinner size="lg" />}>
  <UserProfile userId={id} />   {/* may suspend while fetching */}
</Suspense>

Key timing rules:

  • The fallback appears immediately when a child suspends on the initial render.
  • During a transition (startTransition / useTransition), React keeps showing the previous UI — not the fallback — until the new tree is ready. This avoids unwanted spinners during navigations.
  • If a child suspends after the boundary already committed (e.g., a nested update), React shows the fallback again only if the boundary has not already resolved.

fallback should be lightweight. A heavy fallback that itself needs data defeats the purpose — keep it a skeleton, spinner, or placeholder.

Rule of thumb: One Suspense boundary per independent loading region. Nesting boundaries gives fine-grained control; a single top-level boundary gives a coarser "page is loading" experience.

Lazy loading (React.lazy) uses Suspense to defer the download of a JavaScript bundle. It is fully supported in React 16.6+ and works in both legacy and concurrent mode.

Data fetching with Suspense requires the data source to implement the throw-a-Promise protocol, which React 18+ handles reliably in concurrent mode. React itself does not fetch data — frameworks like Next.js, Relay, or SWR wire this up for you.

// 1. Lazy loading — supported everywhere
const Modal = lazy(() => import('./Modal'))

// 2. Data fetching via React.use() — React 18+
function Profile({ userPromise }) {
  const user = use(userPromise)   // throws the promise if pending
  return <h1>{user.name}</h1>
}

// Parent wraps both the same way
<Suspense fallback={<Skeleton />}>
  <Modal />          {/* or */}
  <Profile userPromise={fetchUser(id)} />
</Suspense>

The fallback mechanism is identical; the difference is what triggers the suspend: a missing chunk vs. unresolved data.

Rule of thumb: Use React.lazy for route-level code splitting today; use data Suspense only through a framework or library that supports it — hand-rolling the throw-a-Promise protocol is fragile.

A Suspense-compatible data source is any library or API that signals "not ready yet" by throwing a Promise when React tries to render a component that needs its data. React catches the thrown Promise, shows the nearest Suspense fallback, and re-renders the component after the Promise resolves.

// Simplified read() that implements the protocol
function createResource(promise) {
  let status = 'pending', result
  const suspender = promise.then(
    data  => { status = 'success'; result = data },
    error => { status = 'error';   result = error }
  )
  return {
    read() {
      if (status === 'pending')  throw suspender   // suspend!
      if (status === 'error')    throw result      // bubble to error boundary
      return result                                 // return data when ready
    }
  }
}

Built-in or framework-level Suspense-compatible sources in 2024:

  • React.lazy (code splitting)
  • React.use(promise) (React 18+)
  • Next.js App Router async Server Components
  • Relay's useFragment / useLazyLoadQuery
  • SWR and React Query (experimental Suspense mode)

Rule of thumb: Never throw a raw Promise from your own components — use React.use() or a library. The protocol has subtle caching requirements that are easy to get wrong.

useTransition returns [isPending, startTransition]. Wrapping a state update inside startTransition tells React that the update is non-urgent: React can interrupt it, keep the current UI interactive, and yield to higher-priority work (e.g., typing) while computing the new tree.

import { useState, useTransition } from 'react'

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

  function handleChange(e) {
    setQuery(e.target.value)                 // urgent — update input immediately
    startTransition(() => {
      setResults(heavyFilter(e.target.value)) // non-urgent — can be deferred
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultList items={results} />
    </>
  )
}

isPending is true while the transition render is in flight, letting you show a subtle loading indicator without hiding the current content.

Rule of thumb: Use useTransition when a state change triggers an expensive re-render (e.g., filtering a large list, navigating to a heavy route) and you want the UI to stay responsive during it.

startTransition is a standalone function imported directly from React. It marks the state updates inside its callback as non-urgent transitions, identical to the startTransition returned by useTransition.

import { startTransition } from 'react'

// Use the standalone import when you don't need isPending
function handleTabChange(tab) {
  startTransition(() => {
    setActiveTab(tab)   // non-urgent — React can interrupt if needed
  })
}

The key difference:

startTransition (import) useTransition (hook)
Returns isPending No Yes
Usable outside components Yes No (hook rules)
Usable in event handlers Yes Yes

Both produce the same concurrent behavior — the standalone version is simply for cases where you do not need to reflect the pending state in UI, or when you are in a utility function outside a component.

Rule of thumb: Prefer useTransition inside components so you can expose isPending for a loading indicator. Use the standalone startTransition in router libraries, event utilities, or anywhere hooks cannot be called.

useDeferredValue accepts a value and returns a deferred copy that lags behind the original during concurrent rendering. React renders the urgent update with the latest value first, then re-renders with the deferred value in the background when the browser is idle.

import { useState, useDeferredValue } from 'react'

function FilteredList({ items }) {
  const [filter, setFilter] = useState('')
  const deferredFilter = useDeferredValue(filter)
  // deferredFilter may be one or more renders behind `filter`

  const visible = items.filter(i =>
    i.name.toLowerCase().includes(deferredFilter)
  )

  return (
    <>
      <input
        value={filter}
        onChange={e => setFilter(e.target.value)}  // stays snappy
      />
      <List items={visible} />   {/* re-renders are deferred */}
    </>
  )
}

The component that reads deferredFilter renders with the stale value while the urgent render (updating the input) commits first — the user sees no jank.

Rule of thumb: Use useDeferredValue when you receive a value from a prop or context that you cannot control with startTransition — it lets you defer a derived expensive render without touching where the value originates.

Both APIs defer work so React can keep the UI responsive, but they operate at different points in the data flow.

useTransition wraps the state setter — you control which update is non-urgent at the source.

useDeferredValue wraps the value downstream — you defer the expensive consumption of a value you didn't produce (e.g., a prop from a parent or a context value).

// useTransition — you own the state setter
const [isPending, startTransition] = useTransition()
startTransition(() => setItems(newItems))   // mark update as non-urgent

// useDeferredValue — someone else sets the state; you defer reading it
function ExpensiveChild({ query }) {         // query comes from a parent
  const deferredQuery = useDeferredValue(query)
  const results = heavySearch(deferredQuery) // runs behind the scenes
  return <List items={results} />
}
useTransition useDeferredValue
Controls the state update the consumed value
Exposes isPending Yes No
Use when you own the setter the value comes from outside

Rule of thumb: Reach for useTransition first — it is more explicit. Fall back to useDeferredValue when the value originates in a parent you cannot modify.

Tearing is a visual inconsistency where different parts of the UI read the same external store at different points in time during a concurrent render, producing a UI where some components show an old value and others show a new value simultaneously.

// External mutable store (not React state)
let externalColor = 'blue'

function A() { return <div style={{ color: externalColor }} /> }
function B() { return <div style={{ color: externalColor }} /> }
// If externalColor changes to 'red' mid-render:
// A → reads 'blue', B → reads 'red' → tearing

React prevents tearing for React-managed state automatically because concurrent renders compute a snapshot and commit it atomically. However, external stores (Redux, Zustand, Jotai, custom globals) can still tear if they are not integrated correctly.

React 18 introduced the useSyncExternalStore hook to fix this. It forces the store subscription to produce a synchronous, consistent snapshot that React can safely read without tearing.

import { useSyncExternalStore } from 'react'

const color = useSyncExternalStore(
  store.subscribe,          // subscribe fn
  store.getSnapshot,        // synchronous snapshot
  store.getServerSnapshot   // SSR snapshot
)

Rule of thumb: If you integrate a custom external store with concurrent React, always use useSyncExternalStore — never read from a mutable global directly inside a render.

ReactDOM.createRoot is the React 18 API that enables concurrent mode. The legacy ReactDOM.render runs React in legacy (blocking) mode where every render is synchronous and cannot be interrupted.

// Legacy mode — React 17 and below (or React 18 opt-out)
import ReactDOM from 'react-dom'
ReactDOM.render(<App />, document.getElementById('root'))

// Concurrent mode — React 18+
import { createRoot } from 'react-dom/client'
const root = createRoot(document.getElementById('root'))
root.render(<App />)

Key differences:

ReactDOM.render createRoot
Rendering mode Synchronous / blocking Concurrent (interruptible)
useTransition works No Yes
Automatic batching Only inside event handlers All state updates
Suspense for data Limited Full

Switching to createRoot is the single change needed to opt the entire app into React 18 concurrent features. Third-party libraries may need updates to be concurrent-safe.

Rule of thumb: All new React 18 apps should use createRoot. Migrate existing apps by searching for ReactDOM.render — it is the single switch that unlocks every concurrent feature.

In legacy (blocking) mode, once React starts rendering a tree it runs synchronously to completion — no other JS can run until it finishes. Large renders block the main thread and cause jank.

In concurrent mode, React renders in small chunks and regularly yields control back to the browser between chunks. If a higher-priority update arrives (e.g., a keypress), React discards the in-progress lower-priority render, handles the urgent work, and then restarts the lower-priority render from scratch.

// React can pause this expensive render mid-way
// if the user types into an input while it is running
startTransition(() => {
  setItems(filterLargeDataset(query))  // low priority
})

// Keypress → React interrupts the transition render,
// updates the input (high priority), then resumes/restarts
// the filterLargeDataset render

This is why render functions must be pure and side-effect free — React may call them multiple times for the same update. Effects (useEffect, useLayoutEffect) still run only once per committed render.

Rule of thumb: Treat concurrent rendering as an implementation detail — write pure render functions and put side effects in useEffect, and React's scheduler handles the rest.

You can nest <Suspense> boundaries to give independent loading states to different regions of the UI. React resolves each boundary independently — an outer boundary does not wait for an inner one to finish before showing its content.

<Suspense fallback={<PageSkeleton />}>        {/* outer */}
  <Header />

  <Suspense fallback={<SidebarSkeleton />}>   {/* inner — resolves independently */}
    <Sidebar />
  </Suspense>

  <Suspense fallback={<MainSkeleton />}>      {/* inner */}
    <MainContent />
  </Suspense>
</Suspense>

React walks up to the nearest boundary when a component suspends:

  • <Sidebar> suspending shows <SidebarSkeleton> — not <PageSkeleton>.
  • If <MainContent> suspends before <Sidebar> has resolved, both inner boundaries independently show their fallbacks.
  • The outer boundary only activates if no inner boundary catches the suspend.

Nesting boundaries lets the rest of the page stay visible and interactive while one slow section loads, improving perceived performance.

Rule of thumb: Nest Suspense boundaries at each independently loading region. Avoid a single top-level boundary if some data loads fast — it forces the whole page to show a spinner longer than necessary.

Next.js App Router renders React Server Components on the server and streams the HTML to the browser in chunks via HTTP streaming (chunked transfer encoding). Each <Suspense> boundary in the tree acts as a streaming flush point.

// app/dashboard/page.tsx (Next.js App Router)
import { Suspense } from 'react'
import { UserStats } from './UserStats'   // async Server Component
import { RecentOrders } from './RecentOrders'

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

      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />       {/* Next.js streams this chunk when ready */}
      </Suspense>

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

The shell HTML (layout, <h1>, skeletons) is sent immediately. As each async Server Component resolves its await, Next.js flushes the rendered HTML chunk and a small <script> that replaces the skeleton via React's selective hydration.

This means Time to First Byte (TTFB) is fast and the page is progressively useful rather than blank until all data is ready.

Rule of thumb: In Next.js App Router, wrap every slow data-fetching Server Component in its own <Suspense> boundary with a skeleton fallback — the streaming benefit is lost if you have no boundaries.

Fetch-on-render (the traditional pattern) starts fetching inside useEffect after the component first renders. The component renders a loading state, mounts, fires the effect, data arrives, and the component re-renders — a waterfall.

Render-as-you-fetch kicks off the fetch before rendering the component — at route load time or in an event handler — and passes the in-flight Promise into the component. The component immediately reads (and suspends on) that Promise.

// FETCH-ON-RENDER (waterfall)
function Profile() {
  const [user, setUser] = useState(null)
  useEffect(() => { fetchUser(id).then(setUser) }, [id])
  if (!user) return <Spinner />
  return <h1>{user.name}</h1>
}

// RENDER-AS-YOU-FETCH (no waterfall)
// 1. Start fetch when the user clicks the nav link
let userPromise = fetchUser(id)     // fire immediately

// 2. Pass the promise to the component
function Profile() {
  const user = use(userPromise)     // suspends until resolved
  return <h1>{user.name}</h1>      // no loading state needed here
}

// 3. Wrap with Suspense in the parent
<Suspense fallback={<Spinner />}>
  <Profile />
</Suspense>

Render-as-you-fetch eliminates the round-trip delay between mount and fetch start, and removes the need for manual loading-state bookkeeping.

Rule of thumb: Prefer render-as-you-fetch for critical path data. Frameworks (Next.js, Remix) implement this automatically — adopt the pattern manually only when fine-tuning client-side navigation.

React.use() (stable in React 19, available as experimental in React 18.3) is a hook that reads the value of a Promise or Context inside a component. When passed a pending Promise it throws it, triggering the nearest Suspense boundary — this is the official, built-in way to implement data Suspense without a library.

import { use, Suspense } from 'react'

async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

// Start the fetch outside the component (render-as-you-fetch)
const userPromise = fetchUser(42)

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

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

Key properties of React.use():

  • Can be called conditionally (unlike other hooks).
  • Also accepts a Context object, replacing useContext when you need conditional context reading.
  • A rejected Promise surfaces to the nearest error boundary, not Suspense.

Rule of thumb: Use React.use(promise) as the standard way to consume async data in React 19+ components. For React 18 production apps, use a library (SWR, React Query, Relay) that implements the same protocol with caching.

More ways to practice

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

or
Join our WhatsApp Channel