The rendering strategy matrix

What it is

A rendering strategy answers two questions for a given route: when is the HTML produced, and where is it produced. Everything else, hydration cost, cacheability, freshness, personalisation, follows from those two answers.

                  When HTML is made      Where            Cacheable at CDN
CSR               in the browser         browser          the empty shell only
SSG               at build time          build machine    yes, fully
ISR               at build, then         server, on a     yes, with a TTL
                  regenerated on demand  background job
SSR               per request            server / edge    only if impersonal
Streaming SSR     per request, in        server / edge    only if impersonal
                  chunks
Islands / partial per request or build,  either           yes
  hydration       with most of the page
                  never hydrating

What this is confused with: a per-application choice. It is a per-route choice, and often a per-region-of-a-route choice. A marketing homepage, a product page, a search results page and an authenticated dashboard in the same application should not all use the same strategy.

Also confused: SSR and "server-side." SSR specifically means running your components on the server to produce HTML that the browser then hydrates. The component code still ships. That is different from Server Components (code never ships) and from a classic template engine (nothing hydrates).

The problem it solves

Every strategy trades three things against each other, and you cannot have all three:

FRESHNESS      how stale can this be?
PERSONALISATION  is the output the same for everyone?
COST/LATENCY   who pays: the build, the server, or the user's device?

The concrete failures at the extremes:

Everything CSR:
  - the crawler sees an empty <div id="root">
  - first contentful paint waits for bundle download + parse + hydrate
  - every data fetch waterfalls behind hydration
  - the user's device pays for work that could have been done once

Everything SSR:
  - TTFB is the slowest query on every request, for every user
  - the CDN caches nothing, so origin load scales with traffic
  - a database hiccup is a site outage rather than stale content
  - you pay compute for output that is identical for 90% of visitors

Everything SSG:
  - a price change requires a rebuild
  - 200,000 product pages is a 40-minute build, and one typo fix is
    a 40-minute build
  - nothing can be personalised

The matrix exists because most real applications have routes at all three extremes, and picking one strategy for the whole app means most routes get the wrong one.

Mechanics

The strategies, precisely

CSR (client-side rendering). The server sends a near-empty HTML document plus a script bundle. React boots and renders in the browser.

HTML (2KB) -> JS download -> parse/execute -> render -> fetch data -> render again
FCP: after JS executes.  LCP: after data lands.
CDN: caches the shell perfectly, which is 2KB of nothing.

Right for: authenticated tools behind a login where SEO is irrelevant, and where the user stays for a long session so the one-time boot cost amortises. An internal admin console is the honest CSR case.

SSG (static site generation). HTML is produced at build time and served as a file.

Build: for each of N routes, render to HTML, write to disk
Serve: CDN edge returns the file. TTFB is edge latency, ~10 to 40ms.

Right for: content that changes on a publish cadence rather than per request. Docs, marketing, blog posts, changelogs. The constraint is build time, which scales with route count, and the failure is a 200,000-page catalogue whose build takes longer than the deploy window.

ISR (incremental static regeneration). SSG plus a per-page TTL and on-demand regeneration.

revalidate: 3600
  request at t=0        -> serve cached HTML, it is fresh
  request at t=3601     -> serve the STALE cached HTML immediately,
                           and trigger a background regeneration
  request at t=3610     -> serve the new HTML

This is stale-while-revalidate: the user never waits for the
regeneration. The first request after expiry gets stale content, which
is the trade.

Plus on-demand revalidation: a webhook from the CMS invalidates a specific path when an editor publishes, so you get build-time performance with near-real-time updates and no full rebuild.

Right for: large catalogues of semi-static content. This is the correct answer for most e-commerce product pages, and it is why the "SSG cannot handle 200,000 pages" objection is usually stale: with ISR you build the top N pages and generate the tail on first request.

SSR (server-side rendering). HTML per request.

request -> run the router, fetch data, render the tree to HTML,
           send it -> browser paints -> download JS -> hydrate
TTFB: server time, which includes the data fetches.

