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?

build timerequest timein the browserSSGonce, on deployISRdeploy + refreshSSRevery requeststreamingrequest, in chunksCSRon the clientearliercheap to serve, cacheable on a CDN,fast first paint — but content is frozenlateralways fresh, personalized, live data —but work per request and a server to run it
The whole space of rendering models is one axis: how early the HTML is produced. Earlier means cheaper and faster to serve but staler; later means fresher but more work per request. Everything else is a variation on this tradeoff.

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.

Visitor Agets cached pageinstantbackgroundregenerates pagestale mark hitVisitor Bgets fresh pageinstant + freshNobody waits on the rebuild. The cost of freshness is paidin the background, not on the critical path of any single visitor.
ISR's stale-while-revalidate cycle: the first visitor after expiry still gets the fast cached page instantly, while a fresh copy builds in the background and is served to everyone after.

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.

1. HTML paintedbuttonvisible, but dead2. JS runsbundle.jsrebuild treedownloading + parsing3. hydratedbuttonclickableThe whole page waits on one big bundle to become usable. That is the problem the next models attack.
Hydration in three beats: the server sends complete HTML the browser paints immediately (visible but dead), the JavaScript bundle downloads and runs, then it attaches event listeners to the existing nodes and the page becomes interactive.

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.

t = 40msheader (shell)feed: skeletont = 380msheader (shell)post from the databaseanother real postfeed: streamed in📦
Streaming SSR: the document arrives and paints in chunks. The shell and header render at once; a slower data region shows a skeleton, then swaps to real content when its chunk arrives — instead of the whole page blocking on the slowest query.

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.

static HTML — 0 KB JS🏝 Searchclient:load🏝 Carouselclient:visible🏝 Newsletter — client:idle
Islands architecture: the page is static HTML (no JavaScript) with a few interactive islands that each hydrate independently. The static sea ships zero JS; only the highlighted islands carry a bundle, and each can hydrate on its own trigger.

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.

interactiveFirst paint vs interactive: CSR vs SSR vs Islands

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:

interactivestaticrarely changeschanges constantlyMarketing / docsSSG or IslandsNews / feedISR or streamingApp dashboardSSR + CSRBlog / e-commerceISR + RSC
A decision guide by content type. Ask how fresh the content must be and how interactive the page is; that pair points at a model. Most real sites combine two or three across different routes.
  • 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.