SSR vs CSR vs Universal Rendering
Every frontend architect faces the same question: where and when does your HTML get generated? The answer determines your Time to First Byte, Time to Interactive, SEO capabilities, hosting costs, and caching strategy.
The Spectrum of Rendering
Server-side Client-side
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
SSG ISR SSR Streaming Universal CSR/SPA
(Static) (Incremental) (Dynamic) SSR (Hydration)
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Fastest FCP Slowest FCP
Stale data Fresh data
Cheapest Most flexibleClient-Side Rendering (CSR / SPA)
The browser receives a minimal HTML shell and JavaScript builds the entire UI:
<!-- What the server sends -->
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/app.js"></script> <!-- 200KB+ -->
</body>
</html>The Timeline
Server sends HTML shell ββ Browser downloads JS ββ JS executes ββ API calls ββ Render
β β β β β
βββββ Empty page ββββββββββββββ Still empty βββββββββ Spinner βββββ Content βWhen CSR Works
- Behind authentication (no SEO needed)
- Highly interactive dashboards
- Real-time applications (trading, gaming)
- When you want simple static hosting (S3, Netlify)
When CSR Fails
- SEO β Search crawlers may not execute JavaScript (Google does, others may not)
- First Contentful Paint β User sees nothing until JS downloads, parses, and executes
- Low-end devices β Heavy JS payload on a $100 phone on 3G = 10+ second load
- Social sharing β Open Graph tags need to be in the initial HTML
CSR Performance Profile
FCP: Slow (waiting for JS download + execution)
LCP: Very slow (waiting for JS + API call + render)
TTI: Moderate (once JS is loaded, it's already interactive)
TTFB: Fast (just serving a static file)Server-Side Rendering (SSR)
The server generates full HTML for every request:
Browser requests /products
β Server fetches data from DB/API
β Server renders React to HTML string
β Sends complete HTML to browser
β Browser displays content immediately
β JS downloads and "hydrates" (attaches event handlers)The Timeline
Server renders HTML βββ Browser receives full HTML βββ JS downloads βββ Hydration
β β β β
βββ Server processing βββββββ Content visible! βββββββββ Not yet βββββ InteractiveTraditional SSR Code
// Next.js Pages Router (getServerSideProps)
export async function getServerSideProps(context) {
const products = await db.products.findAll();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return (
<div>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}SSR Trade-offs
Advantages:
- Full HTML on first response β great FCP and LCP
- SEO-friendly β crawlers get complete content
- Social sharing works (OG tags in initial HTML)
- Works without JavaScript (progressive enhancement)
Disadvantages:
- TTFB is slower β Server must fetch data and render before responding
- Server cost β Every request hits your server (compute cost scales with traffic)
- Time to Interactive gap β User sees content but can't interact until hydration completes
- No caching (by default) β Every request is unique
SSR Performance Profile
FCP: Good (full HTML arrives quickly)
LCP: Good (content is in the HTML)
TTI: Moderate (hydration delay β the "uncanny valley")
TTFB: Slower (server must render)Static Site Generation (SSG)
HTML is generated at build time, not request time:
Build time: Fetch data β Render HTML β Save as static files
Request time: Serve pre-built HTML from CDN β Instant// Next.js App Router (static by default)
async function BlogPost({ params }) {
const post = await getPost(params.slug); // Called at build time
return <Article post={post} />;
}
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}SSG Performance Profile
FCP: Excellent (pre-built HTML from CDN edge)
LCP: Excellent (all content is static)
TTI: Good (less JS to hydrate)
TTFB: Excellent (CDN cache hit)
Cost: Minimal (static files, CDN-served)When SSG Breaks
- Content changes frequently (requires rebuild for every change)
- Thousands of pages (build time grows linearly)
- Personalized content (same page for everyone)
Incremental Static Regeneration (ISR)
The best of SSG and SSR β pages are statically generated but can be revalidated:
// Next.js β revalidate every 60 seconds
export const revalidate = 60;
async function ProductsPage() {
const products = await fetchProducts();
return <ProductGrid products={products} />;
}How ISR Works
First request: Build page β Cache β Serve from cache
Next 60 seconds: Serve stale cache (instant)
After 60s: Serve stale cache AND trigger background rebuild
Next request: Serve newly built page from cacheThis is stale-while-revalidate at the page level. Users always get instant responses; data staleness is bounded by the revalidation interval.
Streaming SSR
Instead of waiting for all data before sending HTML, the server streams content progressively:
// React 18 + Next.js App Router
async function Dashboard() {
return (
<div>
<Header /> {/* Sent immediately */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel /> {/* Streams when data ready */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<ChartSection /> {/* Streams independently */}
</Suspense>
</div>
);
}Streaming Timeline
Traditional SSR:
[Wait for ALL data...............] β [Send complete HTML] β [Hydrate]
Streaming SSR:
[Send shell + skeleton] β [Stream metrics] β [Stream chart] β [Selective hydrate]
Instant FCP Progressive ProgressiveSelective Hydration
React hydrates streamed sections independently. If the user clicks on a not-yet-hydrated section, React prioritizes hydrating that section first.
Universal (Isomorphic) Rendering
Universal rendering means the same JavaScript code runs on both server and client:
- Server renders initial HTML (SSR) for fast FCP
- Client receives HTML + JS bundle
- Client hydrates β attaches event handlers to existing DOM
- Client takes over β subsequent navigation is client-side (SPA behavior)
First page load: SSR β Hydration β SPA mode
Subsequent nav: Client-side routing (no server round-trip)The Hydration Problem
Hydration is the most misunderstood part:
Server HTML: <button>Count: 0</button>
β
Client hydrates: Attach onClick to existing button
Verify server HTML matches client render
β
Mismatch? Warning + client re-renders (expensive!)Common hydration mismatch causes:
- Using
Date.now()orMath.random()during render - Browser-only APIs (
window.innerWidth,localStorage) - Extension-injected DOM nodes
- Different timezone/locale on server vs client
// β Hydration mismatch
function Greeting() {
return <p>Current time: {new Date().toLocaleTimeString()}</p>;
}
// β
Two-pass rendering
function Greeting() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
return <p>Current time: {time ?? 'Loading...'}</p>;
}The Decision Matrix
| Factor | SSG | ISR | SSR | Streaming | CSR |
|---|---|---|---|---|---|
| Time to First Byte | Excellent | Excellent | Slow | Moderate | Excellent |
| First Contentful Paint | Excellent | Excellent | Good | Good | Poor |
| Time to Interactive | Good | Good | Moderate | Good | Moderate |
| Data freshness | Build time | Configurable | Request time | Request time | Request time |
| SEO | Excellent | Excellent | Excellent | Excellent | Poor |
| Personalization | None | Limited | Full | Full | Full |
| Server cost | None (CDN) | Low | High | High | None |
| Complexity | Low | Low | Moderate | High | Low |
Choosing by Page Type
Marketing / Landing pages β SSG (fast, cacheable, SEO-friendly)
Blog / Documentation β SSG or ISR (content changes periodically)
Product catalog β ISR (1000s of pages, hourly updates)
Search results β SSR or Streaming (query-dependent)
User dashboard β Streaming SSR or CSR (personalized, real-time)
Settings / Profile β CSR (behind auth, no SEO)
E-commerce checkout β SSR (personalized pricing, SEO for product pages)
Real-time collaboration β CSR with WebSockets (client-side state is king)Modern Approach: Mix Per Route
app/
βββ (marketing)/
β βββ page.tsx β SSG (static landing page)
βββ blog/
β βββ [slug]/page.tsx β ISR (revalidate: 3600)
βββ products/
β βββ [id]/page.tsx β SSR with streaming (personalized pricing)
βββ dashboard/
β βββ page.tsx β SSR with streaming (auth-gated, real-time)
βββ settings/
βββ page.tsx β CSR behind auth wrapperNo single strategy fits all. Architects choose per route based on the page's data freshness needs, SEO requirements, and interactivity level.
Islands Architecture
Instead of hydrating the entire page, Islands Architecture (popularized by Astro) sends static HTML by default and only hydrates interactive "islands":
βββββββββββββββββββββββββββββββββββββββββββββββ
β Static HTML (zero JS) β
β ββββββββββββ ββββββββββββββββββββ β
β β Interactiveβ β Interactive β β
β β Island β Static β Island β β
β β (React) β HTML β (Svelte) β β
β ββββββββββββ ββββββββββββββββββββ β
β β
β Static HTML (zero JS) β
βββββββββββββββββββββββββββββββββββββββββββββββKey benefits:
- Zero JS by default β Static content ships no JavaScript at all
- Partial hydration β Only interactive components load their framework
- Framework-agnostic β Different islands can use different frameworks
- Progressive loading β Islands can hydrate on idle, on visible, or on interaction
---
import Header from './Header.astro'; // Static β zero JS
import SearchBar from './SearchBar.tsx'; // Interactive island
import Footer from './Footer.astro'; // Static β zero JS
---
<Header />
<SearchBar client:visible /> <!-- Hydrates when scrolled into view -->
<Footer />Best for: Content-heavy sites (blogs, docs, marketing) where interactivity is sparse.
Animating View Transitions
The View Transitions API enables smooth animated transitions between page states or navigations without a framework:
function updateContent(newContent) {
if (!document.startViewTransition) {
renderNewContent(newContent);
return;
}
document.startViewTransition(() => {
renderNewContent(newContent);
});
}::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-in;
}
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }For named transitions (morph a thumbnail into a full image):
.product-card img {
view-transition-name: product-hero;
}
.product-detail img {
view-transition-name: product-hero;
}MPA support: Cross-document view transitions work with @view-transition { navigation: auto; } in CSS, enabling native page transitions in multi-page apps without JavaScript frameworks.
Interview Signal
Senior candidates demonstrate:
- Spectrum understanding β Not just "SSR vs CSR" but the full range (SSG, ISR, Streaming, Islands, Universal)
- Trade-off fluency β TTFB vs FCP vs TTI, server cost vs user experience
- Hydration awareness β What it is, why mismatches happen, the "uncanny valley," partial/selective/progressive hydration
- Per-route thinking β Different strategies for different pages in the same app
- Modern patterns β Streaming SSR, Islands Architecture, View Transitions API, RSC as the next evolution