Right for: personalised or highly volatile content that also needs to be indexable or fast on first paint. The tax is that TTFB now contains your slowest query, and the CDN cannot help.

Streaming SSR. SSR that flushes the shell before the slow parts resolve, using Suspense boundaries (covered in concurrent React and Server Components).

SSR:            |------ 620ms of queries ------| flush everything
Streaming SSR:  |-40ms-| flush shell + skeletons
                        |--- boundaries stream in as they resolve ---|

TTFB drops from 620ms to 40ms. Total time to full content is
unchanged or slightly better.

Streaming decouples TTFB from your slowest dependency, which is its entire point, and it is almost always the right upgrade from plain SSR.

Islands / partial hydration. The page is mostly static HTML; only marked interactive regions ship JS and hydrate.

Astro:  a page with 12 components, 2 marked client:load
        -> JS for 2 components ships, 10 never hydrate
        -> most pages ship zero framework JS
Qwik:   "resumability": no hydration pass at all; event handlers are
        lazily fetched on first interaction, keyed by serialized state
        in the HTML

Right for: content sites with a few interactive widgets. The mismatch is a genuinely application-like UI, where nearly everything is an island and you have added a framework boundary for nothing.

React Server Components cut across this axis rather than sitting on it: they reduce what is in the bundle and can be combined with SSG, ISR, SSR or streaming.

The decision procedure

Ask these in order, per route:

1. Is the content personalised per user?
   YES -> SSR (streaming), or CSR for the personalised region inside
          an otherwise cached page
   NO  -> continue

2. How fresh must it be?
   seconds        -> SSR, or ISR with a short revalidate
   minutes/hours  -> ISR
   per deploy     -> SSG

3. Does a crawler or a link preview need the content?
   YES -> anything but CSR
   NO  -> CSR is on the table

4. How many routes are there?
   > ~10,000  -> ISR or SSR; a full SSG build becomes the bottleneck
   < ~1,000   -> SSG is comfortable

5. How much of the page is actually interactive?
   a little -> islands, or RSC with small client components
   most     -> a full client framework, and focus on bundle size

Step 1 has an escape hatch that resolves most of the hard cases, and it is the technique that matters most in practice: split the page by personalisation rather than choosing one strategy for all of it.

Product page:
  - product info, images, description, reviews  -> SAME for everyone
  - price with the user's currency and any
    account-specific discount                   -> personalised
  - "recently viewed"                            -> personalised
  - cart badge                                   -> personalised

Strategy: ISR the page. Render the personalised bits client-side (or
via an edge function that reads the auth cookie and patches a few
values). The CDN caches 95% of the bytes; the personalised 5% costs
one small request.

This is the same argument as caching personalised pages generally: cache the shared substrate, compose the personal layer separately, rather than declaring the whole page uncacheable.

The costs nobody puts in the matrix

HYDRATION cost scales with the amount of client JS, not with page size.
  An SSR page that ships 800KB of JS paints fast and is
  UNRESPONSIVE until hydration completes. Good LCP, bad INP.
  Fast paint plus a frozen page is a worse experience than a
  slightly slower paint on a responsive page.

CACHE INVALIDATION is the real cost of ISR/SSG.
  Every content change now needs a path to invalidate the right
  pages. A product appearing in 40 category listings means a
  publish must invalidate 41 paths. Tag-based invalidation exists
  for exactly this.

BUILD TIME is a deploy-frequency constraint.
  A 40-minute build means you cannot ship a hotfix in 5 minutes.
  That is an incident-response constraint hiding in a rendering
  decision.

SERVER COST scales with traffic under SSR and with content under SSG.
  Which one you prefer depends on your traffic-to-content ratio,
  and that ratio is a business fact, not a technical one.

A worked example: one e-commerce site, five strategies

An e-commerce site with roughly 60,000 products, 400 category pages, a marketing site, search, and an authenticated account area. It started as a single-page app: everything CSR.

The measured starting point:

