Skip to content

SSR, SSG, ISR, CSR: when the HTML is built, and at what cost

Profile photo of Adrian Zawadzki

Adrian Zawadzki

22 min read

The four rendering strategies aren't a menu you pick once - they're a per-route decision about where the HTML is built and when. Each has a different generation moment, a different TTFB, a different infrastructure cost, and a different 'fresh data vs speed' trade-off. I unpack the mechanics of all four and show when each one makes sense.

Four versions of the same component - a blog post page:

// 1. SSR - fresh HTML on every request
export async function getServerSideProps() {
  const post = await fetchPost();
  return { props: { post } };
}

// 2. SSG - HTML built at build time
export async function getStaticProps() {
  const post = await fetchPost();
  return { props: { post } };
}

// 3. ISR - SSG + periodic background refresh
export async function getStaticProps() {
  const post = await fetchPost();
  return { props: { post }, revalidate: 60 };
}

// 4. CSR - HTML built in the user's browser
function Post() {
  const [post, setPost] = useState(null);
  useEffect(() => {
    fetchPost().then(setPost);
  }, []);
  return post ? <Article {...post} /> : <Skeleton />;
}

I’m using the Pages Router API here because it’s widely recognized. The mechanics are the same in Astro, Remix, SvelteKit, and others - only the names differ. The choice of strategy itself (where and when the HTML is built) is framework-agnostic.

To the end user, the result looks the same. But time to first render, infrastructure cost, and data freshness - that’s where the differences are huge. Choosing between these four approaches isn’t a matter of taste; it’s a deliberate decision about when the HTML is built and who pays for it.

#Client or server - where the HTML is built

Every web page a user sees is ultimately HTML in their browser. The only question is where and when that HTML was built:

  • Server-side - the HTML is ready before it reaches the browser. The server or build system does the work, the browser just displays it.
  • Client-side - the server sends a minimal shell + a JS bundle. The browser downloads, parses, runs it, and only then builds the HTML in the DOM tree.

Server-side splits further depending on exactly when that HTML was built:

  • On every request → SSR
  • Once, at build time → SSG
  • Once at build time + periodic background refresh → ISR

Plus one client-side variant:

  • In the user’s browser, after JS runs → CSR

Four moments of HTML generation. Each has different consequences for performance, SEO, cost, and how fresh the data is when the user sees it.

#SSR (Server-Side Rendering) - fresh on every request

In SSR, every visit to the page runs a handler on the server:

user → request → handler on the server:
                    1. fetch data (from a database, an API)
                    2. render the component to HTML
                    3. respond with the finished HTML
              → browser receives full HTML
              → JS hydrates (adds interactivity)

Every user gets fresh HTML, generated at request time. The data is as new as the last database call.

Pros

  • Data always fresh (every request is a new fetch).
  • HTML complete in the first response - good for SEO, good for users on slow JS (content shows before anything parses).
  • Per-user personalization (the response depends on cookies, session, query params).

Cons

  • TTFB depends on render time on the server. Slow API → slow page.
  • Every request costs CPU + RAM on the server. Scaling = more machines.
  • Harder to cache on a CDN (per-user response).
  • Requires a server running 24/7.

When SSR: the data has to be fresh at the moment it’s shown (a dashboard with live metrics, e-commerce with always-current stock, personalized feeds), or the response depends on the specific user (cookies, A/B test, ip-geo).

web.dev - Rendering on the Web breaks down SSR alongside the other strategies and shows concrete TTFB / FCP / TTI metrics per approach.

#SSG (Static Site Generation) - once per build

In SSG, all the rendering work happens before deploy. The build system iterates over every route, fetches data for each, renders HTML, and writes a file. Deploy pushes the static files to a CDN. From then on every user gets a ready-made file straight from the CDN:

build time:
  1. for each route:
       fetch data
       render to HTML
       write the file
  2. deploy the ready files to a CDN

runtime:
  user → request → CDN node responds with the ready file
        → browser receives HTML
        → JS hydrates

Pros

  • Very fast TTFB (the CDN serves a file, renders nothing).
  • Cheap hosting (every CDN serves static files for pennies).
  • Easy to scale (the CDN does it for you).
  • Ideal for SEO and Core Web Vitals (LCP/FCP in the millisecond range).
  • Works even if the backend is down - the files are already on the CDN.

