Senior Frontend Depth

Concurrent React, Suspense and Server Components in Next.js

Transitions, Suspense boundaries, streaming SSR, hydration errors and the line between server and client components.

Lesson 0220 min read#react#nextjs#suspense#rsc

On this page

  1. Concurrent rendering and transitions
  2. Suspense
  3. Streaming SSR and selective hydration
  4. Server Components (RSC)
  5. Hydration and why mismatches happen
  6. Scenario: a Client Component pulls a heavy library into the server render. What are the consequences?
  7. Common mistakes
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: modern React sorts work by priority (urgent typing versus background updates). It lets components wait in a clear way (Suspense). It also moves parts that need no clicks to the server (Server Components), so they send no JavaScript to the browser. Next.js adds streaming and selective hydration on top of all three.

This lesson gives you a simple picture of the App Router era: what runs where, what can cross between server and browser, and what causes hydration errors.

Concurrent rendering and transitions

In React 18 and later, rendering can be interruptible (when you use transitions, deferred values or Suspense). React can start an update, stop when something more urgent comes (a key press), and then continue or start again later (Lesson 00).

Concurrent rendering means React can pause a render to do something more important first, and can throw away a render that is no longer needed. (A render is React calling your component functions to work out what the screen should look like.) A transition is a state update that you mark as "not urgent". You tell React which updates are urgent (typing, clicking, pressing). You also tell it which are transitions: a screen change or a heavy list update that may lag a little.

