Rendering is not a switch. It's not an all-or-nothing choice between Server-Side Rendering (SSR), Client-Side Rendering (CSR), or Streaming. It's a budget—a set of constraints shaped by data latency, user expectations, and the nature of your content.
The question is not "SSR vs CSR." The question is: "Where should the work live so users wait less, see content faster, and get an interactive page without unnecessary JavaScript?"
The Core Tension
Every rendering decision trades off:
- Time to First Byte (TTFB): How fast does the server respond?
- Time to First Contentful Paint (FCP): How soon does the browser see something?
- Time to Interactive (TTI): How soon can users interact?
- Bundle size: How much JavaScript lands in the browser?
- Server cost: How much compute happens server-side?
The classic framing—SSR for static sites, CSR for apps—misses the nuance. A page is rarely entirely static or entirely dynamic. It's usually both, in different zones.
The Decision Grid
Streaming: Progressive Revelation
Use streaming when you have:
- A cacheable above-the-fold section.
- Slow, asynchronous dependencies (database queries, API calls to third parties).
- The ability to show partial content while slower pieces load.
Example: A dashboard with fast KPI cards at the top and slower analytics widgets below. Stream the page with the KPIs rendered first, then stream in the widgets as they become available. The user sees value immediately; the skeleton screens manage expectations.
How it works:
- Server renders the fast part and sends it immediately.
- Browser renders and displays it while the server finishes slow work.
- Remaining content streams in as React hydrates in phases.
Cost: Higher server load (streaming ties up a connection), but lower user wait time.
SSR: Fast, Cacheable HTML
Use SSR when:
- Content is largely static or pre-computed.
- TTFB can be fast (cached templates, minimal dynamic computation).
- You want a light client bundle and fast interactivity on older devices.
Example: Blog posts, marketing pages, documentation. The HTML is generated on the server (or pre-rendered as static), sent to the browser, and hydrated with a minimal client bundle.
Trade-off: No real-time updates without a separate polling or WebSocket layer. But the page is interactive fast, and search engines see full HTML.
Cost: Works well if you can cache the output. Expensive if you need per-user or per-session customization without adding a client-side layer.
Client Render: Full Interactivity, Full Responsibility
Use client render when:
- The page is highly dynamic and user-specific (Figma canvas, Gmail inbox, Notion).
- Server latency and data dependencies are unavoidable anyway.
- The interactive experience is so rich that server rendering would be a false economy.
Example: A collaborative editor where state is per-user, updates are real-time, and the server's job is to sync, not render.
Cost: Large bundle, slow first load, but maximum interactivity potential. Hydration is complex.
Latency-Aware Defaults
If TTFB is low and content is cacheable (< 100ms): SSR + partial hydration.
- Server renders the full page fast.
- Client hydrates only interactive zones (buttons, forms, modals).
- Rest stays static.
- Works great for blogs, docs, marketing.
If data waterfalls are unavoidable (100–500ms latency): Stream and keep above-the-fold skeletons honest.
- Render the outer shell SSR (nav, header, skeleton containers).
- Stream the content in phases.
- Show realistic loading states—not spinners that vanish in 50ms.
- User perceives progress, not stall.
If the page is an app shell with per-user data: Prefetch on route change and hydrate fast paths only.
- Server sends minimal HTML (shell).
- Client-side router prefetches data during navigation.
- Hydrate interactive components first; lazy-load slower widgets.
- Example: Gmail-style inbox—shell loads fast, messages load in the background.
Avoid These Footguns
Streaming without guards on client-only context
You cannot stream a component that depends on useContext or client-only hooks without wrapping it in boundaries. Use React's use() hook to resolve server promises safely, or separate server and client components explicitly.
// ❌ Will break
export default async function Page() {
return <ClientComponentUsingContext />; // Error: Context doesn't exist during RSC
}
// ✅ OK
import { Suspense } from 'react';
import ClientBoundary from '@/components/ClientBoundary';
export default async function Page() {
const data = await fetchData();
return (
<Suspense fallback={<Skeleton />}>
<ClientBoundary data={data} />
</Suspense>
);
}
Waterfalls hiding inside components
If each component fetches its own data, you create a cascade: outer component fetches, renders inner component, inner component fetches, renders child, child fetches. The page takes 3x longer.
Move all data fetching to the top level (layout or page component). Use Promise.all to fetch in parallel.
// ❌ Waterfall
export default async function Page() {
const user = await fetchUser(); // 100ms
const posts = await fetchPosts(user.id); // 100ms, waits for user
const comments = await fetchComments(posts[0]?.id); // 100ms, waits for posts
return <div>...</div>; // 300ms total
}
// ✅ Parallel
export default async function Page() {
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(userId), // Pass userId from elsewhere or fetch it in parallel too
]); // ~100ms total
return <div>...</div>;
}
Hydration cost ignored
Hydration—the process of attaching event listeners and state to server-rendered HTML—can be expensive. A page that renders fast server-side might feel sluggish if the client-side JavaScript is huge or the tree is deep.
- Memoize lists: Use
React.memoon items in long lists so React doesn't re-render all 1000 items when you change one. - Lazy-load heavy charts: A recharts or D3 viz can add 50KB+ gzipped. Load it only when the user scrolls to it.
- Profile hydration time: Use DevTools Performance tab or Web Vitals library to measure how long hydration takes. Aim for < 1 second.
The Mental Model
Treat rendering mode selection like picking indexes in a database:
- Sequential scans (reading everything in order) are sometimes fine if the table is small. But if you have a large table, you need an index to jump to the relevant data.
- Rendering is the same: Small, static pages benefit from full HTML from the server. Large, dynamic pages need a smart structure: a fast shell (your index), prefetching (your where clause), and progressive hydration (only load the rows you're reading).
The goal is not to pick the "right" rendering strategy. It's to make the right trade-off for each piece of content.