Bundle: 640KB gzipped
Product page (mid-tier phone, 4G):
  TTFB          90ms   (an empty shell from the CDN)
  JS downloaded 1,100ms
  JS executed   1,700ms
  data fetched  2,100ms
  LCP           2,400ms
  INP           260ms (hydration and re-render churn on interaction)

SEO: product pages were indexed via the crawler's JS rendering, with
a reported multi-day lag between publish and index. Category pages
ranked poorly against competitors serving HTML.

Route-by-route decision, using the procedure:

/ , /about, /shipping  (12 marketing routes)
  personalised? no. freshness? per deploy. SEO? critical. count? 12.
  -> SSG. Rebuilt on every deploy, served from the edge.

/category/[slug]  (400 routes)
  personalised? no (product ordering is global). freshness? minutes
  (inventory and merchandising). SEO? critical. count? 400.
  -> ISR, revalidate 300s, plus on-demand revalidation from the
     merchandising tool.

/product/[sku]  (60,000 routes)
  personalised? the PRICE is (currency, account discounts); the rest
  is not. freshness? price in seconds, content in hours. SEO?
  critical. count? 60,000 -> a full SSG build is out.
  -> ISR (revalidate 3600) for the page, with the price block
     rendered client-side from a small edge endpoint, and on-demand
     revalidation on content publish.

/search?q=...
  personalised? mildly (locale, some ranking signals). freshness?
  per request. SEO? not for arbitrary queries. count? unbounded.
  -> streaming SSR. The shell and facets flush immediately; the
     result list streams. Search itself takes 180 to 900ms
     depending on the query, and streaming means TTFB does not.

