awesome-everything RU
↑ Back to the climb

Frontend Architecture

React Server Components and Suspense streaming

Crux How RSC async components eliminate client-side JS for static data, and how Suspense boundaries let the server stream the shell in under 100ms while slow data sections arrive later.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 12 min

A Next.js 15 app with RSC can show a page’s layout shell to the user in under 100ms even when the data behind it takes 800ms to fetch — without any JavaScript running in the browser for that content. How?

What React Server Components are

React Server Components (RSC) are React components that run exclusively on the server. They are async functions that can call databases, read files, or call any server-side API. Their output is HTML plus a special serialized React tree (the RSC Payload) — never JavaScript shipped to the browser.

The key split:

  • Server Components — render on the server, produce HTML. No useState, no useEffect, no browser APIs. Zero KB of component code in the browser bundle.
  • Client Components — marked with 'use client' at the top. Run on both server (for initial HTML) and client (for interactivity). Ship their component code to the browser.
// ServerCard.tsx — Server Component (no directive needed)
// Runs only on server. No JS shipped to browser.
async function ServerCard({ id }: { id: string }) {
  const data = await db.product.findUnique({ where: { id } });
  return <div>{data.name}</div>;
}

// AddToCart.tsx — Client Component
'use client';
import { useState } from 'react';
export function AddToCart({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return <button onClick={() => setAdded(true)}>{added ? 'Added' : 'Add to cart'}</button>;
}

In a typical product page, the product title, description, and image are Server Components — they fetch once at request time, emit HTML, ship no JS. Only interactive leaves (AddToCart, filters, modals) become Client Components.

How Suspense enables streaming

React Suspense is the primitive for “show a fallback while this part of the tree is loading.” On the server, a Suspense boundary lets the rest of the tree stream while the suspended component is still resolving its async work.

The request lifecycle with streaming:

  1. Request arrives at server
  2. Server starts rendering. The layout shell (nav, page header, skeleton-shaped fallbacks) needs no async data — it renders immediately
  3. Shell HTML chunk is sent over the open response stream. TTFB fires here (~50–100ms)
  4. Server fires async data fetches inside Suspense boundaries, in parallel
  5. As each fetch resolves, server renders that section and streams the HTML chunk
  6. Browser receives chunks, swaps Suspense fallbacks for real content
  7. LCP fires when the visually-largest element streams in
RSC streaming timeline
T=0: request arrives, shell renders immediately
T=80ms: shell HTML streamed → TTFB fires
T=300ms: fast section data resolves, streamed
T=600ms: slow section resolves → LCP fires

The benefit: without streaming, the TTFB would equal the slowest data fetch (600ms). With streaming, TTFB is the shell render time (80ms). The user sees content much earlier and the page feels responsive.

The ‘use client’ boundary rule

The best architecture: keep as much of the tree as Server Components, push 'use client' to the leaves that genuinely need interactivity.

// Page.tsx — Server Component (default)
async function ProductPage({ id }) {
  const product = await fetchProduct(id);  // server-side, zero client JS
  return (
    <main>
      <h1>{product.name}</h1>
      <ProductImages urls={product.images} />     {/* Server Component */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsSection productId={id} />          {/* Server Component, streams */}
      </Suspense>
      <AddToCart productId={id} />                 {/* Client Component */}
    </main>
  );
}

The bundle size impact is real: a Next.js 15 app that moves most components to server-side can shrink the client bundle from 240KB to 60KB.

The dehydration/rehydration trap

A common bug when adding TanStack Query to an RSC app: the server fetches data, renders HTML, ships it. The client hydrates, mounts the same query hooks, and fetches the same data again — two round-trips for one page load.

The fix: dehydrate the server’s query cache into the HTML, rehydrate on the client:

// On the server
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id) });
const dehydratedState = dehydrate(queryClient);

// In the HTML — pass to client
<HydrationBoundary state={dehydratedState}>
  <ProductPage />
</HydrationBoundary>

Now useQuery(['product', id]) on the client finds data in cache immediately — no refetch. The query may revalidate in the background per its staleTime, but first paint costs zero extra fetches.

Why this works

RSC’s “zero JS for server components” claim is significant only if you actually use it. The trap is marking everything 'use client' out of habit — you end up with the same JS bundle as a regular SPA but with extra complexity. The rule of thumb: if a component has no onClick, no useState, no useEffect, and no browser APIs, it should be a Server Component.

Quiz

In Next.js 15, where should useState live?

Quiz

Without Suspense, how does a slow data dependency affect TTFB in SSR?

Order the steps

Order the events for an RSC page with Suspense streaming:

  1. 1 User clicks link; browser sends HTTP request to server
  2. 2 Server renders layout shell (no async data needed)
  3. 3 Shell HTML chunk sent over response stream — TTFB fires
  4. 4 Server fires parallel async fetches inside Suspense boundaries
  5. 5 Each fetch resolves; server renders and streams that section
  6. 6 Browser swaps Suspense fallbacks for real content as chunks arrive
  7. 7 LCP fires when the visually-largest element appears
Recall before you leave
  1. 01
    What is the RSC Payload and what is it used for?
  2. 02
    Why does Suspense streaming give a lower TTFB than blocking SSR?
  3. 03
    What is the dual-fetch trap and how do you fix it?
Recap

React Server Components run entirely on the server — no useState, no browser APIs, zero client JS shipped. Async Server Components fetch data at request time; Suspense boundaries let the layout shell stream in ~80ms while each data section arrives independently. The ‘use client’ directive should live at the leaves of the tree, marking only components that genuinely need interactivity. Mixed RSC + client cache apps must use dehydration/rehydration to avoid the dual-fetch trap where client query hooks re-fetch data the server already resolved. In Next.js 15 App Router, this architecture is the default; the bundle can shrink from 240KB to 60KB by moving static display components to server-side.

Connected lessons
appears again in202
Continue the climb ↑Client-side cache: TanStack Query, SWR, and stale-while-revalidate
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.