Frontend System Design
18 min read

Rendering Strategies

Choosing between CSR, SSR, SSG, and hybrid rendering is not a binary decision—it’s about matching rendering strategy to content characteristics. Static content benefits from build-time rendering; dynamic, personalized content needs request-time rendering; interactive components need client-side JavaScript. Modern frameworks like Next.js 15, Astro 5, and Nuxt 3 enable mixing strategies within a single application, rendering each route—or even each component—with the optimal approach.

Client Time

Request Time

Build Time

Static Site Generation

Pre-rendered HTML

Server-Side Rendering

On-demand HTML

Incremental Static Regeneration

Cached + Background Refresh

Streaming SSR

Progressive HTML

Client-Side Rendering

JavaScript builds DOM

Hydration

Attach interactivity

Islands

Selective hydration

CDN Cache

Browser

Rendering strategies span build-time, request-time, and client-time—modern applications mix these based on content characteristics.

Rendering strategy selection reduces to three questions:

  1. When is content known? Build-time (SSG) vs request-time (SSR) vs client-time (CSR)
  2. How fresh must it be? Static (cache forever) vs stale-while-revalidate (ISR) vs always-fresh (SSR)
  3. How interactive is it? None (static HTML) vs partial (islands) vs full (SPA hydration)

The 2025 consensus:

  • Server-first rendering dominates—React Server Components, Astro, and streaming SSR prioritize HTML delivery over JavaScript execution
  • Islands architecture wins for content sites—ship zero JavaScript by default, hydrate only interactive components
  • Streaming SSR replaces blocking SSR—send HTML progressively as data resolves, improving perceived performance
  • Bundle size determines INP—Interaction to Next Paint (the new Core Web Vital) correlates directly with JavaScript shipped

The framework landscape has converged: Next.js 15 defaults to Server Components, Astro 5 ships zero JavaScript, and Nuxt 3 enables per-route rendering modes. The choice is no longer “which framework” but “which rendering mode for this content.”

CSR (Client-Side Rendering) ships a minimal HTML shell and builds the DOM entirely in the browser via JavaScript. The classic SPA (Single-Page Application) approach.

How it works:

  1. Server sends near-empty HTML with <script> tags
  2. Browser downloads, parses, and executes JavaScript bundle
  3. JavaScript fetches data and constructs DOM
  4. User sees content only after JavaScript completes
index.html
4 collapsed lines
<!DOCTYPE html>
<html>
<head>
<title>App</title>
</head>
<body>
<div id="root"></div>
4 collapsed lines
<!-- User sees blank page until this executes -->
<script src="/bundle.js"></script>
</body>
</html>

Core Web Vitals impact:

MetricImpactWhy
FCPPoorNo content until JS executes
LCPPoorLargest element delayed by JS
INPPoorLarge bundles block main thread
CLSVariableDepends on implementation

Design rationale: CSR simplifies deployment (static files only) and enables rich interactivity without server infrastructure. The trade-off is initial load performance—users wait for JavaScript before seeing content.

When CSR makes sense:

  • Internal dashboards where SEO is irrelevant
  • Highly interactive tools (Figma, Notion, Slack)
  • Behind authentication (crawlers can’t access anyway)
  • Apps where subsequent navigation speed matters more than initial load

When CSR is wrong:

  • Marketing sites, blogs, e-commerce (SEO-critical)
  • Content-heavy pages (slow LCP hurts engagement)
  • Mobile users on slow networks (large bundles timeout)

SSR (Server-Side Rendering) executes rendering logic on the server per-request, returning complete HTML. The browser displays content immediately, then hydrates for interactivity.

How it works:

  1. Browser requests page
  2. Server executes component code, fetches data, renders HTML
  3. Server sends complete HTML response
  4. Browser paints content immediately (FCP)
  5. Browser downloads JavaScript and hydrates
next-page.tsx
3 collapsed lines
// Next.js 15 App Router - Server Component by default
import { db } from '@/lib/db'
interface Product { id: string; name: string; price: number }
// This runs on the server every request
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({ where: { id: params.id } })
return (
<main>
<h1>{product.name}</h1>
<p>${product.price}</p>
</main>
2 collapsed lines
)
}

