React Server Components — Deeper
TL;DR
Beyond the basics in ../05_react/server_components.md: how the RSC payload is shaped, the wire protocol, the React Flight serialization format, the module graph split between server and client bundles, caching layers, streaming RSC, and the performance implications. Also the edge cases — what doesn’t work cleanly, why some libraries broke, and what’s coming next.
Interview Q&A
Q: What is the RSC payload?
A: When a Server Component renders, it doesn’t produce HTML only — it produces an RSC payload, a serialized representation of the React tree designed for the client to consume. It includes:
- Rendered element references (server components rendered to their tree output).
- Client component references (
<button>is_jsx("button", ...); client components are_jsx({$$module, $$id}, ...)placeholders that the client resolves). - Props passed across the boundary (serialized via React Flight).
- Promises (suspended values that haven’t resolved yet).
- Module references (where to load client components from).
This payload streams down alongside HTML. The HTML is for first paint; the RSC payload is for the React client to assemble + hydrate.
Q: React Flight — the serialization format.
A: React Flight is the wire format. Roughly:
1:I["client_chunk", ["Button"], "default"]
2:["$","div",null,{"children":[["$","h1",null,{"children":"Title"}], "$L1"]}]
Imarkers register client component references.$markers are React elements.$Lreferences previously-defined values (a way to deduplicate).- Streams arrive in chunks; React assembles them progressively.
You’ll never write this by hand — but knowing it exists explains why props across the server/client boundary must be serializable. The format is JSON-extended (supports Date, Map, Set, Promise) but not functions (you can’t serialize executable code).
Q: The module graph split.
A: RSC requires two module graphs:
- Server graph — all server-only code; can use
fs, DB clients, env vars. - Client graph — code that ships to the browser; what
"use client"marks.
The bundler builds both. Cross-graph imports are tightly constrained:
- A server component can import a client component (the bundler emits a reference, not the code).
- A client component cannot import a server component directly (would force the server code into the client bundle). Workaround: pass server components as
children/props.
// ClientComp.tsx — "use client"
export function Tabs({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// page.tsx — Server Component
<Tabs>
<ServerStats /> {/* Server component passed as children — works */}
</Tabs>
The children slot composes server + client without breaking the graph.
Q: What can cross the boundary as a prop?
A: From server to client component, props must be serializable:
- Primitives (string, number, boolean, null, undefined, BigInt).
- Plain objects, arrays.
Date,Map,Set,Promise(suspended in client).- React elements (server components rendered inline).
- Server Action functions (
"use server").
Not allowed:
- Regular functions (closures, callbacks).
- Class instances.
- Symbols (most).
If you need a callback prop on a client component from a server component, make it a Server Action (with "use server"), or rely on the client component’s own state.
Q: Server Actions — how do they work?
A: A function marked "use server" can be passed to client components or called from forms. The framework turns the function reference into an opaque server endpoint reference; calling it from the client transparently issues an HTTP request to invoke it on the server.
// Server module
async function createPost(formData: FormData) {
"use server";
await db.posts.create({ data: { title: formData.get("title") } });
revalidatePath("/posts");
}
// In a form (works with progressive enhancement — even without JS)
<form action={createPost}>
<input name="title" />
<button>Post</button>
</form>
The function runs on the server with full backend access. The client never sees the implementation — only the reference.
Framework support: Next.js App Router has full Server Actions; bare React’s RSC primitives expose the mechanism but you need framework wiring.
Q: Caching in RSC.
A: Multiple cache layers:
fetchcache — Next.js wrapsfetch()with caching. Pass{ cache: "force-cache" }or{ next: { revalidate: 60 } }to opt into caching.- Request memoization — within a single request’s render, identical
fetchcalls dedupe. - Route cache — full route output cached per (route, params).
- Router cache — client-side prefetched routes cached for navigation.
Next.js 15 made these opt-in / explicit (earlier versions were aggressive by default). Pin behavior explicitly:
const data = await fetch(url, { cache: "no-store" }); // never cache
const data = await fetch(url, { next: { revalidate: 60 } }); // ISR-style
const data = await fetch(url, { next: { tags: ["posts"] } }); // tagged for purge
revalidateTag("posts") purges all fetch results tagged with "posts" — useful from a Server Action after mutation.
Q: Streaming RSC — what’s the model?
A: Server starts streaming the RSC payload + HTML as soon as the shell is ready. Suspended boundaries stream as their data resolves. The client assembles the React tree as chunks arrive.
// Server component
export default function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<Spinner />}>
<SlowChart /> {/* awaits data */}
</Suspense>
</>
);
}
Wire-level:
- Chunk 1 (immediately):
<Header />rendered + Spinner placeholder. - Chunk 2 (when SlowChart’s data resolves): the chart’s rendered HTML + RSC payload replacing the placeholder.
User sees: instant header + spinner → chart fills in. No “wait for the slowest thing” before any paint.
Q: How does RSC handle data fetching?
A: Just await in the component:
export default async function Page({ params }: { params: { id: string } }) {
const post = await db.posts.findUnique({ where: { id: params.id } });
const comments = await db.comments.findMany({ where: { postId: params.id } });
return <Article post={post} comments={comments} />;
}
Compared to old SPA pattern (TanStack Query + Suspense), this:
- Has no waterfalls (the awaits run sequentially unless you parallelize with
Promise.all). - No client cache needed — server fetched, client receives rendered output.
- No loading state ceremony — wrap in
<Suspense>and you get streaming + fallback.
For parallel fetches:
const [post, comments] = await Promise.all([
db.posts.findUnique({ where: { id } }),
db.comments.findMany({ where: { postId: id } }),
]);
Q: When does RSC fall apart?
A:
- Heavily interactive subtrees — most of the page is client components anyway; RSC at the edges provides limited value.
- Apps without a framework runtime — bare React’s RSC primitives exist but need framework wiring (Next, Remix). Building from scratch is for framework authors.
- Third-party libraries that aren’t RSC-aware — anything that uses hooks or browser APIs is a client component implicitly. The library may not have a
"use client"wrapper; you wrap it yourself. - Mature TanStack Query / Redux apps — migrating is a re-architecture, not a sprinkle. Don’t migrate without a reason.
Q: How does RSC affect bundle size?
A: Big win for content-heavy pages. A blog post page that previously shipped:
- React runtime: ~40 KB.
- Markdown renderer: ~50 KB.
- Syntax highlighter: ~100 KB.
- The post itself rendered client-side: ~minimal.
With RSC: server renders the post → client gets HTML. Markdown renderer + syntax highlighter never ship to the client. Bundle drops to ~ React runtime + interactive bits.
For dashboards / interactive apps, less dramatic — most of the tree is already client. But even there, moving the data-fetching layer to the server removes the TanStack Query bundle, the loading-state boilerplate.
Q: Server Components + state libraries (Redux, Zustand).
A: State libraries are client-only — they need React’s reactive runtime. Server Components can’t subscribe to them.
Pattern: server components fetch initial data, pass to client components, client components subscribe to the store from there.
// page.tsx (server)
const initialUser = await getUserSession();
return <ClientApp initialUser={initialUser} />;
// ClientApp.tsx ("use client")
import { useUserStore } from "@/stores/user";
export function ClientApp({ initialUser }: { initialUser: User }) {
useEffect(() => { useUserStore.setState({ user: initialUser }); }, []);
return <Dashboard />;
}
A bit of dance for stores, but it’s the model. For pure server data (the post’s content), no store needed.
Gotchas / edge cases
"use client"propagates downward — once you start a client tree, deeper imports are client. To re-enter server-component territory, pass aschildrenfrom above.- Passing functions across the boundary silently breaks serialization. Use Server Actions.
- Reading cookies / headers in server components needs framework helpers (
cookies()in Next.js). Plain Node APIs don’t always work due to request context. use(promise)requires stable Promise identity — creating a new Promise per render causes infinite suspend. Cache or pass from parent.- RSC errors in dev can be cryptic — the failure may be in the serialization layer, not your code. Check that all props are serializable.
- Third-party CSS-in-JS that runs at render time needs server-render setup; Emotion, styled-components have RSC-compatible packages.
- Auth context — server reads cookies (sync), client reads a Context provider. Bridge via passing user data as props from server to a client Provider.
What a senior is expected to say
- “RSC produces an RSC payload (React Flight format), not just HTML — that’s how the client assembles the tree with client-component placeholders.”
- “Two module graphs: server (full Node access) and client (browser bundle). Props across the boundary must be serializable; functions become Server Actions or stay in client.”
- “Streaming RSC: shell streams first, suspended boundaries stream as their data resolves. Best perceived LCP for data-heavy routes.”
- “Caching is opt-in in Next 15+:
cache: 'no-store'ornext: { revalidate }/tags.revalidateTagpurges from a Server Action after mutation.” - “Bundle wins are huge for content-heavy pages (blog, docs); modest for interactive apps where most of the tree is client anyway.”
- “Server Actions enable forms that work without JS (progressive enhancement). The function reference becomes an opaque server endpoint.”
Cross-references
- RSC basics: ../05_react/server_components.md
- Hydration models: 02_hydration_models.md
- Next.js per-route rendering: 05_nextjs_rendering_control.md
- React 19 features (Server Actions, useFormState): ../05_react/react_19.md
Further reading
- React docs — Server Components: https://react.dev/reference/rsc/server-components
- Next.js — Server and Client Components: https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns
- React Flight protocol: https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFlightServer.js
- Dan Abramov — RSC talks (search “React Server Components Dan Abramov”)