{"schemaVersion":1,"id":"https://lucioduran.com/blog/react-server-components-streaming-architecture","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture","title":"React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns","description":"A deep dive into React Server Components, streaming SSR, Suspense boundaries, and server actions, with honest benchmarks on the real cost of client components and practical migration patterns.","language":"en","datePublished":"2026-01-01","dateModified":null,"contentFormat":"text/markdown","content":"Frontend\n\n# React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns\n\n2026-01-0110 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/react-server-components-streaming-architecture>)\n\n## RSC Request Lifecycle\n\nThe following describes what occurs when a browser requests a page with RSC:\n\n1. The server receives the request and starts rendering the component tree\n2. Server Components execute on the server, they can ` await ` database queries, file reads, API calls directly\n3. When the server hits a ` 'use client' ` boundary, it stops rendering that subtree and instead emits a **reference**, a pointer that says \"render this client component here with these serialized props\"\n4. The server streams the output as a special format called the **RSC payload**, not HTML, but a serializable description of the component tree\n5. The client-side React runtime receives this payload, hydrates the client components, and stitches everything together\n\nThe key insight: the RSC payload is **streamed**. The server doesn't wait for all data to resolve before sending the first byte. Suspense boundaries define the streaming chunks.\n\n```\nServer starts rendering\n├── Layout (server) → streams immediately\n├── Header (server) → streams immediately\n├── MainContent (server, awaiting DB) → streams when data resolves\n│ └── <Suspense fallback={<Skeleton />}>\n│ └── Comments (server, awaiting API) → streams later\n└── InteractiveWidget ('use client') → reference in payload, hydrates on client\n```\n\n## The Cost of 'use client'\n\nA common misconception: ` 'use client' ` does not simply mean \"this component runs on the client.\" It means **this component AND every component it imports** becomes part of the client bundle. It's a boundary declaration, not a per-component toggle.\n\n```jsx\n// This one 'use client' pulls everything into the client bundle\n'use client';\n\nimport { HeavyChartLibrary } from 'heavy-charts'; // 200KB\nimport { DateFormatter } from './utils'; // 2KB\nimport { UserAvatar } from './UserAvatar'; // 5KB + image processing\n\nexport function Dashboard({ data }) {\n const [filter, setFilter] = useState('all');\n // ...\n}\n```\n\nThe fix is surgical decomposition:\n\n```jsx\n// ServerDashboard.jsx (server component - no directive needed)\nimport { HeavyChartLibrary } from 'heavy-charts'; // stays on server!\nimport { DateFormatter } from './utils'; // stays on server!\nimport { DashboardFilter } from './DashboardFilter'; // client boundary\n\nexport async function ServerDashboard() {\n const data = await db.metrics.getAll(); // direct DB access\n\n return (\n <div>\n <h1>Dashboard</h1>\n <DashboardFilter /> {/* tiny client component */}\n {/* Heavy chart renders on server, ships as HTML */}\n <HeavyChartLibrary data={data} interactive={false} />\n <p>Last updated: {DateFormatter.relative(data.updatedAt)}</p>\n </div>\n );\n}\n```\n\n```jsx\n// DashboardFilter.jsx\n'use client';\n\nimport { useState } from 'react';\n\nexport function DashboardFilter() {\n const [filter, setFilter] = useState('all');\n return (\n <select value={filter} onChange={(e) => setFilter(e.target.value)}>\n <option value=\"all\">All</option>\n <option value=\"active\">Active</option>\n <option value=\"archived\">Archived</option>\n </select>\n );\n}\n```\n\nThat ` HeavyChartLibrary ` never ships to the client. The chart renders on the server, and the browser receives pre-rendered HTML. The only JavaScript that reaches the client is the tiny filter dropdown.\n\nIn a real production migration, this pattern cut the main dashboard's client-side JS from 340KB to 89KB (gzipped). First Contentful Paint improved by 1.2 seconds on a median 4G connection.\n\n## Streaming SSR with Suspense: The Practical Setup\n\n```jsx\n// app/page.jsx (server component by default in Next.js App Router)\nimport { Suspense } from 'react';\nimport { ProductList } from './ProductList';\nimport { RecommendationsPanel } from './RecommendationsPanel';\nimport { ReviewsSkeleton, RecommendationsSkeleton } from './Skeletons';\n\nexport default async function ProductPage({ params }) {\n // This resolves fast, it's a simple DB lookup\n const product = await getProduct(params.id);\n\n return (\n <main>\n <h1>{product.name}</h1>\n <p>{product.description}</p>\n <price>{product.price}</price>\n\n {/* Reviews might be slow, don't block the page */}\n <Suspense fallback={<ReviewsSkeleton />}>\n <ProductReviews productId={params.id} />\n </Suspense>\n\n {/* Recommendations are even slower, ML inference */}\n <Suspense fallback={<RecommendationsSkeleton />}>\n <RecommendationsPanel productId={params.id} />\n </Suspense>\n </main>\n );\n}\n\n// This component streams in when the data is ready\nasync function ProductReviews({ productId }) {\n const reviews = await getReviews(productId); // might take 800ms\n\n return (\n <section>\n <h2>Reviews ({reviews.length})</h2>\n {reviews.map(r => (\n <ReviewCard key={r.id} review={r} />\n ))}\n </section>\n );\n}\n```\n\nWhat the user sees:\n\n1. **Instant**: Product name, description, price + skeleton placeholders\n2. **~800ms**: Reviews pop in, replacing the skeleton\n3. **~1500ms**: Recommendations panel fills in\n\nThe browser starts rendering as soon as the first chunk arrives. No white screen waiting for the slowest API call.\n\n## Server Actions: Built-in RPC for React\n\nServer Actions are functions that run on the server but can be called from client components. They compile down to POST requests with encrypted references.\n\n```jsx\n// actions.js\n'use server';\n\nimport { revalidatePath } from 'next/cache';\nimport { redirect } from 'next/navigation';\n\nexport async function createComment(formData) {\n const content = formData.get('content');\n const postId = formData.get('postId');\n\n // Validate on the server, never trust the client\n if (!content || content.length > 5000) {\n return { error: 'Comment must be between 1 and 5000 characters' };\n }\n\n const user = await getCurrentUser();\n if (!user) {\n redirect('/login');\n }\n\n await db.comments.create({\n content,\n postId,\n authorId: user.id,\n createdAt: new Date(),\n });\n\n // Revalidate the page so the new comment shows up\n revalidatePath(`/posts/${postId}`);\n return { success: true };\n}\n```\n\n```jsx\n// CommentForm.jsx\n'use client';\n\nimport { useActionState } from 'react';\nimport { createComment } from './actions';\n\nexport function CommentForm({ postId }) {\n const [state, formAction, isPending] = useActionState(createComment, null);\n\n return (\n <form action={formAction}>\n <input type=\"hidden\" name=\"postId\" value={postId} />\n <textarea\n name=\"content\"\n placeholder=\"Write a comment...\"\n disabled={isPending}\n />\n <button type=\"submit\" disabled={isPending}>\n {isPending ? 'Posting...' : 'Post Comment'}\n </button>\n {state?.error && <p className=\"error\">{state.error}</p>}\n </form>\n );\n}\n```\n\nThe beautiful thing: this form works without JavaScript. The ` <form action={formAction}> ` progressively enhances, without JS, it submits as a regular POST. With JS, React intercepts it, sends the request in the background, and updates the UI optimistically.\n\nThree forms have been shipped in production using this pattern and the progressive enhancement story is real. On slow connections where JS has not loaded yet, users can still submit. That is not a theoretical benefit, session replays from users in rural areas on 2G connections confirm it.\n\n## Server-Side Data Fetching Patterns\n\nIn the RSC model, ` useEffect ` for data fetching and ` react-query ` for initial loads are no longer necessary. Data is fetched at the component level, where it is needed:\n\n```jsx\n// This is a server component, it can access the database directly\nasync function UserProfile({ userId }) {\n const [user, posts, stats] = await Promise.all([\n db.users.findById(userId),\n db.posts.findByAuthor(userId, { limit: 10 }),\n analytics.getUserStats(userId),\n ]);\n\n return (\n <div>\n <h1>{user.name}</h1>\n <StatsBanner stats={stats} />\n <PostList posts={posts} />\n <Suspense fallback={<ActivitySkeleton />}>\n <ActivityFeed userId={userId} /> {/* slow query, stream it */}\n </Suspense>\n </div>\n );\n}\n```\n\nNo loading states for the main content. No waterfall requests. No ` useEffect ` + ` useState ` + ` isLoading ` boilerplate. The component is an async function that fetches its data and renders. If you need to show a loading state, wrap it in Suspense at the parent level.\n\nThe ` Promise.all ` is important, without it, the three queries run sequentially. This mistake appears constantly in code reviews. Developers write three sequential ` await ` calls when the fetches are independent.\n\n## Migration Strategy: The Strangler Fig Approach\n\nYou're not rewriting your app. You're gradually moving components to the server:\n\n**Step 1**: Identify your heaviest client-side dependencies. Run ` npx @next/bundle-analyzer ` and sort by size. The biggest wins come from moving data formatting libraries, markdown renderers, and heavy UI component libraries to server components.\n\n**Step 2**: For each heavy component, ask: \"Does this need interactivity?\" If it's rendering static content derived from data, it's a server component candidate.\n\n**Step 3**: Extract the interactive bits into minimal client components. A 500-line component that only needs ` useState ` in one place becomes a 480-line server component + a 20-line client wrapper.\n\n```jsx\n// Before: entire component is client-side\n'use client';\nimport { useState } from 'react';\nimport { marked } from 'marked'; // 35KB\nimport { Prism } from 'prism-react-renderer'; // 15KB\nimport DOMPurify from 'dompurify'; // 20KB\n\nexport function Article({ markdown }) {\n const [fontSize, setFontSize] = useState(16);\n const html = DOMPurify.sanitize(marked.parse(markdown));\n\n return (\n <div style={{ fontSize }}>\n <FontSizeControl value={fontSize} onChange={setFontSize} />\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </div>\n );\n}\n```\n\n```jsx\n// After: heavy processing on server, tiny interactive wrapper\n// ArticleContent.jsx (server component)\nimport { marked } from 'marked'; // never ships to client\nimport { Prism } from 'prism-react-renderer'; // never ships to client\nimport DOMPurify from 'dompurify'; // never ships to client\nimport { FontSizeWrapper } from './FontSizeWrapper';\n\nexport function ArticleContent({ markdown }) {\n const html = DOMPurify.sanitize(marked.parse(markdown));\n\n return (\n <FontSizeWrapper>\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </FontSizeWrapper>\n );\n}\n\n// FontSizeWrapper.jsx (client component, 0.5KB)\n'use client';\nimport { useState } from 'react';\n\nexport function FontSizeWrapper({ children }) {\n const [fontSize, setFontSize] = useState(16);\n return (\n <div style={{ fontSize }}>\n <button onClick={() => setFontSize(f => f - 2)}>A-</button>\n <button onClick={() => setFontSize(f => f + 2)}>A+</button>\n {children}\n </div>\n );\n}\n```\n\n70KB of libraries removed from the client bundle. The markdown rendering, syntax highlighting, and sanitization all happen on the server.\n\n## Measured Performance Characteristics\n\nOn a real e-commerce product page (Next.js 15, App Router, deployed on [Vercel](<https://vercel.com/>)):\n\n| Metric | Pages Router (CSR) | App Router (RSC + Streaming) |\n| --- | --- | --- |\n| FCP | 2.1s | 0.8s |\n| LCP | 3.4s | 1.2s |\n| TTI | 4.2s | 1.8s |\n| Client JS (gzip) | 287KB | 112KB |\n| TTFB | 180ms | 210ms |\n\nTTFB is slightly *worse* with RSC because the server does more work before sending the first byte. But everything else is dramatically better because the client receives pre-rendered HTML and less JavaScript.\n\nThe streaming aspect is the real win for perceived performance. Users see content in under a second even if the full page takes 2 seconds to complete. That perceived speed difference is enormous for bounce rates.\n\n## The Sharp Edges\n\n**Serialization boundary**: Props passed from server to client components must be serializable. No functions, no classes, no ` Date ` objects (use ISO strings), no ` Map `/` Set `. This catches people constantly.\n\n**Third-party libraries**: Most React libraries assume a client environment. If you import a library that accesses ` window ` or ` document ` in a server component, you'll get a build error. Check if the library exports a server-compatible version or wrap it in a client component.\n\n**Debugging**: The RSC payload format is not human-readable. When something goes wrong in the server-to-client handoff, the error messages are cryptic. The React DevTools Profiler has improved significantly, but debugging streaming issues still feels like reading tea leaves.\n\n**Cache invalidation**: ` revalidatePath ` and ` revalidateTag ` are powerful but coarse. There's no built-in way to invalidate a specific user's cached page without invalidating it for everyone. For user-specific content, you often end up bypassing the cache entirely with ` export const dynamic = 'force-dynamic' `.\n\n## When NOT to Use RSC\n\nNot everything should be a server component. Highly interactive UIs, drag-and-drop builders, real-time collaborative editors, canvas-based tools, are still better as client-side applications. RSC shines for content-heavy pages with islands of interactivity, which is frankly most of the web.\n\nIf your page is a full-screen interactive application where every component needs access to client-side state, RSC adds complexity without much benefit. Know your use case.\n\nThe key mental model: if rendering data, use a server component. If managing state, use a client component. The boundary between them is where the architecture gets interesting, and getting that boundary right is what separates a fast RSC app from a slow one that is just doing SSR with extra steps.\n\n## Frequently asked questions\n\n### Do React Server Components replace SSR?\n\nNo, they complement it. SSR renders the initial HTML. RSC determines which components run on the server vs. client and enables streaming. You typically use both together.\n\n### Can I use RSC without Next.js?\n\nTechnically yes, the React team has published a bundler spec. But in practice, Next.js and Waku are the only production-ready implementations. Rolling your own RSC bundler integration is a multi-month project.\n\n### What's the actual bundle size savings?\n\nIt depends on your component tree. In a representative migration, moving data-fetching and markdown rendering to server components cut the client JS by 38%. The wins come from heavy dependencies that never need to ship to the browser.\n\n## Tips\n\n1. **Tip:** Don't slap ` 'use client' ` on a component just because it uses ` useState `, lift the state into a tiny client wrapper and keep the expensive rendering on the server.\n2. **Tip:** Place your ` <Suspense> ` boundaries at data-fetching edges, not around every component. Each boundary adds a chunk to the stream and too many cause layout thrash.\n3. **Tip:** Server Actions are just POST requests under the hood. You can call them from ` fetch() ` outside React, useful for service workers and background sync.\n4. **Tip:** Use ` React.cache() ` for deduplicating data fetches within a single render pass. It's request-scoped, not persistent, don't confuse it with ` unstable_cache `.\n\n- react\n- rsc\n- server-components\n- streaming\n- ssr\n- suspense\n- next.js\n- frontend-architecture\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#title"},{"level":2,"text":"RSC Request Lifecycle","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#rsc-request-lifecycle"},{"level":2,"text":"The Cost of 'use client'","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#the-cost-of-use-client"},{"level":2,"text":"Streaming SSR with Suspense: The Practical Setup","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#streaming-ssr-with-suspense-the-practical-setup"},{"level":2,"text":"Server Actions: Built-in RPC for React","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#server-actions-built-in-rpc-for-react"},{"level":2,"text":"Server-Side Data Fetching Patterns","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#server-side-data-fetching-patterns"},{"level":2,"text":"Migration Strategy: The Strangler Fig Approach","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#migration-strategy-the-strangler-fig-approach"},{"level":2,"text":"Measured Performance Characteristics","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#measured-performance-characteristics"},{"level":2,"text":"The Sharp Edges","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#the-sharp-edges"},{"level":2,"text":"When NOT to Use RSC","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#when-not-to-use-rsc"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#questions"},{"level":3,"text":"Do React Server Components replace SSR?","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#question-1"},{"level":3,"text":"Can I use RSC without Next.js?","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#question-2"},{"level":3,"text":"What's the actual bundle size savings?","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#article-tips"}],"sections":[{"heading":"React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns","level":1,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture","content":"Frontend"},{"heading":"React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns","level":1,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#title","content":"2026-01-0110 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/react-server-components-streaming-architecture>)"},{"heading":"RSC Request Lifecycle","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#rsc-request-lifecycle","content":"The following describes what occurs when a browser requests a page with RSC:\n\n1. The server receives the request and starts rendering the component tree\n2. Server Components execute on the server, they can ` await ` database queries, file reads, API calls directly\n3. When the server hits a ` 'use client' ` boundary, it stops rendering that subtree and instead emits a **reference**, a pointer that says \"render this client component here with these serialized props\"\n4. The server streams the output as a special format called the **RSC payload**, not HTML, but a serializable description of the component tree\n5. The client-side React runtime receives this payload, hydrates the client components, and stitches everything together\n\nThe key insight: the RSC payload is **streamed**. The server doesn't wait for all data to resolve before sending the first byte. Suspense boundaries define the streaming chunks.\n\n```\nServer starts rendering\n├── Layout (server) → streams immediately\n├── Header (server) → streams immediately\n├── MainContent (server, awaiting DB) → streams when data resolves\n│ └── <Suspense fallback={<Skeleton />}>\n│ └── Comments (server, awaiting API) → streams later\n└── InteractiveWidget ('use client') → reference in payload, hydrates on client\n```"},{"heading":"The Cost of 'use client'","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#the-cost-of-use-client","content":"A common misconception: ` 'use client' ` does not simply mean \"this component runs on the client.\" It means **this component AND every component it imports** becomes part of the client bundle. It's a boundary declaration, not a per-component toggle.\n\n```jsx\n// This one 'use client' pulls everything into the client bundle\n'use client';\n\nimport { HeavyChartLibrary } from 'heavy-charts'; // 200KB\nimport { DateFormatter } from './utils'; // 2KB\nimport { UserAvatar } from './UserAvatar'; // 5KB + image processing\n\nexport function Dashboard({ data }) {\n const [filter, setFilter] = useState('all');\n // ...\n}\n```\n\nThe fix is surgical decomposition:\n\n```jsx\n// ServerDashboard.jsx (server component - no directive needed)\nimport { HeavyChartLibrary } from 'heavy-charts'; // stays on server!\nimport { DateFormatter } from './utils'; // stays on server!\nimport { DashboardFilter } from './DashboardFilter'; // client boundary\n\nexport async function ServerDashboard() {\n const data = await db.metrics.getAll(); // direct DB access\n\n return (\n <div>\n <h1>Dashboard</h1>\n <DashboardFilter /> {/* tiny client component */}\n {/* Heavy chart renders on server, ships as HTML */}\n <HeavyChartLibrary data={data} interactive={false} />\n <p>Last updated: {DateFormatter.relative(data.updatedAt)}</p>\n </div>\n );\n}\n```\n\n```jsx\n// DashboardFilter.jsx\n'use client';\n\nimport { useState } from 'react';\n\nexport function DashboardFilter() {\n const [filter, setFilter] = useState('all');\n return (\n <select value={filter} onChange={(e) => setFilter(e.target.value)}>\n <option value=\"all\">All</option>\n <option value=\"active\">Active</option>\n <option value=\"archived\">Archived</option>\n </select>\n );\n}\n```\n\nThat ` HeavyChartLibrary ` never ships to the client. The chart renders on the server, and the browser receives pre-rendered HTML. The only JavaScript that reaches the client is the tiny filter dropdown.\n\nIn a real production migration, this pattern cut the main dashboard's client-side JS from 340KB to 89KB (gzipped). First Contentful Paint improved by 1.2 seconds on a median 4G connection."},{"heading":"Streaming SSR with Suspense: The Practical Setup","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#streaming-ssr-with-suspense-the-practical-setup","content":"```jsx\n// app/page.jsx (server component by default in Next.js App Router)\nimport { Suspense } from 'react';\nimport { ProductList } from './ProductList';\nimport { RecommendationsPanel } from './RecommendationsPanel';\nimport { ReviewsSkeleton, RecommendationsSkeleton } from './Skeletons';\n\nexport default async function ProductPage({ params }) {\n // This resolves fast, it's a simple DB lookup\n const product = await getProduct(params.id);\n\n return (\n <main>\n <h1>{product.name}</h1>\n <p>{product.description}</p>\n <price>{product.price}</price>\n\n {/* Reviews might be slow, don't block the page */}\n <Suspense fallback={<ReviewsSkeleton />}>\n <ProductReviews productId={params.id} />\n </Suspense>\n\n {/* Recommendations are even slower, ML inference */}\n <Suspense fallback={<RecommendationsSkeleton />}>\n <RecommendationsPanel productId={params.id} />\n </Suspense>\n </main>\n );\n}\n\n// This component streams in when the data is ready\nasync function ProductReviews({ productId }) {\n const reviews = await getReviews(productId); // might take 800ms\n\n return (\n <section>\n <h2>Reviews ({reviews.length})</h2>\n {reviews.map(r => (\n <ReviewCard key={r.id} review={r} />\n ))}\n </section>\n );\n}\n```\n\nWhat the user sees:\n\n1. **Instant**: Product name, description, price + skeleton placeholders\n2. **~800ms**: Reviews pop in, replacing the skeleton\n3. **~1500ms**: Recommendations panel fills in\n\nThe browser starts rendering as soon as the first chunk arrives. No white screen waiting for the slowest API call."},{"heading":"Server Actions: Built-in RPC for React","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#server-actions-built-in-rpc-for-react","content":"Server Actions are functions that run on the server but can be called from client components. They compile down to POST requests with encrypted references.\n\n```jsx\n// actions.js\n'use server';\n\nimport { revalidatePath } from 'next/cache';\nimport { redirect } from 'next/navigation';\n\nexport async function createComment(formData) {\n const content = formData.get('content');\n const postId = formData.get('postId');\n\n // Validate on the server, never trust the client\n if (!content || content.length > 5000) {\n return { error: 'Comment must be between 1 and 5000 characters' };\n }\n\n const user = await getCurrentUser();\n if (!user) {\n redirect('/login');\n }\n\n await db.comments.create({\n content,\n postId,\n authorId: user.id,\n createdAt: new Date(),\n });\n\n // Revalidate the page so the new comment shows up\n revalidatePath(`/posts/${postId}`);\n return { success: true };\n}\n```\n\n```jsx\n// CommentForm.jsx\n'use client';\n\nimport { useActionState } from 'react';\nimport { createComment } from './actions';\n\nexport function CommentForm({ postId }) {\n const [state, formAction, isPending] = useActionState(createComment, null);\n\n return (\n <form action={formAction}>\n <input type=\"hidden\" name=\"postId\" value={postId} />\n <textarea\n name=\"content\"\n placeholder=\"Write a comment...\"\n disabled={isPending}\n />\n <button type=\"submit\" disabled={isPending}>\n {isPending ? 'Posting...' : 'Post Comment'}\n </button>\n {state?.error && <p className=\"error\">{state.error}</p>}\n </form>\n );\n}\n```\n\nThe beautiful thing: this form works without JavaScript. The ` <form action={formAction}> ` progressively enhances, without JS, it submits as a regular POST. With JS, React intercepts it, sends the request in the background, and updates the UI optimistically.\n\nThree forms have been shipped in production using this pattern and the progressive enhancement story is real. On slow connections where JS has not loaded yet, users can still submit. That is not a theoretical benefit, session replays from users in rural areas on 2G connections confirm it."},{"heading":"Server-Side Data Fetching Patterns","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#server-side-data-fetching-patterns","content":"In the RSC model, ` useEffect ` for data fetching and ` react-query ` for initial loads are no longer necessary. Data is fetched at the component level, where it is needed:\n\n```jsx\n// This is a server component, it can access the database directly\nasync function UserProfile({ userId }) {\n const [user, posts, stats] = await Promise.all([\n db.users.findById(userId),\n db.posts.findByAuthor(userId, { limit: 10 }),\n analytics.getUserStats(userId),\n ]);\n\n return (\n <div>\n <h1>{user.name}</h1>\n <StatsBanner stats={stats} />\n <PostList posts={posts} />\n <Suspense fallback={<ActivitySkeleton />}>\n <ActivityFeed userId={userId} /> {/* slow query, stream it */}\n </Suspense>\n </div>\n );\n}\n```\n\nNo loading states for the main content. No waterfall requests. No ` useEffect ` + ` useState ` + ` isLoading ` boilerplate. The component is an async function that fetches its data and renders. If you need to show a loading state, wrap it in Suspense at the parent level.\n\nThe ` Promise.all ` is important, without it, the three queries run sequentially. This mistake appears constantly in code reviews. Developers write three sequential ` await ` calls when the fetches are independent."},{"heading":"Migration Strategy: The Strangler Fig Approach","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#migration-strategy-the-strangler-fig-approach","content":"You're not rewriting your app. You're gradually moving components to the server:\n\n**Step 1**: Identify your heaviest client-side dependencies. Run ` npx @next/bundle-analyzer ` and sort by size. The biggest wins come from moving data formatting libraries, markdown renderers, and heavy UI component libraries to server components.\n\n**Step 2**: For each heavy component, ask: \"Does this need interactivity?\" If it's rendering static content derived from data, it's a server component candidate.\n\n**Step 3**: Extract the interactive bits into minimal client components. A 500-line component that only needs ` useState ` in one place becomes a 480-line server component + a 20-line client wrapper.\n\n```jsx\n// Before: entire component is client-side\n'use client';\nimport { useState } from 'react';\nimport { marked } from 'marked'; // 35KB\nimport { Prism } from 'prism-react-renderer'; // 15KB\nimport DOMPurify from 'dompurify'; // 20KB\n\nexport function Article({ markdown }) {\n const [fontSize, setFontSize] = useState(16);\n const html = DOMPurify.sanitize(marked.parse(markdown));\n\n return (\n <div style={{ fontSize }}>\n <FontSizeControl value={fontSize} onChange={setFontSize} />\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </div>\n );\n}\n```\n\n```jsx\n// After: heavy processing on server, tiny interactive wrapper\n// ArticleContent.jsx (server component)\nimport { marked } from 'marked'; // never ships to client\nimport { Prism } from 'prism-react-renderer'; // never ships to client\nimport DOMPurify from 'dompurify'; // never ships to client\nimport { FontSizeWrapper } from './FontSizeWrapper';\n\nexport function ArticleContent({ markdown }) {\n const html = DOMPurify.sanitize(marked.parse(markdown));\n\n return (\n <FontSizeWrapper>\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </FontSizeWrapper>\n );\n}\n\n// FontSizeWrapper.jsx (client component, 0.5KB)\n'use client';\nimport { useState } from 'react';\n\nexport function FontSizeWrapper({ children }) {\n const [fontSize, setFontSize] = useState(16);\n return (\n <div style={{ fontSize }}>\n <button onClick={() => setFontSize(f => f - 2)}>A-</button>\n <button onClick={() => setFontSize(f => f + 2)}>A+</button>\n {children}\n </div>\n );\n}\n```\n\n70KB of libraries removed from the client bundle. The markdown rendering, syntax highlighting, and sanitization all happen on the server."},{"heading":"Measured Performance Characteristics","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#measured-performance-characteristics","content":"On a real e-commerce product page (Next.js 15, App Router, deployed on [Vercel](<https://vercel.com/>)):\n\n| Metric | Pages Router (CSR) | App Router (RSC + Streaming) |\n| --- | --- | --- |\n| FCP | 2.1s | 0.8s |\n| LCP | 3.4s | 1.2s |\n| TTI | 4.2s | 1.8s |\n| Client JS (gzip) | 287KB | 112KB |\n| TTFB | 180ms | 210ms |\n\nTTFB is slightly *worse* with RSC because the server does more work before sending the first byte. But everything else is dramatically better because the client receives pre-rendered HTML and less JavaScript.\n\nThe streaming aspect is the real win for perceived performance. Users see content in under a second even if the full page takes 2 seconds to complete. That perceived speed difference is enormous for bounce rates."},{"heading":"The Sharp Edges","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#the-sharp-edges","content":"**Serialization boundary**: Props passed from server to client components must be serializable. No functions, no classes, no ` Date ` objects (use ISO strings), no ` Map `/` Set `. This catches people constantly.\n\n**Third-party libraries**: Most React libraries assume a client environment. If you import a library that accesses ` window ` or ` document ` in a server component, you'll get a build error. Check if the library exports a server-compatible version or wrap it in a client component.\n\n**Debugging**: The RSC payload format is not human-readable. When something goes wrong in the server-to-client handoff, the error messages are cryptic. The React DevTools Profiler has improved significantly, but debugging streaming issues still feels like reading tea leaves.\n\n**Cache invalidation**: ` revalidatePath ` and ` revalidateTag ` are powerful but coarse. There's no built-in way to invalidate a specific user's cached page without invalidating it for everyone. For user-specific content, you often end up bypassing the cache entirely with ` export const dynamic = 'force-dynamic' `."},{"heading":"When NOT to Use RSC","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#when-not-to-use-rsc","content":"Not everything should be a server component. Highly interactive UIs, drag-and-drop builders, real-time collaborative editors, canvas-based tools, are still better as client-side applications. RSC shines for content-heavy pages with islands of interactivity, which is frankly most of the web.\n\nIf your page is a full-screen interactive application where every component needs access to client-side state, RSC adds complexity without much benefit. Know your use case.\n\nThe key mental model: if rendering data, use a server component. If managing state, use a client component. The boundary between them is where the architecture gets interesting, and getting that boundary right is what separates a fast RSC app from a slow one that is just doing SSR with extra steps."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#questions","content":""},{"heading":"Do React Server Components replace SSR?","level":3,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#question-1","content":"No, they complement it. SSR renders the initial HTML. RSC determines which components run on the server vs. client and enables streaming. You typically use both together."},{"heading":"Can I use RSC without Next.js?","level":3,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#question-2","content":"Technically yes, the React team has published a bundler spec. But in practice, Next.js and Waku are the only production-ready implementations. Rolling your own RSC bundler integration is a multi-month project."},{"heading":"What's the actual bundle size savings?","level":3,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#question-3","content":"It depends on your component tree. In a representative migration, moving data-fetching and markdown rendering to server components cut the client JS by 38%. The wins come from heavy dependencies that never need to ship to the browser."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/react-server-components-streaming-architecture#article-tips","content":"1. **Tip:** Don't slap ` 'use client' ` on a component just because it uses ` useState `, lift the state into a tiny client wrapper and keep the expensive rendering on the server.\n2. **Tip:** Place your ` <Suspense> ` boundaries at data-fetching edges, not around every component. Each boundary adds a chunk to the stream and too many cause layout thrash.\n3. **Tip:** Server Actions are just POST requests under the hood. You can call them from ` fetch() ` outside React, useful for service workers and background sync.\n4. **Tip:** Use ` React.cache() ` for deduplicating data fetches within a single render pass. It's request-scoped, not persistent, don't confuse it with ` unstable_cache `.\n\n- react\n- rsc\n- server-components\n- streaming\n- ssr\n- suspense\n- next.js\n- frontend-architecture\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture"},{"text":"Vercel","url":"https://vercel.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/react-server-components-streaming-architecture#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/react-server-components-streaming-architecture"},"headline":"React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns","description":"A deep dive into React Server Components, streaming SSR, Suspense boundaries, and server actions, with honest benchmarks on the real cost of client components and practical migration patterns.","datePublished":"2026-01-01","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Frontend","keywords":"react, rsc, server-components, streaming, ssr, suspense, next.js, frontend-architecture","wordCount":1802,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"React Server Components: Streaming Architecture, Performance Tradeoffs, and Migration Patterns","item":"https://lucioduran.com/blog/react-server-components-streaming-architecture"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Do React Server Components replace SSR?","acceptedAnswer":{"@type":"Answer","text":"No, they complement it. SSR renders the initial HTML. RSC determines which components run on the server vs. client and enables streaming. You typically use both together."}},{"@type":"Question","name":"Can I use RSC without Next.js?","acceptedAnswer":{"@type":"Answer","text":"Technically yes, the React team has published a bundler spec. But in practice, Next.js and Waku are the only production-ready implementations. Rolling your own RSC bundler integration is a multi-month project."}},{"@type":"Question","name":"What's the actual bundle size savings?","acceptedAnswer":{"@type":"Answer","text":"It depends on your component tree. In a representative migration, moving data-fetching and markdown rendering to server components cut the client JS by 38%. The wins come from heavy dependencies that never need to ship to the browser."}}]}]}],"contentHash":"cd39742e104f30cb98e60966ede461884da429d71e849b859c90ad0879e0f1b3","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/react-server-components-streaming-architecture","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":71503,"contentBytes":15315}}