Core Web Vitals impact:

MetricImpactWhy
FCPGoodHTML arrives ready to paint
LCPGoodContent visible before JS
INPGoodSmaller bundles (RSC)
TTFBVariableDepends on server/data latency

Design rationale: SSR prioritizes content visibility. Users see meaningful content while JavaScript loads in the background. The trade-off is server load—every request requires compute.

TTFB considerations:

SSR’s TTFB depends on the slowest data fetch. If a page needs data from three APIs, TTFB = max(api1, api2, api3) + render time. Streaming SSR addresses this by sending HTML progressively.

When SSR makes sense:

  • SEO-critical dynamic content (search results, product pages)
  • Personalized content that can’t be pre-rendered
  • Pages with fast data sources
  • When server infrastructure is available

SSG (Static Site Generation) renders pages at build time, producing static HTML files served from CDN. No server compute at request time.

How it works:

  1. Build process fetches all data
  2. Framework renders every page to HTML
  3. HTML files deployed to CDN edge
  4. Users receive pre-built HTML instantly
  5. Optional: hydration for interactivity
astro-page.astro
2 collapsed lines
---
// Astro - runs at build time only
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
---
<html>
<body>
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li><a href={`/posts/${post.slug}`}>{post.title}</a></li>
))}
</ul>
</body>
</html>

Core Web Vitals impact:

MetricImpactWhy
FCPExcellentCDN serves pre-built HTML
LCPExcellentNo server processing
INPExcellentMinimal/no JavaScript
TTFBExcellentEdge-cached responses

Design rationale: SSG maximizes performance by eliminating request-time compute. Content is determined at build time and cached globally. The trade-off is staleness—updates require rebuilds.

Astro’s approach:

Astro exemplifies modern SSG. It renders pages at build time and ships zero JavaScript by default. Interactive components (“islands”) opt-in to client-side JavaScript.

“Astro is designed to be fast. Astro websites load 40% faster and use 90% less JavaScript than sites built with other popular web frameworks.” — Astro Documentation

When SSG makes sense:

  • Documentation sites
  • Marketing pages
  • Blogs with infrequent updates
  • E-commerce product catalogs (with ISR for freshness)

When SSG is wrong:

  • Highly personalized content
  • Real-time data (stock prices, live scores)
  • Sites with thousands of pages and frequent updates

ISR combines static performance with dynamic freshness. Pages are pre-rendered but regenerate in the background after a configurable interval.

How it works (Next.js 15):

  1. First request: serve pre-built static page
  2. After revalidate period expires: serve stale, trigger background regeneration
  3. Next request: serve freshly regenerated page
  4. Repeat
next-isr.tsx
3 collapsed lines
// Next.js 15 App Router - Time-based revalidation
import { db } from '@/lib/db'
// Regenerate this page every 60 seconds
export const revalidate = 60
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findUnique({ where: { id: params.id } })
return (
<main>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>Last updated: {new Date().toISOString()}</p>
</main>
)
}

On-demand revalidation:

For immediate updates (CMS publish, inventory change), trigger revalidation explicitly:

revalidate-action.ts
2 collapsed lines
"use server"
import { revalidatePath, revalidateTag } from "next/cache"
// Revalidate specific path
export async function publishPost(slug: string) {
await db.posts.publish(slug)
revalidatePath(`/posts/${slug}`)
}
// Revalidate by cache tag
export async function updateInventory(productId: string) {
await db.inventory.update(productId)
revalidateTag("products")
}

Design rationale: ISR solves the staleness problem of SSG without SSR’s per-request compute. Most requests hit cache; regeneration happens asynchronously. The trade-off is complexity—cache invalidation requires explicit management.

When ISR makes sense:

  • E-commerce with frequent price/inventory changes
  • News sites with editorial workflow
  • Content sites with thousands of pages
  • Any SSG use case that needs freshness

