Concurrent React and Server Components
What it is
Two separate React features that get conflated because they shipped in the same era and both change where and when work happens.
Concurrent React is the ability to interrupt an in-progress render. React 18 made the render
phase yieldable, so a low-priority update can be started, paused when the browser needs the main
thread, and abandoned entirely if a newer update supersedes it. startTransition,
useDeferredValue and Suspense are the API surface.
React Server Components (RSC) are components that execute only on the server, never ship their code to the browser, and can read data directly. Their output is not HTML, it is a serialised tree (the "RSC payload") that the client runtime merges into the React tree.
Concurrent React WHEN work runs on the client, and whether it can be
interrupted. A scheduling feature.
Server Components WHERE a component's code runs, and whether it is in
the bundle at all. A boundary feature.
What this is confused with: RSC and SSR. Server-side rendering runs your client components on the server to produce HTML, which is then hydrated: the code still ships to the browser. RSC means the code never ships. A page can use both, and in the Next.js App Router it does: server components render to the RSC payload, client components are additionally SSR'd to HTML, and only client components hydrate.
Also confused: startTransition and setTimeout/debouncing. A debounce delays starting the
work. A transition starts immediately at low priority and is interruptible and abandonable, so
the result you get is for the latest input, not for whatever the timer happened to fire on.
The problem it solves
Concurrent React solves "the render blocked the input." Before React 18, once a render started it ran to completion synchronously:
User types "r" in a search box that renders 2,000 result rows.
keydown handler -> setState -> React renders 2,000 rows -> 180ms
Browser cannot paint the character or process the next keystroke
for that entire time.
Type five characters at 60ms apart:
each keystroke queues behind the previous render
the input visibly lags, and the last frame you see is stale
The pre-18 workarounds were debouncing (the input feels right, the results feel late, and you pick a delay by guessing) and virtualisation (correct, and orthogonal, and it does not help when the expensive part is per-row computation rather than node count).
Server Components solve "the bundle contains code that only produces static output."
A markdown article renderer:
markdown parser + syntax highlighter + sanitizer
= a large amount of JS in the client bundle
Every user downloads, parses and executes it, to produce HTML that
could have been produced once on the server and never changes on
the client.
And they solve the data waterfall. A client component that fetches on mount cannot start its request until its parent has rendered, hydrated and mounted:
Client-fetching waterfall:
HTML -> JS download -> hydrate -> Page fetch (150ms)
-> child renders -> Comments fetch (120ms)
time to full content: bundle + hydrate + 270ms of serial requests
Server components:
the server awaits both, in parallel where the code allows, and streams
the result. No client round trips for that data at all.
Mechanics
Lanes, yielding and interruption
React 18 assigns each update a lane, a bit in a bitmask that encodes priority. Discrete events
(click, keydown) get a synchronous lane; transitions get a transition lane; useDeferredValue
schedules at a deferred lane.
The work loop renders fibers one at a time and checks whether it should yield. React's scheduler
uses a 5ms frame yield interval (frameYieldMs in the scheduler source): if more than that has
elapsed since it took control, it yields to the browser and resumes via a MessageChannel
callback.
Sync render (pre-18 / discrete updates):
|=================== 180ms ====================| no paint, no input
Concurrent render (transition):
|==5ms==| |==5ms==| |==5ms==| ...
^ ^ ^
browser gets the thread back here: it can paint, handle
the next keystroke, and if that keystroke schedules a
higher-priority update, React THROWS AWAY the in-progress
tree and starts again with the new input.
"Throws away" is the part that matters and is why this is not a scheduler bolted on top. React
builds the new tree in a separate workInProgress fiber tree (double buffering, each fiber pointing
at its alternate), so an abandoned render leaves the committed tree untouched. This is also why
the render phase must be pure: it may run several times per commit, or never commit at all.
startTransition and useTransition
function SearchPage() {
const [query, setQuery] = useState('') // urgent: drives the input
const [results, setResults] = useState(list) // non-urgent: drives 2,000 rows
const [isPending, startTransition] = useTransition()
function onChange(e) {
setQuery(e.target.value) // SYNC lane: input updates immediately
startTransition(() => {
setResults(filter(list, e.target.value)) // TRANSITION lane: interruptible
})
}
return (
<>
<input value={query} onChange={onChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ResultList items={results} />
</div>
</>
)
}
The two setState calls in the same handler land in different lanes, so React commits the input
update immediately and renders the list at low priority. If another keystroke arrives mid-render,
the list render is discarded.
isPending is what makes it usable: without a visual signal the UI looks frozen-but-responsive,
which reads as broken. Dimming the stale content is the standard treatment.
What startTransition does not do: it does not make rendering faster, and it does not help if a
single component's render is itself slow, because React can only yield between fibers. A 200ms
render of one component is 200ms of blocked main thread inside a transition too. Transitions
distribute pain across many small units; they do not shrink a large one.
useDeferredValue
The same idea without lifting state, useful when you do not own the setter.
function Results({ query }) {
const deferred = useDeferredValue(query)
const stale = query !== deferred
const items = useMemo(() => filter(list, deferred), [deferred])
return <div style={{ opacity: stale ? 0.6 : 1 }}><ResultList items={items} /></div>
}
React renders once with the old value (fast, committed immediately), then schedules a re-render with
the new value at low priority. The useMemo is not optional here: without it the filter runs on
the urgent render too, and you have paid the cost you were trying to defer.
Suspense and streaming SSR
A Suspense boundary catches a child that is not ready and shows a fallback.
<Suspense fallback={<ResultsSkeleton />}>
<Results query={q} /> {/* awaits data on the server, or lazy-loads on the client */}
</Suspense>
With renderToPipeableStream, the server sends the shell as soon as it is ready and streams the
rest:
t=0ms server starts rendering
t=40ms shell is ready (header, nav, layout, the Suspense fallbacks)
-> FLUSHED. Browser starts parsing, downloading CSS/JS,
painting the skeleton. First paint happens here.
t=180ms the slow product-recommendations query resolves
-> React streams an inline <template> with the real HTML plus
a tiny script that swaps it into place, by boundary id
t=260ms reviews resolve, same treatment
TTFB is set by the shell (40ms), not by the slowest query (260ms).
Selective hydration is the client half: React hydrates boundaries independently, and prioritises whichever boundary the user just interacted with. A click on a not-yet-hydrated component causes React to hydrate that boundary first and then replay the event.
Where you place boundaries is the actual design decision. A boundary around the whole page gives you a spinner and nothing else; boundaries around each slow region give you a progressively filling page. Put a boundary at every point where a slow data dependency would otherwise hold up content that is already available.
Server Components and the "use client" boundary
Server Component (the default in the App Router)
- runs on the server only, per request (or at build time)
- can be async: `const user = await db.user.find(id)`
- code is NOT in the client bundle
- CANNOT: useState, useEffect, useContext, event handlers,
browser APIs, class components
Client Component (marked with "use client" at the top of the module)
- runs on the server for the initial HTML (SSR) AND on the client
- code IS in the client bundle
- full React: state, effects, event handlers
"use client" marks a module boundary, not a component. Everything that module imports, and
everything imported transitively, becomes part of the client bundle. This is the single most
misunderstood detail, and it is why "use client" at the top of a layout defeats the whole
feature.
// app/page.tsx -- SERVER component, no directive needed
import { db } from '@/lib/db'
import { LikeButton } from './like-button' // a client component
import { renderMarkdown } from '@/lib/markdown' // 300KB of parser: server only
export default async function ArticlePage({ params }) {
const article = await db.article.find(params.id) // no API route, no client fetch
return (
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(article.body) }} />
<LikeButton articleId={article.id} initialCount={article.likes} />
</article>
)
}
// app/like-button.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ articleId, initialCount }) {
const [count, setCount] = useState(initialCount)
return <button onClick={() => { setCount(c => c+1); like(articleId) }}>{count}</button>
}
The markdown renderer never reaches the browser. Only like-button.tsx and its imports do.
Props crossing the boundary must be serialisable. Strings, numbers, plain objects, arrays, Dates, Maps, Sets, promises and other React elements cross; functions (other than server actions) and class instances do not.
The composition pattern that resolves most "everything became a client component" problems is
passing server-rendered content as children into a client component:
// app/layout.tsx -- stays a SERVER component
import { Sidebar } from './sidebar' // 'use client': needs open/close state
export default async function Layout({ children }) {
const nav = await db.nav.load()
return <Sidebar nav={nav}><ServerRenderedContent /></Sidebar>
}
Sidebar is a client component holding interaction state, but children was already rendered on
the server and arrives as an opaque payload. A client component can render server-component output
it receives as props; it just cannot import a server component.
Server Actions
// app/actions.ts
'use server'
export async function addComment(articleId: string, formData: FormData) {
await db.comment.create({ articleId, body: formData.get('body') })
revalidatePath(`/article/${articleId}`)
}
A "use server" export becomes a callable reference on the client that posts back to the server.
It is an RPC endpoint with a generated id, and every security property of an HTTP endpoint applies
to it: it is reachable by anyone who can find the id, so it must authenticate and authorise
independently. "The button is only rendered for admins" is not authorisation.
A worked example: a dashboard that went from 3.4s to a 400ms shell
A reporting dashboard: a header, a filter bar, a summary card row, a large table, and a recommendations panel. All client-rendered, all data fetched on mount.
Before:
Client bundle: 780KB gzipped, of which
- a charting library 190KB
- a markdown/rich-text renderer 95KB (renders static report notes)
- date/locale formatting 70KB
- the table library 120KB
- app code + React + router 305KB
Timeline (mid-tier laptop, 4G-ish):
0ms HTML (a near-empty div)
120ms JS starts downloading
980ms JS parsed and executed
1,050ms hydrate, mount, components fire their fetches
1,050ms -> summary (180ms)
1,230ms -> table (620ms, the slow one)
1,850ms -> recommendations (1,500ms, an ML service)
3,350ms page fully populated
First meaningful paint: ~1,100ms (skeletons)
Three problems, and each maps to a different feature:
1. Code that produces static output is in the bundle
-> Server Components
2. Every data fetch waits for hydration and then serialises
-> Server Components (fetch on the server, in parallel)
3. The slowest fetch (1,500ms) holds up nothing that depends on it,
yet the user sees nothing until it lands
-> Suspense + streaming
After:
// app/dashboard/page.tsx -- server component
export default async function Dashboard({ searchParams }) {
const filters = parseFilters(searchParams)
return (
<>
<Header /> {/* server: static */}
<FilterBar filters={filters} /> {/* 'use client': needs interaction */}
<Suspense fallback={<SummarySkeleton />}>
<Summary filters={filters} /> {/* server: awaits its own query */}
</Suspense>
<Suspense fallback={<TableSkeleton rows={20} />}>
<ReportTable filters={filters} /> {/* server shell + client sort/paginate */}
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations filters={filters} /> {/* server: the 1,500ms one */}
</Suspense>
</>
)
}
Client bundle: 310KB gzipped
removed: markdown renderer (95KB), date/locale formatting (70KB),
most app code that only formatted server data
kept: charting (190KB, genuinely interactive), the table's
client behaviour, the filter bar, React itself
-> the chart library was additionally moved behind next/dynamic so
it loads only on the tab that shows charts
Timeline:
0ms request
~400ms SHELL FLUSHED: header, filter bar, three skeletons.
Browser paints. TTFB is the shell, not the slowest query.
~590ms summary streams in (its query took ~180ms server-side and
started immediately, in parallel with the others)
~1,030ms table streams in
~1,900ms recommendations stream in
Bundle downloads and hydration overlap all of this rather than
gating it.
The user-visible change is that first paint moved from 1,100ms to about 400ms, and it is real content plus skeletons rather than an empty div. The three queries went from serial-after- hydration to parallel-on-the-server, so total time to full content fell even though the slowest query is unchanged: it was never the bottleneck, the waterfall in front of it was.
Two things went wrong during the migration and are worth stating:
1. Someone put 'use client' at the top of the dashboard LAYOUT to get
a theme toggle working. That made every descendant a client
component and returned the bundle to ~700KB overnight. Caught by a
bundle-size CI check, not by review.
Fix: the theme toggle became its own client component; the layout
passed {children} through.
2. A server action for "export report" was reachable without an auth
check, because the export button was only rendered for users with
the permission. It is an RPC endpoint with a discoverable id.
Fix: the auth check moved INTO the action.
Both failures are the same mistake in different clothing: treating a boundary as if it were a
visual one. "use client" is a module boundary that spreads through imports, and "use server" is
a network boundary that spreads to anyone who can call it.
Production evidence
Next.js App Router ships React Server Components in production and is the largest deployed RSC
implementation. Its documentation states the "use client" module-boundary semantics explicitly,
including that imports of a client module become part of the client bundle.
React's renderToPipeableStream (Node) and renderToReadableStream (web streams) implement
streaming SSR with out-of-order Suspense boundary flushing, documented in the React DOM server API
reference. The mechanism, an inline <template> plus a small script that relocates content by
boundary id, is observable in the raw HTML of any streaming Next.js page.
Selective hydration was described by the React team in the "New Suspense SSR Architecture in React 18" discussion, including hydrating the boundary the user interacted with first and replaying the event.
The scheduler's 5ms yield interval is frameYieldMs in React's scheduler package, which is the
concrete answer to "how often does concurrent React give the browser the thread back."
Shopify's Hydrogen was an early production adopter of the Server Components model for commerce storefronts, and its public rationale, keeping large formatting and data-shaping code off the client, matches the bundle argument above.
Meta uses Relay with fragment-based data colocation rather than RSC on its main surfaces, which is worth knowing because it shows the waterfall problem has more than one solution: Relay solves it by hoisting fragment requirements into a single query at build time, RSC solves it by moving the fetch to the server.
The debate
Are Server Components worth the complexity? For content-heavy, data-heavy applications with a meaningful bundle problem, yes. For an authenticated dashboard that is 95 percent interactive, the gain is small and the mental overhead is real: two execution environments, a serialisation boundary, and a class of errors ("you cannot pass a function to a client component") that did not exist before. The position: adopt RSC when the bundle contains code that only produces static output, or when you have measurable client-fetch waterfalls. Do not adopt it because it is the default in a new framework version.
Does RSC lock you into a framework? Effectively yes, today. RSC requires a bundler integration and a server runtime that understands the payload format, and in practice that means Next.js or a framework that has done the same work. That is a genuine and often understated cost, and it is the strongest argument for teams that value framework portability.
Is concurrent rendering worth using explicitly? Only where you have identified a specific
expensive, non-urgent update. startTransition around every setState is cargo cult: it adds
indirection, and marking an update non-urgent when it is urgent makes the UI feel worse. The
honest default is to reach for it when profiling shows a long task triggered by user input, and to
prefer removing the work (virtualise the list, memoise the computation, move it to a worker) when
that is possible, because transitions redistribute cost rather than reducing it.
Transitions versus debouncing? Transitions are better where they apply, because the abandonment semantics guarantee the committed result matches the latest input, whereas a debounce has to guess a delay and can still commit a stale result. Debouncing still wins for reducing network requests, which transitions do nothing about. Using both is normal: debounce the request, transition the render.
Should every slow region get a Suspense boundary? Nearly, and the failure mode in the other direction is worse. Too few boundaries means one slow query holds the whole page; too many means a flickering mosaic of skeletons. The rule: a boundary wherever a slow dependency would otherwise delay content that is already available, and a skeleton whose layout matches the real content so the swap does not shift the page.
Follow-up Q&A
"What is the difference between SSR and Server Components?"
SSR runs your client components on the server to produce HTML, which the browser then hydrates: the component code still ships to the browser, because it has to run again there. Server Components run only on the server, never ship, and produce a serialised tree rather than HTML. They are complementary: in the App Router, server components render to the RSC payload, client components are also SSR'd to HTML for first paint, and only client components hydrate. The clearest test is whether the code is in the bundle: with SSR it is, with RSC it is not.
"What does startTransition actually do?"
It marks the state updates inside it as low priority, so React renders them in a transition lane that the scheduler can interrupt. React renders a few fibers, checks whether roughly 5ms have elapsed, yields to the browser so it can paint and process input, and resumes. If a higher-priority update arrives, the in-progress tree is discarded and rendering restarts with the new state, which is safe because React builds it in a separate work-in-progress tree. That abandonment property is why the committed result always corresponds to the latest input, unlike a debounce.
"When does startTransition not help?"
When a single component's render is itself slow, because React can only yield between units of work. A component that takes 200ms to render blocks the main thread for 200ms inside a transition too. It also does nothing about network requests or about the total amount of work. If the profile shows one long task inside one component, the fix is to reduce the work: virtualise, memoise the computation, move it off the main thread, or render less.
"How does streaming SSR work at the protocol level?"
The server renders with renderToPipeableStream and flushes the shell, everything above and around
the Suspense boundaries, as soon as it is ready, with the fallbacks in place. The browser starts
parsing, fetching CSS and JS, and painting. As each suspended boundary's data resolves, the server
appends an inline <template> containing that boundary's real HTML plus a small script that moves it
into position by boundary id, over the same still-open response. TTFB is therefore set by the shell,
not by the slowest query, and boundaries can arrive out of order.
"What exactly does 'use client' mark?"
A module boundary, not a component. The module and everything it imports, transitively, become part
of the client bundle. That is why putting it at the top of a layout to get one interactive widget
working can pull the whole tree into the bundle, which is a real regression that a bundle-size check
catches and code review usually does not. The composition fix is to make the interactive part its own
client component and pass server-rendered content through it as children, since a client component
can render server output it receives as props even though it cannot import a server component.
"What are the security properties of a server action?"
It is an RPC endpoint with a generated id, reachable by anyone who can discover the id. Every check you would put on an HTTP endpoint belongs inside the action: authentication, authorisation, and input validation. Rendering the button only for admins is a UI decision and is not an access control. This is the most common RSC security mistake and it has the same shape as trusting a hidden form field.
"When would you not adopt Server Components?"
When the application is overwhelmingly interactive, so most components would carry "use client"
anyway and the bundle contains little server-only code; when framework portability matters, since RSC
needs deep bundler and server-runtime integration and in practice means committing to Next.js or an
equivalent; or when the team is small and the two-environment mental model would cost more than the
bundle it saves. The decision should follow a measured bundle-composition or waterfall problem.
Common misconceptions
"Server Components are just SSR with a new name." SSR ships the code and hydrates. RSC does not ship the code at all, and the output is a serialised tree rather than HTML.
"'use client' marks a component." It marks a module, and the effect spreads through every
import.
"startTransition makes rendering faster." It makes it interruptible. Total work is the same or
slightly higher; what changes is that the browser gets the thread back and stale renders are
discarded.
"Suspense is for data fetching." It is a boundary for anything that suspends: lazy-loaded code,
server-streamed data, or a framework's data layer. Calling fetch in a component does not by itself
suspend anything.
"Server actions are safe because they run on the server." They are network endpoints with discoverable ids. Authorise inside the action.
"A transition and a debounce are interchangeable." A debounce delays starting the work and can commit a stale result. A transition starts immediately and abandons superseded work. A debounce reduces network calls, which a transition does not.
Interview delivery note
Say this verbatim: "Concurrent React changes when work runs and whether it can be interrupted; Server Components change where a component runs and whether it is in the bundle at all. They get conflated because they shipped together, and they solve completely different problems." The one-sentence disambiguation is the answer most candidates cannot give.
The senior-versus-staff separator is knowing that "use client" is a module boundary that spreads
through imports, and naming the failure it causes: one engineer adds the directive to a layout to
make a theme toggle work, and the bundle silently returns to its pre-migration size because every
descendant is now a client module. A staff engineer also names the control that catches it, a
bundle-size check in CI, because review does not catch a one-line directive with a whole-tree effect.
The second signal is being unwilling to over-apply transitions. Saying "transitions redistribute cost rather than reducing it, so if the profile shows one long task inside one component I would virtualise or memoise instead, and marking a genuinely urgent update as a transition makes the UI feel worse" shows you have used it rather than read the release notes.
Further reading
- React DOM server API reference for
renderToPipeableStream, and the React team's "New Suspense SSR Architecture in React 18" write-up, for streaming and selective hydration. - React documentation for
useTransition,useDeferredValueand Server Components, including the serialisation rules for props crossing the boundary. - Next.js App Router documentation on Server and Client Components, for the module-boundary semantics and the composition patterns.
- React's scheduler source (
frameYieldMs) for the concrete yield interval behind time slicing. - The rendering strategy matrix page, which places these among the other options.