JavaScript
const [isPending, startTransition] = useTransition();function onTabClick(next: string) {  startTransition(() => setTab(next));      // non-urgent: React may interrupt or defer this render}return (  <>    <Tabs onSelect={onTabClick} />    {isPending && <Spinner />}              {/* optional feedback while the transition is in progress */}    <TabPanel tab={tab} />  </>);
  • useTransition is a hook (a function whose name starts with use and that lets a component use a React feature) that returns [isPending, startTransition]. startTransition(fn) marks the state updates inside fn as transitions, and isPending is true while that transition (including an async function you passed) is not finished. If the user acts again, React can drop the render that was in progress. (A plain startTransition function can also be imported from react when you do not need isPending.) Note: only state updates made inside the function before the first await are marked. After an await, wrap each later state update in its own startTransition.
  • useDeferredValue(value) is a hook that returns a copy of value that lags behind. At first it gives the old value while React renders the new one in the background, and then it switches. The slow part of the UI uses the late copy, and the urgent part stays up to date (Debugging, Lesson 01). The difference in one line: useTransition wraps the code that sets state; useDeferredValue wraps a value you receive.

startTransition vs debounce

Debounce is a technique that delays a function until the user has stopped doing something (typing, for example) for a set time, and then runs it once. The two tools solve different problems.

DebouncestartTransition
How it worksDelays a function by N msLowers the priority of a React update
Fixed delayYes (the user waits at least N ms)No: it renders as soon as the browser is free
Can be interruptedNoYes: a newer action cancels the old render
Fewer network callsYesNo (it is about rendering, not requests)
Use forLimiting requests (typeahead fetches)Keeping the UI smooth during heavy re-renders

They work well together: debounce the fetch, and use a transition for the render.

Suspense

Suspense is a React component (<Suspense>) that wraps part of the screen which may need to wait for code or data. While anything inside is waiting, React shows the fallback you give it (a temporary screen, such as a skeleton). When the waiting ends, React swaps in the real content.

JavaScript
<Suspense fallback={<ProductSkeleton />}>  <Product id={id} />        {/* this subtree may suspend while it loads */}</Suspense>

Things that can suspend: lazy-loaded components (React.lazy loads a component's code only when it is first shown), data read with the use hook (a React function that reads a promise or a context and waits for it) or with libraries and frameworks that support Suspense, and (in Next.js) async Server Components.

  • Boundaries decide the loading experience. Put them around parts that do not depend on each other. Then fast parts show at once and slow parts show skeletons (grey placeholder shapes). One big spinner for the whole page is the thing to avoid.
  • With transitions, React can keep showing the old screen while the new content waits. It does not swap to the fallback. This avoids flicker when the user moves between pages.
  • Add error boundaries to handle failures. An error boundary is a component that catches an error thrown while rendering its children, and shows a backup screen instead of breaking the whole page. Suspense handles waiting. Error boundaries handle errors that are thrown.

Streaming SSR and selective hydration

SSR (server-side rendering) means the server builds the HTML for the page before sending it. Hydration means React, in the browser, attaches event handlers (and state) to that ready-made HTML so it becomes interactive.

With classic SSR, the server builds all the HTML and then sends it. The browser then downloads all the JavaScript and hydrates everything. So one slow data call blocks the whole page.

Suspense lets React do two better things:

  1. Streaming: the server sends the HTML shell (the page frame) at once. It then streams (sends piece by piece) the rest as each waiting part is ready. Placeholders are replaced in place. TTFB (time to first byte: how long until the first data arrives) and FCP (first contentful paint: when the first content shows) get better, because slow data no longer blocks fast content.
  2. Selective hydration: React hydrates each Suspense boundary on its own, in order of priority. If the user clicks inside a part that is not hydrated yet, React hydrates that part first.
TEXT
Server: [header + nav + main shell] ──flush──▶ browser paints now        ... recommendations query finishes ...  ──stream──▶ replaces the skeletonClient: hydrates header, then main, then recommendations as their JS arrives; clicks bump priority.

This is why loading.tsx and <Suspense> are so useful in the Next.js App Router. Loading states and streaming come "for free" from the file structure.

Server Components (RSC)

React Server Components (RSC) are components that run only on the server (at build time or for each request). Their code never goes to the browser. A Client Component is the opposite: a component whose code is sent to the browser, so it can use state, effects and event handlers.

JavaScript
// app/products/[id]/page.tsx — a Server Component by default in the Next.js App Routerimport { notFound } from "next/navigation";import { db } from "@/lib/db";import { AddToCart } from "./AddToCart";            // a Client Componentexport default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {  const { id } = await params;  const product = await db.product.findUnique({ where: { id } });   // direct data access: no API, no client JS  if (!product) notFound();                                          // findUnique returns null when nothing matches  return (    <article>      <h1>{product.name}</h1>      <p>{product.description}</p>      <AddToCart productId={product.id} price={product.price} />    {/* interactive leaf */}    </article>  );}

What Server Components can and cannot do

Server ComponentsClient Components
Can be async and await data, query databases, read files and secretsCan use useState, useEffect and event handlers (onClick)
Send no component JavaScript to the browser (smaller bundles, less hydration)Run in the browser (and are also turned into HTML on the server first)
Cannot use state, effects, browser APIs or event handlersCan use browser APIs (window, localStorage)
Can render Client ComponentsCannot import Server Components, but can receive them as children or props

The "use client" boundary

Write "use client" at the top of a file to mark the boundary. That file and everything it imports go into the client bundle (the JavaScript sent to the browser). Everything else stays on the server by default.

JavaScript
"use client";import { useState } from "react";import { addToCart } from "./actions";            // a Server Actionexport function AddToCart({ productId, price }: { productId: string; price: number }) {  const [qty, setQty] = useState(1);  return <button onClick={() => addToCart(productId, qty)}>Add {qty} (₹{price * qty})</button>;}

Rule of thumb: keep the boundary as low as possible, at interactive leaves (buttons, forms, widgets). Then the big static parts stay on the server.

Composition pattern: a Client Component can show server-rendered content that you pass in as children:

JavaScript
// ClientShell is a client component (e.g. has open/close state); it does not import ServerList<ClientShell>  <ServerList />       {/* rendered on the server, passed through as already-rendered content */}</ClientShell>

The serialisation rule

Serialising means turning data into plain text so it can travel over the network. Props that go from a Server Component to a Client Component must be serialisable. That means strings, numbers, booleans, plain objects and arrays, dates, promises, and a few more. Functions, class instances and event handlers cannot cross the boundary. The one exception is Server Actions, which are special references to a function that stays on the server. If you pass onClick={() => …} from a server component to a client component, you get an error.

Server Actions

A Server Action is a function marked "use server". It runs on the server, but the client can call it (from forms and handlers). It replaces hand-written API endpoints for changing data. Treat every Server Action as a public endpoint. Check who is calling, check what they may do, and validate the input inside it (Backend, Lessons 03 and 06).

JavaScript
"use server";export async function addToCart(productId: string, qty: number) {  const user = await requireUser();  const input = schema.parse({ productId, qty });  await db.cartItem.upsert(/* … */);  updateTag("cart");   // in a Server Action: expire the cached cart data so the next read is fresh}// Outside Server Actions (route handlers, webhooks) use revalidateTag("cart", "max"); the one-argument form is deprecated.

Data fetching and caching notes

  • Fetch where you need the data in Server Components. Identical fetch GET requests (same URL and options) are remembered during one server render. So several components can ask for the same data without extra calls. For work that is not fetch (such as database calls), wrap the function with React's cache.
  • Fetch in parallel (Promise.all) to avoid server-side waterfalls. A waterfall is when requests wait for each other even though they do not depend on each other.
  • How data is kept between requests has changed between Next.js versions. For example, newer versions use the explicit "use cache" model. Read the documentation for your version. Do not trust memory.

Hydration and why mismatches happen

Hydration (defined above) reuses the DOM that the server made and attaches event handlers to it. For this to work, the first render in the browser must give the same output as the server's HTML. If it does not, React reports a hydration error (for example, "Hydration failed because the server rendered text didn't match the client"). It then throws away the server HTML for the nearest Suspense boundary (or the whole page) and renders it again in the browser. Then you lose the benefit.

Common causes and fixes:

CauseExampleFix
Values that change each time in renderDate.now(), Math.random(), new Date().toLocaleString()Work it out on the server and pass it as a prop. Or show the browser-only value after mount, in an effect
Browser-only APIs in renderwindow.innerWidth, localStorage, navigator.languageRead them in useEffect (start state with a value that is safe on the server). Or use useSyncExternalStore with a server snapshot
Locale or timezone differencesThe server formats dates in UTC, the browser in ISTSet the locale and timezone clearly, or format on the client
Invalid HTML nesting<div> inside <p>, <a> inside <a>, <tr> outside <tbody>Fix the markup (browsers repair it by themselves, which causes a mismatch)
Third-party code and extensionsBrowser extensions that add nodes to the DOMUse suppressHydrationWarning on that element. It hides the warning for one level of text or attributes only, so use it sparingly
Conditional rendering on browser-only statetypeof window !== "undefined" ? <A/> : <B/>Render the same thing on the first pass. Switch after mount
JavaScript
function ClientTime({ iso }: { iso: string }) {  const [text, setText] = useState(iso);                       // identical on server and first client render  useEffect(() => setText(new Date(iso).toLocaleString()), [iso]);   // localised after hydration  return <time dateTime={iso}>{text}</time>;}

Scenario: a Client Component pulls a heavy library into the server render. What are the consequences?

Remember this: Client Components are also rendered to HTML on the server (for the first paint), and they are sent to the browser. So a heavy library imported by a Client Component has these effects:

  1. The JS bundle gets bigger. The browser must download, parse and run it. LCP (largest contentful paint: when the main content shows) and INP (interaction to next paint: how fast the page reacts to input) get worse, especially on mid-range phones.
  2. Hydration costs more. There is more client code to run before the page is interactive.
  3. Server render time goes up too. The library runs during SSR for every request (or at build time for static pages). This adds to TTFB, and it may break if it touches window during SSR.
  4. It spreads. Every module imported by the "use client" file also joins the client bundle.

Fixes:

  • Keep the heavy work in a Server Component and pass only the results as props. For example, render Markdown, highlight code, format dates or process data on the server.
  • Move the "use client" boundary down to the smallest interactive component.
  • Lazy-load widgets that only work in the browser, with next/dynamic. Use ssr: false only when the widget needs the browser and is not needed for the first paint. In the App Router, ssr: false must be used inside a Client Component. You can also load the widget when the user interacts (Debugging, Lesson 06).
  • Replace the library with a lighter one, and set a bundle size budget.
  • Mark server-only modules with import "server-only". Then nobody can import them into client code by mistake.

Common mistakes

  1. Putting "use client" at the top of large components or the root layout. This sends everything to the browser.
  2. Passing functions (that are not actions) or class instances from server to client components.
  3. Using hooks, state or browser APIs in a Server Component.
  4. Hydration mismatches from dates, random values, window and invalid HTML.
  5. One big Suspense boundary (or none), so the slowest query blocks all content.
  6. Server-side waterfalls (one await after another for data that does not depend on each other).
  7. Treating Server Actions as private. They are public endpoints and need auth and validation.
  8. Assuming the caching defaults of an older Next.js version.

Interview questions

What problem do Server Components solve?

They let components that need no clicks run only on the server. Their code and packages never reach the browser, so the bundle is smaller and hydration costs less. They can reach databases and secrets directly, fetch data close to where it lives, and stream the results. The interactive parts stay as Client Components.

What causes hydration mismatches?

The first render in the browser gives different output from the server HTML. Causes are: values that change each time (dates, random numbers), browser-only APIs during render, locale or timezone differences, invalid HTML nesting, extensions that change the DOM, and rendering that depends on browser-only state. The fix is to make the first render predictable, and to update browser-specific values after mount.

startTransition vs debounce?

Debounce waits a fixed time before it runs an action, so it reduces how often work happens, for example network calls. startTransition runs the update at once but gives its render a low priority and lets React interrupt it, so urgent input stays smooth. It does not reduce network calls. Use debounce for requests and transitions for heavy rendering. Often you use both.

Scenario: a Client Component pulls a heavy library into the server render. What are the consequences?

The library makes the client bundle and the hydration cost bigger. It also runs during server rendering, which slows TTFB and may cause window errors. Everything the client module imports goes to the browser too. To fix it, move heavy work into Server Components and pass results as props. Also move the client boundary lower, lazy-load widgets that only work in the browser, replace the library, and set bundle budgets.

How does streaming SSR improve performance?

The server sends the HTML shell at once. It then streams the slower Suspense parts as they become ready. Users see content sooner, and slow data no longer blocks the whole page. Selective hydration then hydrates each boundary on its own, and it starts with the parts the user touches.

What can cross the server-to-client boundary?

Data that can be serialised: primitives, plain objects and arrays, dates, promises and some other supported types. Also Server Action references, and rendered server content passed as children. Normal functions, class instances and event handlers cannot cross.

Where should the "use client" boundary go?

As deep as possible, at interactive leaf components (forms, buttons, widgets). If parents stay as Server Components, there is less client JavaScript. Client Components can still show server-rendered content that is passed in as children.

Why are Server Actions a security concern?

They can be called over the network, so they are public endpoints. Each one must check who the caller is, check that they may use that exact resource, validate and clean the input, and handle errors safely. This is the same as for an API route.

Scenario: a hydration error appears only in production, not locally. Where do you look?

Look for differences between the two environments. Examples: a timestamp or locale formatted on the server in another timezone; content that depends on cookies, headers or feature flags that differ in production; caching that serves HTML made for another user or locale; environment variables or build-time values that differ between the two builds; and browser extensions. Compare the server HTML with the first browser render. Make the server and browser inputs the same. Move browser-only values into an effect that runs after mount.

Key takeaways

  • Concurrent React: updates are either urgent or transitions. useTransition and useDeferredValue keep input smooth and can be interrupted, unlike debounce.
  • Suspense gives clear loading states. Streaming and selective hydration show content early and hydrate by priority.
  • Server Components send no JS and can read data directly. "use client" marks interactive leaves. Props must be serialisable.
  • Hydration mismatches come from output that changes each time or depends on the browser. Keep first renders the same and update after mount.
  • Keep heavy libraries on the server, move boundaries down, lazy-load, fetch in parallel, and check the caching docs for your Next.js version.

What's next

Lesson 03: State Management Decisions. Now that rendering and data flow are clear, we choose where each kind of state should live.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series