Traditional SSR blocks until all data is ready, then sends the complete HTML. Streaming SSR sends HTML progressively as data resolves.

How renderToPipeableStream works (React 18+):

server.ts
4 collapsed lines
import { renderToPipeableStream } from 'react-dom/server'
import { createServer } from 'http'
createServer((req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
// Shell (layout, navigation) ready - start streaming
res.setHeader('Content-Type', 'text/html')
pipe(res)
},
onShellError(error) {
// Error before any HTML sent - can redirect/show error page
res.statusCode = 500
res.end('Error')
},
onAllReady() {
// All Suspense boundaries resolved - useful for crawlers
},
2 collapsed lines
})
}).listen(3000)

Progressive rendering with Suspense:

page.tsx
5 collapsed lines
import { Suspense } from "react"
async function SlowComponent() {
const data = await fetch("https://slow-api.example.com/data")
return <div>{data.json().content}</div>
}
export default function Page() {
return (
<html>
<body>
{/* Shell streams immediately */}
<header>Site Header</header>
<nav>Navigation</nav>
{/* Content streams as ready */}
<Suspense fallback={<p>Loading...</p>}>
<SlowComponent />
</Suspense>
<footer>Site Footer</footer>
</body>
</html>
)
1 collapsed line
}

What the user sees:

  1. Instant: Header, navigation, footer, “Loading…” placeholder
  2. When data ready: Placeholder replaced with actual content (no page refresh)

Design rationale: Streaming decouples TTFB from slowest data fetch. Users see the page structure immediately; content fills in progressively. The browser can start parsing and rendering before the response completes.

Shopify Hydrogen results:

Shopify’s Hydrogen framework (React Server Components + Streaming) achieves:

  • Sub-50ms TTFB on Oxygen CDN (V8 Isolates)
  • Progressive hydration reduces TTI
  • No client round-trips—data embedded in streaming HTML

Suspense boundaries can resolve in any order. The browser receives placeholder slots and fills them as data arrives:

<!-- Initial stream -->
<html>
<body>
<header>Header</header>
<!--$?--><template id="B:0"></template>
<p>Loading...</p>
<!--/$-->
<footer>Footer</footer>
</body>
</html>
<!-- Later stream (injected at end of document) -->
<script>
// React replaces placeholder with resolved content
$RC("B:0", "<div>Actual content from slow API</div>")
</script>

Design rationale: Out-of-order streaming maximizes parallelism. Fast data sources render immediately; slow sources don’t block the page. The technique uses inline scripts to swap placeholders after they stream.

Hydration attaches React’s event handlers and state management to server-rendered HTML. The browser receives static HTML, then React “hydrates” it to make it interactive.

The hydration sequence:

  1. Server renders HTML → complete DOM structure
  2. Browser receives HTML → displays immediately (FCP)
  3. Browser downloads JavaScript → React bundle loads
  4. hydrateRoot called → React attaches to existing DOM
  5. Event handlers attached → page becomes interactive
client.tsx
2 collapsed lines
import { hydrateRoot } from 'react-dom/client'
import App from './App'
// React expects DOM to match what it would render
const root = document.getElementById('root')
hydrateRoot(root, <App />)

Critical constraint: Server and client must produce identical HTML. React compares the server-rendered DOM against what it would render; mismatches cause errors and visual glitches.

Mismatches occur when server and client render different content. Common causes:

1. Browser-only APIs:

// ❌ Mismatch: window doesn't exist on server
function Component() {
return <div>{window.innerWidth}px</div>
}
// ✅ Fixed: defer to client
function Component() {
const [width, setWidth] = useState<number | null>(null)
useEffect(() => {
setWidth(window.innerWidth)
}, [])
return <div>{width ?? "Loading..."}px</div>
}

2. Date/time rendering:

// ❌ Mismatch: server and client have different times
function Component() {
return <span>{new Date().toLocaleString()}</span>
}
// ✅ Fixed: render on client only
function Component() {
const [time, setTime] = useState<string | null>(null)
useEffect(() => {
setTime(new Date().toLocaleString())
}, [])
return <span suppressHydrationWarning>{time ?? ""}</span>
}

