Rendering Models: CSR, SSR, SSG, ISR, Streaming & Islands
Two apps can use the exact same framework, the same components, the same data, and feel completely different to a user. One shows text in 300 milliseconds. The other shows a blank white screen for two seconds, then a spinner, then finally the content. The code that produced the pixels is nearly identical. What differs is where and when the HTML was built.
That single decision — build time, request time, or in the browser — is the rendering model. It decides your Time to First Byte, how fast the page becomes clickable, whether a crawler sees your content, and how much your server bill is. Frameworks give these choices friendly names — SSG, SSR, ISR, streaming, islands, RSC — but underneath they are all answers to one question: who runs your render function, and at what moment?
This article walks each model with its real tradeoffs, then shows how to pick one by the kind of page you are shipping. It builds on how frameworks turn state into DOM; here we care about where that DOM first comes from.
The one axis that explains everything
Put every rendering model on a single line: how far ahead of the user’s request is the HTML produced?
Hold that picture. Every model below is a point (or a clever mix of points) on that axis. Now let’s walk them.
CSR: render in the browser
Client-Side Rendering is the classic single-page app. The server sends a nearly empty HTML shell — a <div id="root"></div> and a script tag. The browser downloads your JavaScript bundle, runs it, and that code builds the DOM. Nothing meaningful is on screen until the bundle has loaded, parsed, and executed.
<!-- what the server actually sends for a pure CSR app -->
<!doctype html>
<html>
<head><link rel="stylesheet" href="/app.css"></head>
<body>
<div id="root"></div>
<script type="module" src="/app.[hash].js"></script>
</body>
</html>
The upside is real: once the app is running, navigation is instant. Clicking a link doesn’t fetch a new document — it swaps components in memory and maybe fetches a little JSON. For a highly interactive app where a user spends twenty minutes clicking around, that in-app snappiness is exactly what you want.
The downside is the first visit. The user stares at nothing until the bundle arrives, and the bundle grows with your app. A crawler or a link-preview bot that doesn’t run JavaScript sees an empty div. Search engines execute JS today, but they do it on a delay and a budget, so content-heavy sites still pay an SEO tax for pure CSR.
SSR: render per request on the server
Server-Side Rendering runs your components on the server, for each incoming request, and sends back fully-formed HTML. The browser paints real content immediately — before a single line of your app’s JavaScript has run. Then the same JS loads and hydrates the page (more on that word in a moment) to make it interactive.
// the shape of an SSR handler, framework-agnostic
export async function handleRequest(req) {
const user = await getUserFromSession(req); // per-request data
const html = renderToString(<App user={user} />); // run components on the server
return new Response(pageShell(html), {
headers: { "content-type": "text/html" },
});
}
You get a good Time to First Byte relative to CSR’s blank screen, real content for crawlers, and the ability to personalize per request — the logged-in user’s name is baked into the HTML because the server knew who they were. The cost is that a server has to run your render on every hit. That is CPU you pay for, latency if a data call is slow, and a scaling story more involved than “put files on a CDN.” SSR also can’t be cached as bluntly as static HTML, because the response can differ for every user.
SSG: render once at build time
Static Site Generation moves the render all the way to the left of the axis. You run the render once, when you deploy, and write plain HTML files to disk. At request time the server (or, better, a CDN edge node) just hands over a file. No render, no data fetch, no compute.
# a build step pre-renders every route to HTML
$ astro build
▶ / → dist/index.html
▶ /blog/ → dist/blog/index.html
▶ /blog/hello → dist/blog/hello/index.html
12 pages built in 1.4s
This is the fastest and cheapest way to serve a page, full stop. A static file on a CDN has a Time to First Byte measured in single-digit milliseconds, it survives traffic spikes trivially, and there’s no server to break at 3am. The catch is in the name: it’s static. The HTML reflects the data as it was at build time. Change a price, publish a post, edit a typo — the live site doesn’t move until you rebuild and redeploy. For a thousand-page site, a full rebuild can also get slow.
ISR: static, but allowed to refresh
Incremental Static Regeneration is the pragmatic middle. You still serve static HTML from cache — so visitors get SSG-class speed — but the framework is allowed to regenerate a page in the background, either on a schedule or when you explicitly tell it to. It follows a stale-while-revalidate rhythm: serve the cached page now, quietly rebuild it, swap in the fresh version for the next visitor.
There are two triggers, and mature setups use both:
// 1. time-based: this page may be up to 60s stale, then rebuild
export const revalidate = 60;
// 2. on-demand: a CMS webhook fires this after an editor hits "publish"
// (Next.js App Router style)
export async function POST(req) {
const { slug } = await req.json();
revalidatePath(`/blog/${slug}`); // invalidate; next request regenerates
return Response.json({ revalidated: true });
}
Time-based revalidation is your safety net — even if a webhook silently fails, the page can’t stay stale forever. On-demand revalidation is the precision tool — the instant an editor publishes, exactly the affected paths regenerate and nothing else. You get near-static performance with content that’s minutes-fresh or better. The tradeoff is conceptual overhead: you now reason about cache lifetimes and invalidation, which is its own small discipline.
Hydration, and why it costs you
Every server-rendered model — SSR, SSG, ISR — shares one awkward truth. The server sends HTML that looks done, but it’s inert. The buttons don’t click, the inputs don’t respond, because the event listeners live in JavaScript that hasn’t run yet. The browser then downloads that JS, re-builds the component tree in memory, walks the existing DOM, and attaches the listeners. That second pass is hydration.
Hydration is why a server-rendered page can feel like a trap: content appears fast, you click a button, nothing happens, then a beat later it works. The gap between “I can see it” and “I can use it” is the hydration window. And it isn’t free — the browser essentially does the render again on top of the HTML it was handed, and the bigger your app, the longer that takes.
The industry’s answer isn’t “hydrate faster” — it’s “hydrate less.” If most of the page is static text that will never respond to a click, why ship and run JavaScript for it at all? Two ideas fall out of that question: partial hydration (hydrate only the interactive pieces) and streaming (don’t make the whole page wait on one bundle). They are the heart of modern rendering.
Streaming SSR: paint in chunks
Classic SSR has a hidden stall. If your page needs a slow database call, the server holds the entire response until that call finishes — the user gets nothing, not even the header, until the slowest piece is ready. Streaming SSR breaks that. The server sends the shell — header, layout, nav — immediately, then streams the slow parts in as their data resolves, over the same HTTP response using chunked transfer encoding.
The developer-facing API for this is a Suspense boundary. You wrap a slow section, give it a fallback (a skeleton or spinner), and the framework streams the fallback first, then pushes the real HTML when it’s ready and swaps it in.
export default function Page() {
return (
<Layout>
<Header /> {/* in the shell — streams instantly */}
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* slow data — streams in when ready */}
</Suspense>
</Layout>
);
}
The win is that first paint no longer waits on your slowest query. The user sees a real, laid-out page in milliseconds and the expensive region fills in a moment later — far better than a blank screen followed by everything at once.
Islands: static page, a few live spots
An island architecture flips the default. Instead of “the whole page is a JS app, hydrate all of it,” you say “the whole page is static HTML, and only these specific components get JavaScript.” A blog post is mostly prose — headings, paragraphs, images — that never needs to react to anything. Sprinkle in a search box, a like button, an image carousel. Those interactive spots are the islands; everything around them is a static sea that ships zero JavaScript.
Astro made this the mainstream pattern. You write components normally, and you opt each interactive one into hydration with a directive that also says when to bother:
---
import Search from "../components/Search.jsx";
import Comments from "../components/Comments.jsx";
import Newsletter from "../components/Newsletter.jsx";
---
<article>
<h1>Rendering models, explained</h1>
<p>…lots of static prose that ships no JavaScript…</p>
<Search client:load /> <!-- above the fold: hydrate immediately -->
<Newsletter client:idle /> <!-- not urgent: hydrate when the tab is idle -->
<Comments client:visible /> <!-- below the fold: hydrate only when scrolled into view -->
</article>
client:visible is the quiet superpower: the comments section’s JavaScript never downloads at all unless the user actually scrolls to it. On a page where 90% of the pixels are static, you might ship a few kilobytes of JS instead of a few hundred. First paint is instant because there’s barely anything to hydrate, and interactivity arrives per-island rather than all-or-nothing.
RSC: components that never reach the browser
React Server Components take partial hydration to its logical end. In this model, components render only on the server by default and send their output — not their code — to the browser. A component that formats a date, reads from the database, or renders a markdown article has no reason to exist on the client, so its JavaScript simply never gets shipped. You opt a component into the client, with its interactivity and its bundle cost, using a "use client" directive.
// app/post/[id]/page.jsx — a Server Component (the default)
import db from "../../lib/db";
import LikeButton from "./LikeButton"; // this one is "use client"
export default async function Post({ params }) {
const post = await db.posts.find(params.id); // runs on the server; no client JS
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton postId={post.id} /> {/* only this ships JS to the browser */}
</article>
);
}
// LikeButton.jsx
"use client"; // opt into the browser
import { useState } from "react";
export default function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? "♥" : "♡"}</button>;
}
RSC has been production-stable in the React ecosystem since 2023 and is the default architecture in the current Next.js App Router; by 2026 it’s a battle-tested pattern rather than an experiment. The headline benefit is bundle size — letting non-interactive components stay on the server can cut the client JavaScript that drives slow interactivity by a large margin, because the date formatter and the markdown renderer and the data layer never cross the wire.
The complementary half is Server Actions: functions you write on the server but call from a client component, so a form submit or a button click runs server code (a database write, say) without you hand-authoring an API route.
// a Server Action — runs on the server, invoked from the client
async function addComment(formData) {
"use server";
await db.comments.insert({ text: formData.get("text") });
revalidatePath("/post/[id]"); // refresh the cached page after the write
}
The mental adjustment is real: you’re now conscious of a server/client boundary running through your component tree, and "use client" marks the crossing. Get it wrong and you either leak server secrets toward the browser or accidentally pull a big dependency into the client bundle. The tooling guards against the first; the second is on you to watch.
See the difference: first paint vs interactive
The clearest way to feel CSR versus a server-rendered model is to watch the two moments that matter — when the user first sees content, and when the page first responds to a click. Press play and compare.
Notice the two green “interactive” moments. CSR and SSR both make you wait for a full bundle before anything clicks; islands make content clickable almost as soon as it’s painted, because there’s so little to hydrate. That gap is exactly what the modern models are chasing.
Choosing by what the page actually is
There is no best model, only a best fit for a given page — and a real app usually mixes several. Match the model to the content’s nature:
- Marketing pages, docs, blogs rarely change and are mostly text. Pre-render them: SSG for the fastest possible serve, or islands if you want a few interactive widgets without a full JS app. This is where you win Core Web Vitals easily.
- A news feed or product listing changes often but not per-user. ISR keeps it near-static-fast while refreshing on a timer or on publish; streaming SSR helps when one region depends on a slow query.
- A logged-in app dashboard is personalized and highly interactive. SSR the first paint so it’s not a blank screen, then let it behave like a CSR app for the twenty minutes the user is inside it. Nobody’s indexing it, so the SEO argument evaporates.
- A page with both — a product page with static description and a live inventory count — is exactly what RSC and server islands are for: static shell, a small server-rendered dynamic slot.
It all comes back to Core Web Vitals
Every tradeoff here shows up in the metrics that measure real user experience, which is why rendering choice is a performance decision, not an architecture-astronaut one. See Core Web Vitals for the full picture; here’s how each model tends to move them:
The through-line: shipping less JavaScript and putting real HTML in the first response is almost always the win. The models differ in how they get you there — build-time pre-render, per-request server render, per-island hydration, or a server/client boundary through your tree — but they’re all bending the same axis toward “content sooner, JavaScript later, and less of it.”
Summary
- The core axis: rendering models differ only in when HTML is produced — build time (earliest, cheapest, stalest) → request time → in the browser (latest, freshest, most work per hit).
- CSR ships a JS bundle and renders in the browser: instant in-app navigation, but a slow first paint and an SEO/JS cost. Right for logged-in apps, wrong for content sites.
- SSR renders per request on the server: good first paint and SEO, supports personalization, but pays server compute on every hit and can’t be cached bluntly.
- SSG pre-renders at build time to static files: the fastest, cheapest, most cacheable option — but content is frozen until the next deploy.
- ISR is SSG that regenerates on a timer or on-demand (
revalidatePath), following stale-while-revalidate so no visitor waits on the rebuild. - Hydration is the cost server-rendered pages pay: HTML looks done but is inert until JS downloads and attaches listeners. The modern answer is to hydrate less.
- Streaming SSR sends the shell immediately and streams slow regions in via Suspense boundaries, so first paint isn’t held hostage by the slowest query.
- Islands (Astro) ship static HTML plus a few independently-hydrated interactive components — minimal JS, per-island triggers like
client:visible. - RSC renders components on the server with zero client JS by default, opting into the browser with
"use client"; Server Actions run server code straight from a client event. Production-stable and mainstream by 2026. - Choose by content: marketing/docs → SSG or islands; news/feed → ISR or streaming; app dashboard → SSR + CSR; mixed pages → RSC or server islands. Every choice is ultimately a bet on Core Web Vitals.