Cons

  • Data is “frozen” at build time. A change in the database won’t show until the next deploy.
  • Every change = a full build. Fine for 50 routes, hours for 50,000.
  • No per-user personalization (everyone sees the same file).

When SSG: content changes rarely and deploys can be planned (blog, documentation, marketing pages, landing pages, portfolio). Practically the default for content-driven sites.

#ISR (Incremental Static Regeneration) - SSG plus refresh

ISR is a hybrid of SSG and SSR. The page is pre-rendered at build time (like SSG), but the server keeps a timestamp of the last regeneration. Once the threshold passes (revalidate: 60 seconds), the next request gets the old cached HTML, while regeneration kicks off in the background. Later users already get the new version:

build: render and cache every route (like SSG).
       cache starts fresh, revalidate = 60s.

t=0s    deploy, cache fresh

t=10s   user A → inside the 60s window, cache fresh
               → serve from cache instantly, nothing in the background

t=45s   user B → still inside the window, cache fresh
               → serve from cache instantly, nothing in the background

t=65s   user C → 60s window passed, cache stale
               → serve the OLD version from cache INSTANTLY (user doesn't wait)
               → in the background: fetch + render + swap the cache

t=66s   user D → background regeneration still running
               → still the OLD version from cache

t=80s   user E → cache swapped, the window restarts
               → serve the NEW version from cache

What you just saw is called stale-while-revalidate: serve the old version instantly, refresh in the background. The point: nobody ever waits for regeneration. The user gets the cache immediately (like user C), and the fresher version reaches the next visitors (like user E).

Pros

  • TTFB close to SSG (a cache hit served instantly).
  • Data fresher than pure SSG (revalidates every N seconds).
  • CDN-like scalability (most requests come from cache).
  • Can regenerate individual routes without a full build.

Cons

  • Some users see stale data (between revalidate and the actual regeneration).
  • Deployment complexity - it needs a server that knows the cache timestamp (not a pure CDN).
  • It sometimes shows stale data, and that’s not acceptable everywhere. Take stock levels in a shop - ISR might serve “in stock” when the product just sold out (old cache until the next regeneration). SSR is better here - always-fresh state.
  • Vendor lock-in: implementations differ across platforms (Vercel, Netlify, Cloudflare).

When ISR: content changes occasionally and a few seconds of delay isn’t a problem (a blog with lots of posts, an e-commerce product catalog, listing pages). The sweet spot between SSG speed and SSR freshness.

#CSR (Client-Side Rendering) - the HTML is built on the user’s machine

In CSR the server sends a minimal HTML shell plus a JS bundle. The shell is an empty skeleton of the page - a husk with no content, usually nothing more than <div id="root"></div>, into which JS will later inject the content. The browser downloads, parses, and runs the JS, and only then does the JS build the DOM. Data is fetched from the browser (usually via useEffect + fetch):

user → request → server responds with an HTML shell + JS bundle
              → browser downloads + parses
              → JS runs
              → useEffect fires the data fetch
              → response comes back from the API
              → React (or another framework) renders the component
              → user sees the content

Each of these steps takes time. The user sees content only in the last step.

Pros

  • High interactivity - after the first render everything reacts instantly (CSR has no separate hydration step - the component tree is built in the browser interactive from the start).
  • Real-time updates are easier (WebSocket → setState).
  • The simplest hosting that just serves ready files is enough - no rendering server (all rendering happens in the browser).
  • Granular control over when and what to fetch (lazy loading, infinite scroll, optimistic UI).

Cons

  • Empty HTML in the first response - to SEO crawlers without JS the page is blank. Google has long handled it (it renders JS), but slower and not as well as with ready HTML.
  • The first content appears late - the user sees a skeleton/spinner before anything renders (delayed FCP). Full interactivity (TTI) comes even later, once the JS parses and runs.
  • All rendering happens on the user’s device - a weaker device = a slower page.
  • Two round trips to the server before the first content: HTML+JS first, then the data fetch.

When CSR: apps like SaaS dashboards / admin panels / internal tools, where SEO doesn’t matter, the user is already logged in, the session is long, and interactivity is the heart of the product. The classic SPA case.

#What to choose and why

Four strategies compared across seven criteria:

SSRSSGISRCSR
TTFBvariable (depends on API)very lowvery lowlow (HTML shell)
FCPgoodvery goodvery goodpoor
SEOvery goodvery goodvery gooddepends on crawler
Data freshnessmaximalonly at buildrevalidate timerper fetch at runtime
Infra costhigh (server 24/7)very low (CDN)mediumvery low (CDN)
Complexitymediumlowhighmedium
Personalizationyesnono (without tricks)yes

The key to the choice: decide per route, not per app. A real app uses a mix:

  • / (landing) - SSG (rarely changes, SEO matters)
  • /blog/[slug] - SSG or ISR (stable content, new posts weekly)
  • /shop/[product] - ISR (stock changes periodically, SEO ranking matters)
  • /dashboard - CSR (logged-in user, full interactivity)
  • /checkout - SSR (always-fresh data, per-user personalization)
  • /search?q=... - SSR (dynamic based on query params)

Frameworks like Next.js, Astro, and Remix let you choose per route. There’s no longer a situation where “this app is SSR” - it’s “this app uses a mix of strategies, each route has its own”.

Try it below - click a strategy to see where on the timeline the HTML is built and what that means for TTFB, freshness, SEO, and cost:

Pick a strategy:
When the HTML is born:
  1. build
  2. deploy
  3. requestHTML born here
  4. browser
Trade-offs:
Moment HTML is born
request
TTFB
Variable - depends on render time on the server (typically 100-500ms).
Data freshness
Maximum - every request hits fresh data.
SEO
Excellent - HTML complete in the first response.
Infra cost
High - a server has to run 24/7 to handle every request.
When to use it
Dashboards, ecommerce, personalised feeds, search results - anywhere data must be current at view time or the response depends on the user.

#Modern - streaming, partial hydration, RSC

The four basic strategies cover the entire choice. What’s below isn’t another option alongside them - these are variants layered on top of those four, most often on SSR. You still choose SSR / SSG / ISR / CSR; these additions only change how the HTML is delivered or how much JS lands on the client, not when and where the HTML is built.

Streaming SSR (React 18, Suspense) - this is still SSR, just delivered in chunks. The server doesn’t wait for all the HTML to render - it sends fragments (chunks) as they’re ready. The user sees the page header while the server is still computing the product list. TTFB is much lower than in plain SSR - the server sends the top of the page right away instead of waiting for all the data. FCP is faster too: the first content shows before the rest of the page finishes rendering.

Partial hydration / Islands (Astro, partly Next.js with RSC) - the server renders the whole HTML, but only hydrates some fragments (the ones that need interactivity). The rest stays static HTML. Less JS to download, less JS to run, better interactivity metrics on weaker devices. (Qwik goes a step further with resumability - it skips hydration entirely, resuming state from serialized HTML instead of rebuilding it in the browser.)

React Server Components (RSC) - a hybrid of server-rendered and client-side within a single component tree. Components marked as “server” render and stay on the server (they don’t ship to the browser as JS). “Client” components hydrate normally. The boundary is fluid - a server component can embed a client component. It fits best when the page is mostly static content (server components, zero JS sent to the client) and interactivity is needed only here and there (client components).

All these variants are extensions of the same principle (when and where the HTML is built), not new strategies. You can read them as “SSR with better delivery” (streaming), “SSR with less JS on the client” (islands), “SSR with the boundary controlled at the component level” (RSC).

Patterns.dev - Rendering Patterns breaks each of these variants down in its own note with diagrams and code.

#Common pitfalls - what breaks most often in each strategy

Each strategy has its own collection of problems that keep coming back in real projects. A good set for interview prep and for code review.

SSR

  • window / document on the server. Node.js has no DOM API. Trying to use them in a component rendered on the server throws a ReferenceError. Fix: guard with typeof window !== 'undefined', use useEffect, or mark the component as client-only.
  • Hydration mismatch. The client renders differently from the server (e.g. new Date().toLocaleString() from a different timezone, Math.random(), data from localStorage). The framework rebuilds the fragment from scratch, losing the pre-render benefit and logging a warning. Fix: deterministic render (the same input always yields the same HTML - no Math.random(), time, or timezone in the markup), suppressHydrationWarning (a React prop saying “I know the server and client differ here, don’t warn me”) for deliberately allowed differences, client-only for genuinely variable fragments.
  • Singleton shared across requests. A global object (e.g. a cache, config, user state) initialized once at server start is seen by everyone. One user can see another’s data - a classic memory + security bug.
  • Slow API blocks the whole render. SSR waits until all the data is available before sending the HTML. One slow query → TTFB in seconds. Fix: streaming SSR with Suspense, parallel queries, timeouts.

SSG

  • Stale content without a rebuild. You change a post in the CMS, but until you run npm run build and deploy, the user sees the old version. Solution: webhook → CI → automatic redeploy, or ISR.
  • Build time grows out of control with a large number of pages. SSG for 100k products = a build that takes hours. I tripped over this myself - in one of my projects Gatsby builds reached 15-20 minutes (the image pipeline was the bottleneck) and Netlify would sometimes abort them at the time limit. Fix: fallback: blocking / on-demand generation, or SSR per route with aggressive caching.
  • A missing dynamic path = 404. A page you didn’t generate at build time doesn’t exist. A classic trap for new items added after deploy.
  • Environment variables baked in. process.env.* values used at build end up in the static HTML. Changing them requires a rebuild. Never put secrets in public variables.

ISR

  • Stale content visible “right after revalidate”. The first user after the revalidate time elapses gets the old HTML (cache), while regeneration runs in the background. The second user sees the new version. The consequence: two users on the same page in the same second see different data. Fine for a blog, problematic for product prices.
  • Too short a revalidate = effectively SSR. Set it to 5 seconds and every request regenerates, costs climb to SSR levels without the cache benefits.
  • Vendor lock-in. Full ISR (with on-demand revalidation, granular cache tags) works most completely on Vercel + Next.js. Netlify and Cloudflare have their own equivalents (Netlify, for example, via standard stale-while-revalidate headers), but with different semantics and configuration.
  • Race condition on regeneration. Two requests arrive at the same time after the revalidate time elapses - both try to regenerate. Some platforms deduplicate, others don’t.

CSR

  • SEO disaster. The crawler gets an empty <div id="root"></div> and nothing else. Google has long been able to execute JS, but: delayed indexing, and social-media crawlers (FB, Twitter, Slack) generally don’t run JS. Open Graph + Twitter Cards are lost.
  • Layout shift while data loads. The user sees a skeleton, then content, then the data loads in, then the rest jumps in. CLS in Lighthouse goes red.
  • Auth flicker. The page renders “Sign in” for a moment before it checks the token from localStorage. The classic flickering logged-in / logged-out header.
  • Memory leaks from useEffect. Subscribe to a WebSocket, setInterval, a listener on window - without cleanup in a long-lived SPA, memory usage grows with every navigation.

Cross-cutting (any strategy with server-side rendering)

  • Theme flicker (FOUC). FOUC = Flash of Unstyled Content: a brief flash of the page before the intended styles apply. Here specifically: dark mode kept in localStorage - the server doesn’t know it and sends HTML in one theme, the client hydrates and switches to the other. A flash of a white/black screen. Fix: an inline script before <body> that sets the class from localStorage before the first paint.
  • Locale mismatch. The server renders in one language, the client prefers another - either two renders (time and cost) or a flash of content.
  • Date / timezone divergence. new Date().toLocaleString() returns something different in Node (usually UTC) than in the browser (local timezone). A classic cause of hydration mismatch.

#Strategies aren’t a menu, they’re a per-route decision

The four basic strategies - SSR, SSG, ISR, CSR - really differ in just one thing: when and where the HTML is built. Every other difference (TTFB, SEO, cost, freshness, interactivity) follows from that single choice.

A pragmatic decision path:

  1. Default = SSG. Static content, deploy = re-build, the CDN serves it. Cheapest, fastest, the fewest things that can break.
  2. If the data changes but a few seconds of delay is acceptable → ISR. SSG performance, freshness grows automatically.
  3. If the data has to be fresh at display time or depends on the user → SSR. The price is TTFB and server cost.
  4. If SEO doesn’t matter and interactivity is the heart of the product → CSR. The classic SPA.
  5. Per route, not per app. A mix of strategies is the norm, not the exception.

The framework is an implementation detail - Next.js, Astro, Remix, SvelteKit, Nuxt all offer the same four options behind different APIs. The choice of strategy doesn’t start with “which framework”, it starts with “what data, and how often does it change”.

Comments

Loading comments…

Leave a comment