3. Conditional rendering:

// ❌ Mismatch: typeof window differs
function Component() {
if (typeof window !== "undefined") {
return <ClientOnlyFeature />
}
return null
}
// ✅ Fixed: state-based detection
function Component() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
return <ClientOnlyFeature />
}

Debugging mismatches:

  1. Check browser console for hydration warnings (React 18+ provides detailed messages)
  2. Compare server HTML (view-source:) with client render
  3. Search for typeof window, navigator, document in render paths
  4. Check timezone-dependent formatting

React 18 enables selective hydration—components wrapped in Suspense can hydrate independently:

selective-hydration.tsx
3 collapsed lines
import { Suspense } from "react"
function Page() {
return (
<div>
<header>Always hydrates first</header>
<Suspense fallback={<p>Loading sidebar...</p>}>
<Sidebar /> {/* Hydrates when ready */}
</Suspense>
<Suspense fallback={<p>Loading content...</p>}>
<MainContent /> {/* Hydrates independently */}
</Suspense>
</div>
)
}

How it works:

  • React prioritizes hydrating components the user interacts with
  • Clicking a not-yet-hydrated component triggers priority hydration
  • Other Suspense boundaries continue hydrating in background

Design rationale: Selective hydration improves perceived interactivity. The header becomes interactive immediately; heavy components hydrate progressively without blocking the page.

Islands architecture renders most of the page as static HTML and hydrates only interactive “islands.” Each island is independent—different frameworks can coexist.

Core concept:

┌─────────────────────────────────────────────┐
│ Static HTML (no JavaScript) │
│ ┌─────────┐ ┌──────────────┐ │
│ │ Island │ │ Island │ │
│ │ (React) │ │ (Vue) │ │
│ │ 3KB JS │ │ 5KB JS │ │
│ └─────────┘ └──────────────┘ │
│ │
│ More static content... │
│ │
│ ┌────────────────────────────┐ │
│ │ Island (Svelte) │ │
│ │ 8KB JS │ │
│ └────────────────────────────┘ │
└─────────────────────────────────────────────┘
Total JS: 16KB (vs 200KB+ for full hydration)

Astro implements islands with the client:* directives:

page.astro
5 collapsed lines
---
import Header from "./Header.astro" // Static, no JS
import Search from "./Search.tsx" // Interactive island
import Footer from "./Footer.astro" // Static, no JS
---
<html>
<body>
<Header />
<!-- Hydrates on page load -->
<Search client:load />
<!-- Hydrates when visible in viewport -->
<Newsletter client:visible />
<!-- Hydrates on first interaction -->
<Comments client:idle />
<!-- Never hydrates (server-only) -->
<Analytics />
<Footer />
</body>
</html>

Hydration directives:

DirectiveWhen HydratesUse Case
client:loadImmediatelyAbove-fold interactivity
client:idleBrowser idleBelow-fold, non-critical
client:visibleIn viewportLazy components
client:mediaMedia query matchesResponsive features
client:onlyClient-only (no SSR)Browser-only components

Design rationale: Islands invert the default. Instead of “hydrate everything, optimize later,” islands are “hydrate nothing, add JavaScript where needed.” This produces dramatically smaller bundles.

Fresh implements islands without a build step:

routes/index.tsx
2 collapsed lines
// Route - server-rendered
import Counter from "../islands/Counter.tsx"
export default function Home() {
return (
<div>
<h1>Welcome</h1>
<p>This is static HTML</p>
{/* Island - hydrates on client */}
<Counter start={3} />
</div>
)
}
// islands/Counter.tsx - client-side JavaScript
import { useState } from "preact/hooks"
5 collapsed lines
export default function Counter({ start }: { start: number }) {
const [count, setCount] = useState(start)
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}

Design decisions:

  • No build step—TypeScript compiled on-demand via Deno
  • Islands in islands/ directory automatically hydrated
  • Uses Preact (3KB) instead of React (40KB)
  • Zero JavaScript by default