/account/*  (orders, addresses, returns)
  personalised? entirely. SEO? irrelevant, it is behind auth.
  -> CSR, inside an SSR'd shell. Long sessions, so boot cost
     amortises, and no crawler cares.

The build-time problem, and how ISR solved it:

Full SSG of 60,000 products, measured at ~55ms per page rendered
with 8 build workers:
    60,000 * 55ms / 8 = ~7 minutes of pure render
  plus data fetching, image processing and asset work, the real
  build was ~34 minutes.

With ISR: pre-render the 2,000 best-selling SKUs at build time
(2,000 * 55ms / 8 = ~14 seconds of render), generate the rest on
first request and cache them.
    build time: 34 minutes -> ~4 minutes
    tail SKUs: first visitor after a cache miss waits for a real
    render (~300ms including data), every subsequent visitor gets
    the edge.

A 34-minute build is a deploy-frequency constraint and therefore an incident-response constraint, and that, not the rendering performance, was the argument that carried the decision internally.

The result on the product page:

                      before (CSR)      after (ISR + client price)
TTFB                  90ms              30ms  (edge cache hit)
LCP                   2,400ms           700ms
INP                   260ms             120ms (bundle 640KB -> 210KB)
price visible         2,100ms           ~450ms (edge endpoint, parallel
                                        with the page's own paint)
origin requests       per data fetch    ~1 per hour per SKU

The price block deserves the detail, because it is the decision that made the rest possible. Making the whole page uncacheable to get the price right would have forced SSR on 60,000 routes and returned origin load to per-request. Instead:

The ISR'd HTML contains a placeholder with the list price and a
data attribute for the SKU. A tiny client script (or an edge
function reading the auth cookie) fetches the personalised price for
the visible SKUs in one batched request and patches the DOM.

Cost: one small request, ~40ms at the edge, and a brief moment where
the list price is shown before the discounted one. That moment was
mitigated by rendering the personalised price server-side at the edge
for logged-in users, keyed on the cookie, so only anonymous visitors
see the list price and for them it is correct.

Two things went wrong:

1. Invalidation was under-specified. Changing a product's title
   invalidated /product/[sku] but not the 6 category pages listing
   it, so category pages showed the old title for up to 300s. Fixed
   with tag-based invalidation: pages register the tags they depend
   on, and a publish invalidates by tag rather than by path.

2. The search route's streaming shell rendered facet counts that
   arrived with the results, so the facet sidebar reflowed and
   pushed the results down. Fixed by giving the facet skeleton the
   same fixed height as the real facets, which is the general rule:
   a skeleton whose layout does not match the real content trades
   a slow paint for a layout shift.

Production evidence

Next.js implements SSG, ISR (including on-demand revalidation and tag-based invalidation via revalidateTag), SSR, streaming SSR and Partial Prerendering, and its documentation describes ISR explicitly as stale-while-revalidate at the page level. It is the most widely deployed implementation of the full matrix.

Astro's islands architecture ships zero framework JavaScript by default and hydrates only components marked with a client:* directive, which is the reference implementation of partial hydration.

Qwik's resumability avoids the hydration pass entirely by serialising application state and listener locations into the HTML and lazily fetching handler code on first interaction. It is the clearest existing counter-argument to hydration as a necessary cost.

Remix (now folded into React Router) defaults to server rendering with progressive enhancement, including forms that work without JavaScript, and pushes filter and pagination state into the URL, which is the framework-level version of the URL-as-state argument.

stale-while-revalidate is a standardised HTTP Cache-Control extension (RFC 5861), so ISR is a well-known caching pattern applied at the page-render layer rather than a novel invention.

Google's documented position is that its crawler renders JavaScript, but that rendering is queued and deferred relative to HTML parsing, which is the concrete mechanism behind the indexing lag that CSR-only sites observe.

The debate

Is SSR the safe default? No, and treating it as one is a common expensive mistake. SSR puts your slowest query in every user's TTFB and removes the CDN from the picture, which converts a traffic spike into an origin incident. The position: default to the most cacheable strategy the route's freshness and personalisation requirements allow, and reach for SSR when personalisation or per-request freshness genuinely requires it.

Is ISR just caching with extra steps? Essentially yes, and that is a point in its favour: it is stale-while-revalidate applied to page rendering, a pattern with two decades of operational understanding. The genuine new cost is invalidation correctness, because a page's content now has dependencies that must be tracked, which is why tag-based invalidation exists and why path-based invalidation quietly goes wrong.

Do islands beat React? For content sites, on the metric of shipped JavaScript, clearly. For application-like UIs the comparison collapses, because when most of the page is an island you have a component framework with an extra boundary. The position: islands for content with widgets, a component framework for applications, and the honest test is what fraction of the page is interactive.

Should you personalise at the edge or on the client? Edge personalisation gives a correct first paint with no flash of the wrong value, at the cost of a cache key per audience segment and edge compute. Client patching keeps the cache key at one and shows a brief incorrect value. The resolution is usually to segment: anonymous visitors get the fully cached page, authenticated ones get edge personalisation on a cookie-derived key. What you should not do is make the whole page uncacheable to fix one number.

Does SEO still force server rendering? For anything you need indexed promptly, yes in practice. Crawlers do execute JavaScript, but rendering is queued and deferred, so publish-to-index lag is materially worse. And it is no longer only crawlers: link previews in messaging apps and social platforms generally do not execute JavaScript, so a CSR page shares as a blank card.

Follow-up Q&A

"How do you choose a rendering strategy for a route?"

Ask, in order: is the content personalised per user, how fresh must it be, does a crawler or link preview need it, how many routes are there, and how much of the page is actually interactive. Personalised and fresh points to streaming SSR; impersonal and hourly-fresh points to ISR; impersonal and deploy-fresh points to SSG; behind a login with no crawler need makes CSR defensible. The count matters because a full static build scales with route count and becomes a deploy-frequency constraint. And it is a per-route decision, often a per-region decision, not an application-wide one.

"What exactly does ISR do on a request after the TTL expires?"

It serves the stale cached HTML immediately and triggers a background regeneration, so no user waits for the render. The next request after regeneration completes gets fresh content. That is stale-while-revalidate applied at the page level, and the trade is explicit: the first request after expiry sees stale content. On-demand revalidation complements it, letting a CMS webhook invalidate specific paths or tags on publish, so you get build-time performance with near-real-time updates and no full rebuild.

"How do you serve a personalised price on a statically cached page?"

Split the page by personalisation rather than downgrading the whole route. Cache the impersonal 95 percent, and render the personalised fragment separately: either client-side from a small batched endpoint, or at the edge from a cookie-derived key. For anonymous visitors the cached list price is already correct, so only authenticated users need the edge path. The alternative, making the whole page uncacheable to get one number right, forces SSR on every route and returns origin load to per-request.

"Why is a fast LCP with a big bundle still a bad experience?"

Because paint and interactivity are decoupled. Server-rendered HTML can paint quickly while the page remains unresponsive until hydration finishes, and hydration cost scales with the amount of client JavaScript, not with the size of the HTML. That shows up as a good LCP and a bad INP: the page looks ready and does not respond to taps. Reducing shipped JavaScript, through islands, Server Components, or code splitting, is what fixes it; rendering strategy alone does not.

"What breaks when you adopt ISR or SSG at scale?"

Invalidation correctness and build time. A product appearing in 40 category listings means a publish must invalidate 41 paths, and path-based invalidation quietly misses the ones nobody enumerated, which is why tag-based invalidation exists. Build time scales with route count: 60,000 pages at 55ms each across 8 workers is about 7 minutes of pure render and, with data fetching and assets, a 34-minute build. That is a hotfix you cannot ship in five minutes, which makes it an incident-response constraint hiding inside a rendering decision.

"When is client-side rendering the right answer?"

Behind authentication, where no crawler or link preview needs the content and users hold long sessions so the one-time boot cost amortises across many interactions. An internal admin console or an account area is the honest case. It stops being right the moment the route needs to be indexed, shared as a link preview, or opened cold and used briefly.

Common misconceptions

"Pick one rendering strategy for the app." It is a per-route and often per-region decision. A marketing page, a product page, a search page and a dashboard have different answers.

"SSR is the safe default." It puts your slowest query in every TTFB, removes the CDN, and turns a traffic spike into an origin incident.

"SSG cannot handle large catalogues." ISR pre-renders the popular subset and generates the tail on demand, which is the standard answer for six-figure route counts.

"Server rendering fixes performance." It fixes first paint. Interactivity is gated by hydration, which scales with shipped JavaScript, so a server-rendered page with a huge bundle has a good LCP and a bad INP.

"Crawlers run JavaScript, so CSR is fine for SEO." Rendering is queued and deferred, so publish-to-index lag is materially worse, and link previews in messaging and social apps generally do not execute JavaScript at all.

"A personalised element makes the page uncacheable." It makes that element uncacheable. Cache the shared substrate and compose the personal layer separately.

Interview delivery note

Say this verbatim: "Rendering strategy is a per-route decision driven by two questions: is this personalised, and how stale can it be. And when one small element is personalised, split the page rather than downgrading the whole route, because making a product page uncacheable for the price block turns a CDN hit into origin load on every request." The framing plus the technique that resolves most of the hard cases.

The senior-versus-staff separator is naming build time as an incident-response constraint. A senior engineer compares LCP across strategies. A staff engineer points out that a 34-minute static build means you cannot ship a hotfix in five minutes, so the rendering decision has quietly set your mean time to recovery, and that this is usually the argument that actually moves an organisation, because it is about risk rather than milliseconds.

The second signal is separating paint from interactivity. Saying "server rendering fixes LCP and does nothing for INP, because hydration scales with shipped JavaScript, so a fast-painting frozen page is a worse experience than a slightly slower responsive one" shows you have measured both rather than optimising the metric that is easiest to move.

Further reading

  • Next.js documentation on Incremental Static Regeneration, on-demand revalidation and revalidateTag, and on Partial Prerendering.
  • RFC 5861, stale-while-revalidate, for the caching semantics ISR implements at the page layer.
  • Astro's islands architecture documentation, and Qwik's resumability documentation, as the two serious challenges to whole-page hydration.
  • Google Search Central's documentation on JavaScript rendering and the deferred rendering queue.
  • The INP diagnosis page in this chapter, for what hydration cost looks like as a user-visible metric.