Rendering Strategies and React Server Components: Where HTML Gets Built
CSR, SSR, SSG, streaming, islands, and RSC — a principal-level map of where HTML is generated, what hydration costs, and how to choose.
Mọi quyết định kiến trúc frontend cuối cùng đều thu gọn về một câu hỏi: HTML được tạo ở đâu và khi nào? Câu trả lời không phải nhị phân — đó là một phổ từ SPA render hoàn toàn trên client đến document render hoàn toàn trên server, với các mô hình lai (SSG, ISR, streaming, islands, RSC) nằm ở giữa.
Bài viết này dành cho senior và principal engineer đã quen React. Chúng ta sẽ không lặp lại nội bộ Virtual DOM hay so sánh trang marketing của framework. Thay vào đó, ta ánh xạ chiến lược render với độ trễ người dùng cảm nhận, chi phí vận hành, và failure mode — rồi đặt React Server Components (RSC) vào bối cảnh đó tính đến 2025–2026.
Ghi chú phạm vi: Ví dụ dùng idiom React/Next.js vì RSC là tính năng React, nhưng trade-off áp dụng cho mọi stack.
The rendering spectrum
At the highest level, rendering strategies differ along three axes:
Ở mức cao nhất, chiến lược render khác nhau theo ba trục:
| Axis | Client-heavy | Server-heavy |
|---|---|---|
| When HTML is produced | After JS downloads and executes | Before or during the HTTP response |
| Where computation runs | User’s device (browser) | Origin server, edge, or build pipeline |
| What ships to the client | JS bundle + data fetches | HTML (+ optional JS for interactivity) |
Chiến lược “tốt nhất” không bao giờ phổ quát — phụ thuộc độ tươi của content, mật độ tương tác, ranh giới auth, và ngân sách hạ tầng.
Fully client ◄────────────────────────────────────────► Fully server
CSR SSR (blocking) Streaming SSR SSG/ISR RSC + islands
│ │ │ │ │
│ │ │ │ └─ Server components + selective client JS
│ │ │ └─ Pre-built HTML, optional revalidation
│ │ └─ Chunked HTML as data resolves
│ └─ Full HTML per request, then hydrate
└─ Empty shell → fetch → render in browser
Client-Side Rendering (CSR)
Trong CSR, server gửi HTML shell tối thiểu (thường chỉ <div id="root"></div>) và bundle JavaScript. Browser tải, parse, và thực thi JS, rồi fetch data và dựng DOM.
Why CSR dominated for a decade
CSR đơn giản hóa deploy: file tĩnh trên CDN, không cần compute server theo page view. Nó khớp tự nhiên với REST/GraphQL API và tách repo frontend/backend rõ ràng. Với dashboard có auth mà SEO không quan trọng, CSR vẫn ổn.
The blank-screen problem
The user sees nothing useful until:
Người dùng không thấy gì hữu ích cho đến khi:
- HTML document tới
- JS bundle tải xong (thường 200KB–2MB+ gzip cho app React)
- JS parse và chạy
- Fetch data xong (waterfall nếu lồng nhau)
- React commit lên DOM
Trên mạng chậm hoặc thiết bị yếu, bước 2–4 chi phối Time to Interactive (TTI). Lighthouse có thể cho FCP chấp nhận được nếu spinner render sớm — nhưng spinner không phải content.
CSR failure modes
| Failure mode | Root cause |
|---|---|
| SEO gaps | Crawlers that don’t execute JS miss content |
| Layout shift after hydration | Client-only measurement changes layout |
| Bundle bloat | Every route’s dependencies ship upfront or via lazy chunks with latency cost |
| Request waterfalls | Component tree triggers sequential fetches after mount |
Khi CSR vẫn đúng: Tool nội bộ, UI trading real-time, editor cộng tác, hoặc app sau auth mà TTFB ít quan trọng hơn state client phong phú.
Server-Side Rendering (SSR)
SSR tạo HTML trên server theo request (hoặc theo request có lớp cache). Browser nhận markup có nghĩa ngay — metric gần Time to First Byte (TTFB) như FCP và LCP tốt hơn.
// Conceptual SSR flow (framework handles the wiring)
async function renderPage(url: string): Promise<string> {
const data = await fetchProduct(url);
const html = ReactDOMServer.renderToString(<ProductPage data={data} />);
return wrapInDocument(html);
}
TTFB vs interactivity
SSR cải thiện first paint nhưng không loại bỏ yêu cầu JS client cho app React. Cùng bundle vẫn phải tải và hydrate — gắn event listener và reconcile HTML server với React client.
Bạn có thể có LCP nhanh và TTI chậm cùng lúc. Người dùng thấy content nhưng nút chưa phản hồi cho đến khi hydration xong.
The hydration uncanny valley
Giữa lúc HTML tới và hydration xong, trang trông tương tác được nhưng thực tế không. Click vùng chưa hydrate có thể không làm gì hoặc xếp hàng không dự đoán được. Input form có thể reset khi React tiếp quản nếu markup khác.
Đây là uncanny valley của hydration: nhìn hoàn chỉnh, hành vi chưa đủ.
Hydration cost
Hydration không miễn phí.
Client phải:
- Chạy lại logic component (thường lặp công việc server)
- Duyệt cả cây (hoặc subtree lớn) để gắn fiber
- Serialize/deserialize props nhúng trong HTML
Với trang nhiều content và island tương tác nhỏ (nav toggle, cart badge), hydrate cả trang là lãng phí.
Blocking SSR and the data waterfall
Classic blocking SSR waits for all data before sending HTML:
SSR blocking cổ điển chờ mọi data trước khi gửi HTML:
Request ──► fetch layout data ──► fetch page data ──► fetch sidebar data ──► HTML
─────────────────── TTFB grows linearly ───────────────────────►
Nếu call upstream chậm, cả response bị chặn. Đây là request waterfall trên server — cấu trúc giống waterfall client, chỉ dời sớm hơn.
Static Site Generation (SSG) and Incremental Static Regeneration (ISR)
SSG dời tạo HTML sang build time. Mỗi route thành file tĩnh trên CDN — TTFB gần với độ trễ mạng, không phải compute server.
# Build-time: generate /blog/post-1.html, /blog/post-2.html, ...
npm run build
# Deploy: CDN serves pre-rendered HTML globally
When SSG fits
- Trang marketing, docs, blog
- Catalog đổi theo nhịp deploy, không theo giây
- Content biết slug lúc build
ISR: static speed with eventual freshness
ISR (phổ biến qua Next.js revalidate) giữ delivery tĩnh nhưng regenerate trang nền sau TTL hoặc on-demand.
| Trigger | Behavior |
|---|---|
Time-based (revalidate: 60) | Serve stale HTML; rebuild after 60s on next request |
| On-demand (webhook/tag) | Invalidate specific paths when CMS publishes |
| First request after expiry | User may get stale page while rebuild runs (stale-while-revalidate) |
Cache invalidation is the hard part
ISR chuyển phức tạp từ runtime render sang cache coherence.
Principal engineer nên hỏi:
- Độ stale tối đa người dùng chấp nhận?
- Segment cá nhân hóa có phá cache tĩnh?
- Invalidate path liên quan (index + detail + RSS) thế nào?
Pitfall: Coi ISR như “SSR miễn phí.” Regenerate vẫn đánh origin, database, CMS API — ở scale, spike theo pattern traffic.
Streaming SSR
Streaming SSR gửi HTML theo chunk khi từng phần sẵn sàng, dùng HTTP chunked transfer encoding. renderToPipeableStream của React 18 (Node) và API tương tự runtime khác cho phép điều này.
Suspense boundaries as backpressure valves
Bọc subtree chậm trong Suspense với fallback; server emit fallback trước, rồi stream UI đã resolve.
export default function Page() {
return (
<main>
<Header /> {/* sends immediately */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* streams when DB query completes */}
</Suspense>
</main>
);
}
Browser có thể paint shell và above-the-fold trong khi comments vẫn đang load. TTFB tốt hơn vì byte đầu rời đi trước khi IO chậm xong.
Progressive and selective hydration
Streaming đi cùng progressive hydration: hydrate vùng tương tác theo thứ tự ưu tiên (viewport trước, idle sau). Framework như Marko, Qwik, và Astro (với client directive) đi xa hơn về partial/selective hydration — chỉ island cần JS client trả chi phí.
Streaming failure modes
| Issue | What goes wrong |
|---|---|
| Suspense misplacement | Entire page blocked if boundary wraps too much |
| Error during stream | Partial HTML sent; error boundaries must handle late failures |
| Duplicate data fetching | Server component + client child both fetch same resource without dedup |
| CDN buffering | Some proxies buffer full response, defeating streaming |
Islands architecture and resumability
Islands architecture (Astro, Fresh sớm, Elder.js) đảo mặc định: HTML tĩnh mặc định; widget tương tác opt-in qua directive.
---
// Astro: server-rendered markdown + one interactive island
import Counter from '../components/Counter.jsx';
---
<article>{/* static HTML, zero JS */}</article>
<Counter client:visible />
Mỗi island hydrate độc lập. Phần còn lại trang ship không JS client.
Hydration vs resumability (Qwik)
Hydration cổ điển chạy lại code component trên client để dựng lại state. Resumability (mô hình Qwik) serialize state closure vào attribute HTML lúc SSR, rồi “resume” listener mà không chạy lại cả cây.
| Model | Client work on load |
|---|---|
| Full hydration | Re-run components, rebuild virtual tree, attach events |
| Partial hydration | Re-run subset of components |
| Resumability | Deserialize state, lazy-load handlers on interaction |
Không mô hình nào loại JS cho tương tác — chúng đổi khi nào và bao nhiêu JS chạy trước khi trang sống.
Insight principal: Islands và RSC cùng mục tiêu — giảm JavaScript client — nhưng khác component model. Islands tách theo ranh giới framework; RSC tách theo ranh giới server/client component trong React.
React Server Components (RSC)
React Server Components (ổn định trong React 19, áp dụng qua Next.js App Router, Waku, và setup thử nghiệm khác) giới thiệu loại component mới chỉ chạy trên server. Chúng không bao giờ hydrate. Không ship JavaScript lên client dưới dạng code component.
Tính đến 2025–2026, RSC production-ready nhưng có quan điểm: cần bundler, server runtime, và tích hợp flight protocol — không drop-in cho Create React App.
Server vs client components
Mặc định, component trong app bật RSC là Server Component. Thêm 'use client' đầu file để đánh dấu ranh giới Client Component.
// app/comments.tsx — Server Component (no directive)
import { db } from '@/lib/db';
export async function Comments({ postId }: { postId: string }) {
const rows = await db.comment.findMany({ where: { postId } });
return (
<ul>
{rows.map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
);
}
// app/like-button.tsx — Client Component
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked((v) => !v)}>
{liked ? 'Unlike' : 'Like'}
</button>
);
}
Rules that senior engineers internalize:
Quy tắc senior engineer nên nội hóa:
| Server Components | Client Components |
|---|---|
Can async/await directly | Cannot be async (as component function) |
| Can access DB, filesystem, secrets | Cannot access server-only modules |
No useState, useEffect, browser APIs | Full hooks and event handlers |
| Cannot import Client Components directly into server-only logic without composition | Can import Server Components as children/slots |
Pattern composition: Server Component render Client Component, truyền props serializable và children render trên server.
// page.tsx — Server Component
import { LikeButton } from './like-button';
import { Comments } from './comments';
export default async function PostPage({ params }: { params: { id: string } }) {
return (
<article>
<LikeButton postId={params.id} />
<Comments postId={params.id} />
</article>
);
}
The RSC payload and serialization
When the server renders, it produces:
Khi server render, nó tạo:
- HTML cho initial paint (SSR vẫn áp dụng)
- RSC Payload (Flight) — định dạng gọn, theo dòng mô tả cây component, props, và lazy reference
Client runtime reconcile dữ liệu Flight để cập nhật cây khi navigate mà không reload full page.
Chỉ props serializable: function, class instance, và Symbol không qua ranh giới được. Date và Map cần quy ước serialize rõ.
// ❌ Cannot pass a callback from Server to Client like this:
<ClientForm onSubmit={(data) => save(data)} />
// ✅ Use Server Actions or URL-based mutations instead
Ràng buộc này gây bất ngờ team migrate từ SSR cổ điển từng bundle nhầm inline handler.
Server Actions
Server Actions ('use server') là async function gọi từ Client Component qua POST, chạy trên server với bảo vệ CSRF (tùy framework).
// actions.ts
'use server';
export async function addComment(postId: string, formData: FormData) {
const body = formData.get('body');
await db.comment.create({ data: { postId, body: String(body) } });
}
'use client';
import { addComment } from './actions';
export function CommentForm({ postId }: { postId: string }) {
return (
<form action={(fd) => addComment(postId, fd)}>
<textarea name="body" />
<button type="submit">Post</button>
</form>
);
}
Server Actions colocate mutation với UI — giảm boilerplate API route — nhưng không thay mọi backend endpoint (webhook, tích hợp bên thứ ba, mobile client vẫn cần REST/GraphQL).
Colocated data fetching
Lợi ergonomic lớn của RSC: fetch nơi render, trên server, không lộ endpoint.
async function ProductDetails({ id }: { id: string }) {
const product = await getProduct(id); // direct DB or internal API
return <h1>{product.name}</h1>;
}
Fetch song song tự nhiên khi Server Component anh em độc lập — runtime có thể IO đồng thời. Khác với chuỗi useEffect trên client.
Cẩn thận: Không cache/dedup rõ (cache(), React.cache, fetch next: \{ revalidate \} trong Next.js), request trùng vẫn xảy ra giữa component.
What RSC does not solve
- Tương tác client vẫn cần Client Component và bundle JS.
- Layout shift nếu fallback stream thiết kế kém.
- Phức tạp vận hành: cụm server render, versioning flight protocol, debug stack tách.
- Rủi ro phụ thuộc vendor 2025–2026: tooling sâu nhất vẫn xoay quanh Next.js; runtime RSC khác có nhưng độ trưởng thành ecosystem khác nhau.
Decision matrix: app type → strategy
Dùng làm điểm xuất phát cho architecture review, không phải đơn thuốc.
| App profile | Primary strategy | Secondary / hybrid |
|---|---|---|
| Marketing site, docs, blog | SSG (+ ISR for CMS) | Islands for search, theme toggle |
| E-commerce catalog | ISR / SSG with on-demand invalidation | Client cart/checkout islands |
| Authenticated dashboard | CSR or RSC + Client islands | SSR for first paint if SEO/login matters |
| Social feed, real-time | CSR with SSE/WebSocket | Streaming SSR for shell |
| Content + rich interactivity (e.g. docs with playground) | SSG + partial hydration | RSC for dynamic auth-gated sections |
| Multi-tenant SaaS marketing + app | SSG marketing, CSR/RSC app subdomain | Shared design system, separate deploy |
Questions to ask in every review
- Google phải thấy gì không cần chạy JS? → hướng SSR/SSG/RSC server render
- Ngân sách p95 TTI trên Android tầm trung? → ít JS client hơn
- Data phải tươi đến mức nào? → SSG vs ISR vs SSR
- Secret ở đâu? → server component/action vs API public
- Ai vận hành server ở scale? → CSR/CDN-only vs compute render
Common pitfalls and failure modes
The 'use client' sprawl
Team gấp deadline đánh 'use client' cả thư mục vì cần một hook. Ranh giới RSC sụp — mọi con ship thành client bundle.
Sửa: Đẩy 'use client' xuống leaf; giữ parent fetch data là Server Component.
❌ 'use client' on page.tsx (entire route hydrates)
✅ page.tsx (server) → ChartWrapper (server) → Chart (client)
Accidental waterfalls
Dù có RSC, await tuần tự trong một Server Component tái tạo SSR blocking.
// ❌ Sequential — latency sums
async function Page() {
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
return <Feed user={user} posts={posts} comments={comments} />;
}
// ✅ Parallelize independent IO
async function Page() {
const [user, trending] = await Promise.all([getUser(), getTrending()]);
return (
<>
<UserHeader user={user} />
<Suspense fallback={<Skeleton />}>
<PostList userId={user.id} />
</Suspense>
<TrendingSidebar items={trending} />
</>
);
}
Over-fetching in Client Components
Chuyển fetch sang useEffect trong Client Component sau khi adopt RSC vứt bỏ lợi ích chính. Giữ fetch ở Server Component; truyền kết quả xuống.
Ignoring caching semantics
RSC không tự cache. cache(), unstable_cache, fetch revalidation, hoặc invalidate theo tag phải khớp yêu cầu staleness sản phẩm. Mặc định “dynamic mọi nơi” tái tạo chi phí SSR không lợi CDN.
Testing and observability gaps
Server Component phá giả định @testing-library/react trong jsdom — test leaf Client và integration-test path server riêng. Trace phân tán phải span server render, serialize Flight, và client navigation.
A principal-engineer mental model
Think in three budgets that trade against each other:
Nghĩ theo ba ngân sách cạnh tranh nhau:
┌─────────────────────────────────────────────────────────┐
│ 1. Bytes to client (JS + RSC payload + assets) │
│ 2. Server compute per request (SSR, RSC, actions) │
│ 3. Staleness / consistency (CDN cache, ISR, realtime) │
└─────────────────────────────────────────────────────────┘
Optimize one → usually costs another
Chiến lược render là vẽ ranh giới.
Bạn vẽ ranh giới giữa:
- Content tĩnh vs dynamic
- Logic chỉ server vs bắt buộc client
- Segment cache được vs cá nhân hóa
- Response stream vs atomic
RSC thêm trục thứ tư: props serializable vs không serializable qua wire. Mỗi file 'use client' là thuế có chủ đích lên ngân sách #1.
The 2025–2026 state of play
| Trend | Reality check |
|---|---|
| RSC as default for new React apps | True for Next.js App Router greenfield; less true elsewhere |
| CSR SPAs “dead” | False for apps prioritizing client state and offline |
| Islands gaining share | True for content sites; limited for complex client state |
| Streaming SSR baseline | Expected for React 18+ meta-frameworks |
| Partial hydration maturing | Selective approaches exist; no single standard yet |
React Server Components không chỉ là chiến lược render — đó là mô hình thực thi component kết hợp với SSR, streaming, và static generation. Kiến trúc thắng 2026 là hybrid có chủ đích: shell tĩnh, vùng dynamic stream, client island cho tương tác, và server component cho data và secret.
Nguyên tắc kết: Ship lượng JavaScript nhỏ nhất cần cho tương tác; tạo mọi thứ còn lại gần user (CDN/edge) hoặc sớm (build time) trong giới hạn staleness cho phép.
Đo FCP, LCP, TTI, và Total Blocking Time cùng lúc — tối ưu một metric riêng lẻ tạo uncanny valley chỗ khác. Đó là việc ở cấp principal: không chọn CSR hay SSR từ checklist blog, mà thương lượng ngân sách giữa product, infra, và UX với mắt mở về chi phí hydration, invalidate cache, và ranh giới serialize RSC.