Partial hydration: Framework hydrates a subset of components, but all components come from the same framework. React’s selective hydration is partial hydration.

Islands: Each island is independent. Different frameworks can coexist. No shared runtime overhead between islands.

The distinction matters for bundle size. Partial hydration still loads React for the entire page; islands load only what each component needs.

React Server Components (RSC) and Server-Side Rendering (SSR) are complementary, not alternatives.

AspectSSRRSC
Runs onServer (per-request)Server (can be cached)
OutputHTML stringReact element tree (serialized)
Client bundleFull app codeOnly Client Components
Hooks allowedAllNone (useState, useEffect forbidden)
Data fetchinggetServerSideProps / fetchDirect async functions
InteractivityAfter hydrationClient Components only

How RSC works:

  1. Server renders Server Components into serialized React tree
  2. Tree includes references to Client Components (not their code)
  3. Client receives serialized tree + Client Component bundles
  4. Client renders tree, loading Client Components as needed
rsc-example.tsx
4 collapsed lines
// Server Component - NOT shipped to client
import { db } from "@/lib/db"
import { formatMarkdown } from "@/lib/markdown" // 50KB library stays on server
import LikeButton from "./LikeButton" // Client Component
async function BlogPost({ id }: { id: string }) {
const post = await db.posts.findUnique({ where: { id } })
const html = formatMarkdown(post.content) // Heavy library, zero client cost
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
{/* Client Component - this code ships to client */}
<LikeButton postId={id} />
</article>
)
}
// Client Component - marked explicitly
7 collapsed lines
;("use client")
import { useState } from "react"
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
return <button onClick={() => setLiked(!liked)}>{liked ? "❤️" : "🤍"}</button>
}

Bundle size impact:

Traditional React: marked (50KB), sanitize-html (25KB), and every component ship to client.

RSC: Only LikeButton (and its dependencies) ship to client. Markdown processing happens on server.

RSC enables Server Actions—functions that execute on the server, called from the client:

server-actions.tsx
2 collapsed lines
"use server"
import { db } from "@/lib/db"
export async function addComment(postId: string, content: string) {
await db.comments.create({
data: { postId, content, createdAt: new Date() },
})
// Revalidate the post page
revalidatePath(`/posts/${postId}`)
}
// Client Component using Server Action
;("use client")
import { addComment } from "./actions"
function CommentForm({ postId }: { postId: string }) {
return (
<form action={addComment.bind(null, postId)}>
<input name="content" />
4 collapsed lines
<button type="submit">Post</button>
</form>
)
}

Design rationale: Server Actions eliminate the need for API routes in many cases. Form submissions call server functions directly, with automatic serialization and error handling.

Server Components cannot:

  • Use useState, useReducer, useEffect, or other client hooks
  • Access browser APIs (window, document)
  • Use event handlers (onClick, onChange)
  • Be rendered conditionally based on client state

These constraints enable the zero-bundle benefit. If a component needs interactivity, mark it 'use client'.

Qwik eliminates hydration entirely through “resumability.” Instead of replaying component logic on the client, Qwik serializes execution state and resumes exactly where the server left off.

Hydration vs Resumability:

AspectHydrationResumability
Client startupRe-execute all componentsResume from serialized state
JavaScript loadedEntire app (or chunks)Only for triggered interactions
Time to InteractiveAfter hydration completesNear-instant
Memory at loadFull React treeMinimal

How Qwik works:

  1. Server renders HTML with serialized state in attributes
  2. Client loads minimal Qwik runtime (~1KB)
  3. On interaction, Qwik lazy-loads only the relevant event handler
  4. Component state resumes from serialized data
qwik-example.tsx
2 collapsed lines
import { component$, useSignal } from "@builder.io/qwik"
export const Counter = component$(() => {
const count = useSignal(0)
return <button onClick$={() => count.value++}>Count: {count.value}</button>
})
// Rendered HTML includes serialized state:
// <button on:click="q-abc123">Count: 0</button>
// On click, Qwik fetches only the click handler code

Design rationale: Hydration assumes the client must understand the component tree before interactivity. Resumability questions this assumption—why re-execute code that already ran on the server? By serializing execution state, Qwik achieves near-instant interactivity.

Trade-offs:

  • Sub-second TTI on mobile
  • Minimal initial JavaScript
  • Different mental model from React/Vue
  • Smaller ecosystem
  • Serialization overhead for complex state

Default behavior: Server Components (RSC) in App Router

Rendering modes:

ModeConfigUse Case
Staticexport const dynamic = 'force-static'Build-time rendering
Dynamicexport const dynamic = 'force-dynamic'Per-request rendering
ISRexport const revalidate = 60Cached with background refresh
StreamingSuspense boundariesProgressive rendering

Partial Prerendering (PPR):

Next.js 15 introduces PPR—combining static shells with dynamic content:

ppr-page.tsx
3 collapsed lines
import { Suspense } from "react"
// next.config.ts: cacheComponents: true
export default function ProductPage({ params }) {
return (
<main>
{/* Static shell - prerendered at build */}
<Header />
<ProductInfo id={params.id} />
{/* Dynamic content - streams at request time */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice id={params.id} />
</Suspense>
<Footer />
</main>
)
}

Default behavior: Zero JavaScript, static output

Rendering modes:

astro.config.mjs
export default defineConfig({
output: "static", // Full SSG (default)
// output: 'server', // Full SSR
// output: 'hybrid', // Per-page control
})

Hybrid rendering:

---
// Force server rendering for this page
export const prerender = false
---
<html>
<body>
<UserDashboard />
</body>
</html>

Strengths:

  • Fastest static sites (40% faster than React equivalents)
  • Framework-agnostic islands (React, Vue, Svelte, Solid)
  • Content-first architecture

Default behavior: Universal rendering (SSR + hydration)

Rendering modes per route:

nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
"/": { prerender: true }, // Static
"/blog/**": { isr: 60 }, // ISR
"/admin/**": { ssr: false }, // Client-only
"/api/**": { cors: true }, // API routes
},
})

Strengths:

  • Vue 3 Composition API
  • Nitro server engine (edge-deployable)
  • Mature hybrid rendering
FactorNext.js 15Astro 5Nuxt 3Qwik
Default JSRSC (minimal)ZeroFull hydrationZero (resumable)
Framework lock-inReactNoneVueQwik
SSG (primary)
SSR
Streaming
IslandsVia RSCNativeNuxt IslandsNative
Learning curveModerateLowModerateHigh
EcosystemLargestGrowingLarge (Vue)Small

Challenge: E-commerce storefronts with dynamic pricing, inventory, personalization

Approach:

  • React Server Components for product pages
  • Streaming SSR for progressive rendering
  • Edge deployment on Oxygen (V8 Isolates)
  • Cache at CDN, invalidate on inventory changes

Results:

  • Sub-50ms TTFB from edge
  • Zero client-side data fetching for product info
  • Progressive hydration reduces TTI

Key insight: RSC eliminates the “client-side fetch waterfall” problem. Product data embeds directly in the streamed HTML.

Challenge: Reference e-commerce implementation showcasing Next.js

Approach:

  • Next.js App Router with RSC
  • ISR for product catalog (revalidate on webhook)
  • Streaming for search results
  • Edge middleware for geolocation/personalization

Architecture:

Request → Edge Middleware (geo, A/B) → ISR Cache → Origin (if miss)
Streaming Response

Challenge: News site with millions of pages, real-time updates

Approach:

  • Server-rendered articles
  • Incremental regeneration for breaking news
  • Islands for interactive elements (comments, live blogs)
  • Aggressive caching at CDN

Key insight: News content is mostly static with pockets of interactivity. Islands architecture matches this perfectly—static article body, interactive comment section.

As of 2025, Core Web Vitals are:

MetricTargetRendering Impact
LCP< 2.5sSSG/ISR > SSR > CSR
INP< 200msLess JS = better INP
CLS< 0.1Streaming can help/hurt

INP optimization:

INP (Interaction to Next Paint) replaced TTI as the interactivity metric. It measures responsiveness to user interactions, not initial load. Large JavaScript bundles degrade INP because they compete for main thread time.

Strategies:

  1. Ship less JavaScript — RSC, islands, code splitting
  2. Defer non-critical JSclient:idle, client:visible
  3. Avoid layout thrashing — batch DOM reads/writes
  4. Use Web Workers — offload computation
web-vitals.ts
3 collapsed lines
import { onLCP, onINP, onCLS } from "web-vitals"
function sendToAnalytics(metric) {
// Send to your analytics endpoint
fetch("/api/vitals", {
method: "POST",
body: JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
}),
})
}
onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)
Use CasePrimary StrategyFallback
Marketing siteSSG + IslandsISR for dynamic sections
E-commerce catalogISROn-demand revalidation
DashboardCSR
BlogSSGISR for comments
News siteSSR + StreamingISR for archive
SaaS appRSC + StreamingCSR for complex widgets

Rendering strategy selection is no longer framework-constrained—modern tools support multiple modes. The decision reduces to content characteristics:

  1. Static content → SSG (Astro, Next.js static export)
  2. Dynamic but cacheable → ISR (Next.js, Nuxt)
  3. Personalized/real-time → SSR + Streaming
  4. Highly interactive → CSR or RSC with Client Components
  5. Mixed → Islands (Astro) or RSC (Next.js)

The trend is server-first: render on the server, ship minimal JavaScript, hydrate selectively. React Server Components and islands architecture represent convergent evolution toward this model.

For new projects: start with the minimal JavaScript approach (Astro for content, Next.js RSC for apps) and add interactivity where needed. The performance benefits of shipping less JavaScript compound—better Core Web Vitals, lower hosting costs, broader device support.

  • HTML/CSS fundamentals and DOM understanding
  • JavaScript execution model (event loop, main thread)
  • React component model (or equivalent framework knowledge)
  • HTTP caching basics (Cache-Control, CDN concepts)
TermDefinition
CSRClient-Side Rendering—building DOM in browser via JavaScript
SSRServer-Side Rendering—generating HTML on server per-request
SSGStatic Site Generation—pre-rendering HTML at build time
ISRIncremental Static Regeneration—SSG with background refresh
HydrationAttaching JavaScript interactivity to server-rendered HTML
IslandsIndependent interactive components in otherwise static pages
RSCReact Server Components—components that run only on server
TTFBTime to First Byte—server response latency
FCPFirst Contentful Paint—when first content appears
LCPLargest Contentful Paint—when main content is visible
INPInteraction to Next Paint—responsiveness to user input
ResumabilityContinuing server execution on client without replay
  • Match strategy to content: static → SSG, dynamic → SSR, personalized → CSR
  • Server-first wins: RSC and islands ship less JavaScript, improving INP
  • Streaming improves perceived performance: send HTML progressively, don’t block on slowest data
  • Hydration has costs: consider islands or resumability for content-heavy sites
  • ISR bridges static and dynamic: cache-first with background refresh
  • INP is the new TTI: JavaScript bundle size directly impacts interactivity metrics

Read more

  • Previous

    Bundle Splitting Strategies

    System Design / Frontend System Design 19 min read

    Modern JavaScript applications ship megabytes of code by default. Without bundle splitting, users download, parse, and execute the entire application before seeing anything interactive—regardless of which features they’ll actually use. Bundle splitting transforms monolithic builds into targeted delivery: load the code for the current route immediately, defer everything else until needed. The payoff is substantial—30-60% reduction in initial bundle size translates directly to faster Time to Interactive (TTI) and improved Core Web Vitals.

  • Next

    Image Loading Optimization

    System Design / Frontend System Design 13 min read

    Client-side strategies for optimizing image delivery: lazy loading, responsive images, modern formats, and Cumulative Layout Shift (CLS) prevention. Covers browser mechanics, priority hints, and real-world implementation patterns.