Skip to content

    Frontend System Design

    Rendering Strategies: CSR, SSR, SSG, ISR and Streaming

    Decide where and when each page of a web app produces its HTML: compare CSR, SSR, SSG, ISR and streaming, and the hydration cost, caching and metrics of each.

    By Published 23 min read

    Share

    This lesson is part of Frontend System Design Foundations: How the Web Runs. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to explain where and when the HTML of a page is produced under client-side rendering, server-side rendering, static generation, incremental static regeneration and streaming; describe what hydration is, what it costs and why a hydration mismatch happens; trace the request lifecycle of each strategy; and choose, per page type, the strategy that fits, including how it interacts with HTTP caches and a CDN.

    A restaurant can get a meal to your table in several ways. It can hand you raw ingredients and a recipe, so you cook at the table. It can cook each order when it arrives. It can cook a batch before opening, and swap in a fresh tray when the old one gets stale. Or it can send out the starter first and the rest as each course leaves the kitchen. The food is the same; what changes is where the cooking happens, when, and who waits.

    That is the subject of this lesson, applied to web pages. The running example is an online store with five kinds of page: a home page, category pages (all shoes), product pages, a cart, and a signed-in account area. They look alike to the shopper but need different answers.

    Here rendering means producing the HTML markup of a page, not the browser turning HTML into pixels (that is The Critical Rendering Path).

    The core idea

    Two questions describe every strategy: where is the HTML produced (a build machine, a server, or the user's browser), and when (before anyone asks, when someone asks, or in the background after an earlier request)?

    Strategy Where the HTML is produced When
    Client-side rendering (CSR) In the browser, by JavaScript After the page loads, on every visit
    Server-side rendering (SSR) On a server At request time, every request
    Static site generation (SSG) On a build machine At build time, once per deploy
    Incremental static regeneration (ISR) On a server, saved as a static page At build or first request, then refreshed in the background
    Streaming SSR On a server, sent in pieces At request time, in chunks as parts are ready

    When HTML comes from somewhere other than the browser, JavaScript must later attach itself to it to make it interactive. That step is hydration, and it is the price of every strategy except pure CSR.

    Figure 1. Three places HTML can be produced

    Where HTML is produced: build machine, server or browserBuild step to CDN (publish files); CDN to Browser (ready HTML); Render server to Browser (HTML per request); JavaScript bundle to Browser (JS builds page)Build step: runs on deployBuild stepruns on deployCDN: stored HTML filesCDNstored HTML filesRender server: runs per requestRender serverruns per requestJavaScript bundle: served as a fileJavaScriptbundleserved as a fileBrowserBrowserpublish filesready HTMLHTML per requestJS builds page

    How to read this: each arrow into the browser is one way a page arrives. Static generation and its regenerating variant use the top path, SSR and streaming the middle one, and client-side rendering the bottom one, where the browser itself builds the markup.

    Client-side rendering (CSR)

    With CSR the server sends a nearly empty HTML document and a script. The script runs in the browser, fetches data from an API and builds the page with the DOM. A single-page application (SPA) applies this to a whole product: after the first load, navigation also happens in the browser.

    <!doctype html>
    <html lang="en">
      <head>
        <title>Store</title>
        <script type="module" src="/assets/app.4f9c1e.js"></script>
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
    // app.4f9c1e.js (framework-free, to show the idea)
    const id = location.pathname.split("/").pop();
    const res = await fetch(`/api/products/${id}`);
    const product = await res.json();
    
    const title = document.createElement("h1");
    title.textContent = product.name;      // textContent, never innerHTML, for data you did not write
    document.getElementById("root").append(title);

    Until the fetch resolves, #root is empty and the page body shows nothing. The data request cannot start until the script has loaded and run, so the page is built from three requests in sequence.

    Figure 2. Request lifecycle of client-side rendering

    sequenceDiagram accTitle: Client-side rendering request lifecycle accDescr: The browser requests a product URL and the CDN returns a nearly empty HTML shell. The browser then downloads the JavaScript bundle, runs it, requests product data from the API, and only then builds and shows the product. The screen is empty until the data arrives. participant B as Browser participant C as CDN participant A as Product API B->>C: GET /products/42 C-->>B: HTML shell with empty root Note over B: Screen: empty shell B->>C: GET app.js C-->>B: JavaScript bundle Note over B: Runs the bundle, no data yet B->>A: GET /api/products/42 A-->>B: JSON Note over B: Builds the DOM, screen shows the product

    How to read this: follow the arrows top to bottom. The user sees content only after the last request returns, because each step needs the one before it.

    CSR has real strengths. The shell is the same for everyone, so a CDN can serve it from a nearby location (see CDN and object storage); hosting needs no render servers; personalisation is easy because the code runs next to the user's session. The react.dev guide to starting a React project says frameworks support CSR and SPAs deployable without a server, with server features added per route later.

    The costs: first paint waits for JavaScript, then for data, and growing JavaScript can hurt responsiveness (a metric called INP, interaction to next paint), as web.dev notes in Rendering on the Web. The initial HTML has no content for search or link previews. Google's JavaScript SEO basics say Googlebot queues pages for rendering in a headless Chromium and indexes the result, but also that not all bots can run JavaScript, and that in client-rendered apps meaningful HTTP status codes "can be impossible or impractical". A missing product can then show "not found" while answering 200, a soft 404.

    Server-side rendering (SSR)

    With SSR the server runs the component code, produces the full HTML for the requested URL and sends it. The browser can show content as soon as the HTML arrives, before any JavaScript has loaded.

    Figure 3. Request lifecycle of server-side rendering

    sequenceDiagram accTitle: Server-side rendering request lifecycle accDescr: The browser requests a product URL. The render server queries the database, renders the full HTML and returns it. The browser shows the content immediately but it cannot respond to clicks yet. The browser then downloads the JavaScript bundle and hydrates the page, after which it is interactive. participant B as Browser participant S as Render server participant D as Database B->>S: GET /products/42 S->>D: query product D-->>S: rows Note over S: Renders components to HTML S-->>B: full HTML Note over B: Screen: product visible, not yet interactive B->>S: GET app.js S-->>B: JavaScript bundle Note over B: Hydrates, handlers attached, page interactive

    How to read this: the wait for the database happens before the first byte of HTML. The gap between "product visible" and "page interactive" is the hydration window.

    First paint no longer waits for JavaScript, but the first byte now waits for the server and its data; web.dev lists a higher time to first byte (TTFB) as a cost of SSR. Every request does render work, so servers must be sized for traffic, and if the server is down, no page loads. The back-end side is covered in request lifecycle.

    There is a second cost. web.dev describes server-rendered pages that "can appear to be loaded and interactive, but can't actually respond to input" until client scripts run. A shopper can tap "Add to cart" in that window and nothing happens.

    Hydration: what it is and what it costs

    Hydration is what a UI library does to turn server-produced HTML into a live app. react.dev describes hydrateRoot as attaching React to HTML already rendered by react-dom/server, taking over the DOM without recreating the markup. The browser must download the component code, run it to rebuild the component tree, and match it against the existing DOM. Three costs follow:

    1. JavaScript to download and run for every hydrated component, though the HTML already shows it.
    2. Main-thread time. That code runs on the thread that also handles taps and scrolls. web.dev states that rehydration can have "a significant negative impact on TBT and INP, even if it improves FCP". TBT is total blocking time, a lab measure of main-thread blocking; FCP is first contentful paint.
    3. Duplicated data. The client needs the data the server used. Next.js embeds a serialised copy of the component tree in the HTML stream, so information is sent twice: as markup and as data.

    What a hydration mismatch is

    React expects the tree it builds in the browser to produce the same output the server produced. If not, that is a hydration mismatch. react.dev says to treat mismatches as bugs, and lists common causes: typeof window !== 'undefined' checks in render logic, browser-only APIs such as window.matchMedia in render code, different data on server and client, and extra whitespace around the markup in the root node.

    // Server and browser may disagree: the server's clock and time zone are not the user's.
    function Greeting() {
      const hour = new Date().getHours();
      return <p>{hour < 12 ? "Good morning" : "Good afternoon"}</p>;
    }

    The server renders at its own local hour; the user's browser, in another time zone or minutes later, may take the other branch. react.dev says React recovers from some mismatches, but "in the best case, they'll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements". It adds that React will not try to patch mismatched text and that development builds warn. For client-only values, react.dev's advice is to render the same thing first and update after mount:

    import { useEffect, useState } from "react";
    
    function Greeting() {
      const [hour, setHour] = useState(null);
      useEffect(() => setHour(new Date().getHours()), []);   // effects run only in the browser
      if (hour === null) return <p>Hello</p>;                // identical on server and first client render
      return <p>{hour < 12 ? "Good morning" : "Good afternoon"}</p>;
    }

    The first pass matches, so hydration succeeds; then the effect updates the text. react.dev notes this two-pass approach slows hydration and can feel jarring, and offers suppressHydrationWarning for unavoidable differences (it silences the warning one level deep and does not fix the text).

    Hydrating less: selective, partial, islands and Server Components

    Because hydration is the cost, several ideas pay less of it. Their names are often confused.

    • Selective hydration (React 18 and later). With streamed HTML and <Suspense> boundaries, React can hydrate each boundary separately. The React 18 working group's architecture note describes the constraint it removed (all JavaScript loaded and all components hydrated before any interaction) and says a part the user interacts with is hydrated first.
    • Progressive and partial hydration. web.dev defines progressive rehydration as booting parts of an app over time, and partial rehydration as turning the JavaScript of mostly static parts into inert references.
    • Islands. A mostly static page with small interactive components hydrated independently. Astro's documentation defines a client island as an interactive component "hydrated separately from the rest of the page", with JavaScript shipped only for components that opt in.
    • React Server Components (RSC). react.dev's description: components that render ahead of time, before bundling, in a separate environment, either once at build time or per request. The client receives their output, not their code. Components needing state or event handlers are marked "use client". In Next.js, JavaScript hydrates those Client Components, so hydration work applies to them. RSC is stable in React 19, but the APIs for building a framework do not follow semver, so in practice you use it through one.

    Figure 4. A product page split into server output and an island

    Product page: server-rendered parts and one hydrated islandProduct page to Gallery, description (HTML); Product page to Reviews list (HTML); Product page to Add to cart button (props + JS)NO COMPONENT CODE SENT TO THE BROWSERHYDRATED IN THE BROWSERProduct page: rendered on serverProduct pagerendered onserverGallery, description: HTML onlyGallery,descriptionHTML onlyReviews list: streamed laterReviews liststreamed laterAdd to cart button: hydrated islandAdd to cartbuttonhydrated islandHTMLHTMLprops + JS

    How to read this: most of the page is output with no client behaviour and needs no hydration. Only the button, which has state and a click handler, ships code and is hydrated. This is a design sketch of the idea, not any one framework's exact output.

    Static site generation (SSG)

    With SSG the HTML for each URL is produced once, at build time, and stored as files. MDN notes that a static site's content is generated at build time, not request time, and can be served from a CDN. At request time there is no rendering at all.

    Figure 5. Request lifecycle of static generation

    sequenceDiagram accTitle: Static generation lifecycle accDescr: During deployment the build step reads all products from the data source, renders one HTML file per page, and publishes the files to the CDN. Later, a browser requests a product page and the CDN returns the stored file immediately. The browser then loads the JavaScript bundle and hydrates the page. participant X as Build step participant D as Data source participant C as CDN participant B as Browser X->>D: read all products D-->>X: product data Note over X: Renders one HTML file per URL X->>C: publish files Note over C: Files wait for visitors B->>C: GET /products/42 C-->>B: stored HTML file Note over B: Screen: product visible, not yet interactive B->>C: GET app.js C-->>B: JavaScript bundle Note over B: Hydrates, page interactive

    How to read this: the top half happens once, with no user waiting. The bottom half, repeated for every visitor, contains no server work.

    web.dev says static rendering gives a consistently fast TTFB. SSG also fails well: if the back end is down, files already on the CDN keep serving. The costs: build time grows with the number of pages, content is as old as the last build, and one file serves everyone, so the HTML cannot hold anything specific to the visitor. That is a good deal for the store's home page, and a poor one for tens of thousands of product pages whose prices change during the day.

    Incremental static regeneration (ISR)

    ISR keeps the benefits of static pages while letting them update after deploy. The term was popularised by Next.js, which announced stable support in version 9.5 (July 2020). That announcement describes re-rendering existing pages in the background as traffic comes in, inspired by stale-while-revalidate (RFC 5861): serve the saved copy now, refresh it in the background, use the new copy afterwards.

    The behaviour below is from the Next.js App Router documentation as read in early November 2026 (latest release then: 16.4), for ISR without the newer Cache Components option. New apps created with the recommended defaults already enable Cache Components, which has its own "ISR with Cache Components" guide, so check which model your app uses. Other frameworks differ in names and details.

    // app/products/[id]/page.js (Next.js App Router)
    export const revalidate = 3600; // seconds
    
    export async function generateStaticParams() {
      const products = await fetch("https://api.example.com/products").then((r) => r.json());
      return products.map((p) => ({ id: String(p.id) }));
    }
    
    export default async function Page({ params }) {
      const { id } = await params;
      const product = await fetch(`https://api.example.com/products/${id}`).then((r) => r.json());
      return (
        <main>
          <h1>{product.name}</h1>
          <p>{product.price}</p>
        </main>
      );
    }

    Per the docs, pages from generateStaticParams are prerendered at build time and requests are served from the cache. After the revalidation time passes, the next request still receives the stale page and triggers regeneration in the background; once it succeeds, later requests get the new page. A path not prerendered is generated on first request (see dynamicParams). If regeneration throws, the last good page keeps being served and the next request retries.

    Figure 6. Request lifecycle of incremental static regeneration

    sequenceDiagram accTitle: Incremental static regeneration lifecycle accDescr: A browser requests a category page and the cache returns the stored page. After the revalidation time has passed, the next request still gets the stale stored page immediately, and the server regenerates the page in the background by reading fresh data and storing the new page. A later request receives the new page. participant B as Browser participant C as Page cache participant R as Render server participant D as Data source B->>C: GET /category/shoes C-->>B: stored page, still fresh Note over C: Revalidation time passes B->>C: GET /category/shoes C-->>B: stored page, now stale, sent at once C->>R: regenerate in background R->>D: read current data D-->>R: data R->>C: store the new page B->>C: GET /category/shoes C-->>B: new page

    How to read this: the visitor who triggers regeneration does not wait; they get the old page. The price of that choice is that one visitor after expiry sees data older than the limit you set.

    On-demand revalidation shortens that gap: code that knows the data changed (a price edit, say) calls revalidatePath or revalidateTag, which marks the page stale so it regenerates on the next request. Caveats from the same page: ISR needs the Node.js runtime and is not supported in a static export; with several instances the default file-system cache is per instance, so on-demand revalidation only invalidates the instance that received the call unless you share a cache; and the x-nextjs-cache header reports HIT, STALE, MISS or REVALIDATED.

    At the HTTP level the same idea can be written for any CDN that supports it:

    Cache-Control: public, s-maxage=300, stale-while-revalidate=60

    This lets a shared cache keep the response fresh for 300 seconds and reuse it for up to 60 more while it fetches a new one (illustrative values; MDN documents the directives; support varies by cache, so check your CDN). See caching basics for TTLs and advanced caching for stampedes: the Next.js 9.5 announcement says at most one regeneration happens per revalidate window, to prevent one.

    Streaming server rendering

    Plain SSR sends nothing until the whole page is ready, so one slow query delays everything. Streaming sends the HTML in pieces. React's renderToPipeableStream (Node.js) defines the shell as everything outside any <Suspense> boundary. React sends the shell when ready, with a fallback (a skeleton, say) in place of each boundary still loading. When a boundary's data arrives, React sends its HTML plus a small inline script that swaps it in, and react.dev notes the swap does not wait for the page's JavaScript.

    import { Suspense } from "react";
    
    export default function ProductPage({ id }) {
      return (
        <>
          <ProductSummary id={id} />                 {/* in the shell: fast data */}
          <Suspense fallback={<ReviewsSkeleton />}>  {/* streamed when ready */}
            <Reviews productId={id} />
          </Suspense>
        </>
      );
    }

    Figure 7. Request lifecycle of streaming server rendering

    sequenceDiagram accTitle: Streaming server rendering lifecycle accDescr: The browser requests a product page. The render server reads the product from the product database and sends the first chunk, the shell, containing the product summary and a reviews placeholder. The browser paints it. The reviews service answers later, and the server sends a second chunk with the reviews HTML and a small script that replaces the placeholder. JavaScript downloads in parallel and each boundary hydrates. participant B as Browser participant S as Render server participant P as Product database participant R as Reviews service B->>S: GET /products/42 S->>P: read product P-->>S: product S-->>B: chunk 1: shell, product, reviews placeholder Note over B: Screen: product and skeleton S->>R: read reviews R-->>S: reviews S-->>B: chunk 2: reviews HTML and swap script Note over B: Screen: reviews replace skeleton Note over B: JavaScript loads, boundaries hydrate

    How to read this: the shell leaves as soon as the fast data is ready. The slow service delays only its own section.

    Streaming gives an earlier first paint than plain SSR, per-section loading states, and (per the Next.js streaming guide) independent hydration of boundaries. It also constrains you:

    • The status code is committed with the first chunk. Once streaming begins you cannot change the status code or headers. A notFound() that fires mid-stream cannot become a 404; Next.js adds a noindex meta tag instead. To return a real 404, check existence before the boundary.
    • Bots. react.dev suggests crawlers may be better served the complete document (onAllReady) than the shell, and Next.js holds back streaming for bots that need metadata in the initial HTML.
    • Layers can buffer. Proxies, CDNs, serverless platforms and compression can hold chunks back and turn a stream into one late response.
    • Layout shift and LCP. A skeleton of a different size moves the page when swapped, and an element inside a boundary cannot paint until its swap.

    Hybrid models

    Strategies can mix inside one URL. Two shapes matter.

    1. Static shell, dynamic holes. Next.js calls this Partial Prerendering. With its Cache Components option (cacheComponents: true, already set in new apps created with the recommended defaults, per the docs), a route is prerendered into a static shell that a CDN can serve, while parts that read cookies or fetch uncached data sit behind <Suspense> and stream in per request.
    2. Static page, client-side holes. Serve the same prerendered HTML to everyone and let a small client component fetch personal parts (cart count, "welcome back") after load. This works in any framework, at the cost of a visible update.

    Comparison

    Judgements are qualitative and describe usual behaviour, not measurements. "Time to interactive" means the moment the page reliably responds to input; lab tools have a formal TTI metric, but web.dev's rendering article focuses on TBT and INP instead.

    Concern CSR SSR SSG ISR Streaming SSR
    First byte Fast: static shell Waits for server and data Fast: stored file Fast; slower if generated on first request Early: shell first
    First paint After script and data After the HTML arrives After HTML and CSS Same as SSG After the shell; slow parts fill in
    Time to interactive After script runs and data is in After hydration After hydration After hydration Per-boundary hydration can shorten it
    Search and link previews No content in initial HTML Content in HTML Content in HTML Content in HTML Content in HTML; bots may get the whole page
    Personalisation Easiest Per request, defeats shared caching Client fetches only Same as SSG Personal parts behind boundaries
    Infrastructure Static hosting plus an API Render servers Build pipeline only Build, render runtime, page cache Render servers; no buffering
    CDN cacheability Shell high; API per its headers Only if identical for many Highest High, with refresh Low if personalised
    Failure behaviour Script error leaves empty shell Server down, no page Back end down, files serve Failed regeneration keeps last page Status already sent; errors handled in place

    How each strategy meets HTTP caches and CDNs

    Two kinds of response are involved: the HTML document and the files it points to (JavaScript, CSS, images). The rendering strategy mostly decides the policy of the first.

    • Hashed assets. Name each build file after its content (app.4f9c1e.js) and allow very long caching with immutable; the Next.js 9.5 announcement says it began setting such a header on hashed bundles, and the CDN lesson covers versioned URLs and cache headers. A new build means new names, so nothing needs purging.
    • CSR shell, SSG and ISR pages. The shell is identical for everyone, so cache it, but briefly or with revalidation, because it names the current bundle. Static and regenerated pages live in the CDN and change on deploy or by the ISR rules above.
    • SSR pages. Shared-cacheable only when the output is the same for many visitors. If it depends on the user, MDN advises private, or no-store for sensitive content, and warns that forgetting private on personalised content lets a shared cache reuse it for other users and leak personal information.
    • Streamed responses. They can pass through a cache, but any layer that buffers removes the benefit.

    HTTP Caching for the Front End covers document caching in depth. One rendering-specific hazard belongs here: version skew, which the Next.js glossary defines as clients still active after a deploy referencing JavaScript, CSS or data from an older build. A CDN may hold HTML naming old assets, or a user may hold an old page requesting files a new deploy removed. A common precaution is to keep previous build assets available for a period.

    Choosing per page type

    Figure 8. Choosing a strategy for a page

    flowchart TD accTitle: Choosing a rendering strategy for a page accDescr: First ask whether the page must be in the first HTML for search or link previews. If not, as for pages behind a login, use client-side rendering, or server rendering for a faster first paint. If yes, ask whether every visitor sees the same content. If not, use server rendering and stream the slow parts. If yes, ask whether it changes rarely and has few pages. If so use static generation, otherwise incremental static regeneration. Both static choices can add a personal part by streaming it or fetching it in the browser. Start(["A page type"]) Q1{"Must the first HTML contain the content?"} Q2{"Same content for every visitor?"} Q3{"Changes rarely and few pages?"} CSR["CSR, or SSR if first paint matters"] SSR["SSR, stream the slow parts"] SSG["SSG"] ISR["ISR with on-demand revalidation"] Part["Personal part? Stream it or fetch it in the browser"] Start --> Q1 Q1 -->|no| CSR Q1 -->|yes| Q2 Q2 -->|no| SSR Q2 -->|yes| Q3 Q3 -->|yes| SSG Q3 -->|no| ISR SSG --> Part ISR --> Part

    How to read this: start at the top and take the first answer that fits. "Must the first HTML contain the content" is about search, link previews and perceived speed, not taste.

    Applied to the store, as a starting point rather than a rule:

    Page Suggested strategy Reasoning
    Home page SSG, or ISR if merchandising changes daily Same for everyone, must be found. A greeting is a client or streamed hole.
    Category pages ISR with on-demand revalidation Many pages, shared, change when the catalogue changes. Filters can be client-side.
    Product detail ISR for the page; price and stock streamed or fetched separately Needs search and previews, but price and stock change fastest.
    Cart CSR, or SSR with no-store Per user, never indexed; the cart service is the truth.
    Account dashboard CSR behind login, or streaming SSR No search benefit; per user, so a shared cache cannot help.
    Checkout Dynamic and uncached (SSR or CSR) Correctness over speed. An ordinary server-rendered form can submit before hydration finishes.

    Mixing strategies in one product

    Strategies are chosen per route, and they coexist. react.dev says frameworks let you add server-side rendering per route when it makes sense.

    Figure 9. The store with a strategy per route

    The store: static and regenerated routes from a CDN, dynamic routes from a serverShopper to CDN (shared pages); Shopper to Render server (per-user pages); Build and regeneration to CDN (new pages); Render server to Cart and order APIs (no-store)ShopperShopperCDN: home, category, productCDNhome, category,productRender server: account, checkoutRender serveraccount, checkoutBuild and regeneration: reads catalogueBuild andregenerationreads catalogueCart and order APIsCart and orderAPIsshared pagesper-user pagesnew pagesno-store

    How to read this: shared pages travel the top path and are produced ahead of time or in the background. Per-user pages take the bottom path, produced per request. Dashed flow marks work off the request path.

    Three rules keep a mixed product coherent. Shared layout code must be safe in every mode: no reads of window or the clock while rendering. Personal fragments inside shared pages, such as the cart count in the header, are islands or streamed holes so the page itself stays shareable. And after the first load most frameworks navigate in the browser, so a shopper moves between a static category page and a dynamic account page without noticing.

    Which metric moves

    Metrics, thresholds and measurement belong to Loading Performance and Core Web Vitals. Here is only the direction each strategy tends to push them, drawn from web.dev's Rendering on the Web and the Next.js streaming guide. LCP is largest contentful paint, CLS is cumulative layout shift.

    Strategy Usually helps Usually hurts
    CSR Cheap hosting; fast shell First paint and LCP (script, then data); INP as bundles grow
    SSR FCP and LCP (content in the HTML) TTFB (server work first); TBT and INP (hydration)
    SSG and ISR TTFB, FCP, LCP Staleness; ISR: first visit to an ungenerated page
    Streaming TTFB and FCP against plain SSR; INP through split hydration LCP if the key element sits in a boundary; CLS if skeleton size differs

    The cost of choosing wrongly

    Wrong choice What goes wrong Why it is costly to fix
    CSR for product pages that must rank Empty initial HTML, late first paint Data fetching lives in browser code and must move to the server
    SSR for pages identical for everyone Server cost and TTFB on every view; an origin outage becomes a site outage Needs a caching layer and a review of what is truly per-user
    Shared caching of per-user content One user's data served to others A privacy incident, not a performance bug

    Moving data fetching between browser and server changes code structure, error handling and security assumptions, which is why the choice belongs early in a design.

    In practice: moving a store's product and category pages off client-side rendering

    NOTE

    This is a realistic scenario, not a case history. It is a composite example built from common production patterns, it does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A mid-sized online store sells about 40,000 products (illustrative). It is a React single-page application on a CDN, with a REST catalogue API and a separate cart and account API. Product pages rank poorly, link previews in chat apps show only the site name, and the largest contentful paint is late on mid-range phones. Prices change several times a day during promotions.

    Starting architecture

    Figure 10. Starting architecture: everything is built in the browser

    Starting architecture: a single-page app built entirely in the browserShopper to CDN (shell, JS); Shopper to Catalogue API (JSON, every visit); Catalogue API to Database; Shopper to Cart and account API (after sign-in)Shopper: mid-range phoneShoppermid-range phoneCDN: shell and bundleCDNshell and bundleCatalogue APICatalogue APICart and account APICart andaccount APIDatabaseDatabaseshell, JSJSON, every visitafter sign-in

    How to read this: the CDN serves only an empty shell and the bundle. Every product view makes the browser call the catalogue API, so content appears last (the sequence in Figure 2).

    Options considered

    Option What it fixes What it costs Risk
    Keep CSR, shrink the bundle Some of the delay Little Initial HTML still empty
    SSR for every page Content in HTML Render servers sized for peak Origin outage hits every page
    SSG for all products Fastest delivery Long builds; stale prices Wrong prices until next build
    ISR for category and product pages; cart and account stay client-rendered Content in HTML, CDN-served, fresh enough Framework with ISR, shared page cache, invalidation path Stale page if invalidation is missed

    The decision

    Field Decision record
    Context Product and category pages must be found, previewed and painted early; prices change daily; cart and account are per user.
    Decision ISR (Next.js App Router) for product and category pages: one-hour revalidation plus on-demand revalidation on catalogue change. Price and stock come from a separate short-lived browser fetch. Cart and account stay client-rendered with no-store APIs.
    Consequences, good Content in the first HTML, working previews, cache-served pages, last good page kept if regeneration fails.
    Consequences, bad A shared page cache to operate, a new invalidation path, a visible price update after load.
    Revisit when Price staleness causes support contacts, or hydration cost shows up in INP field data.

    The page price is a hint and the cart is the truth: the cart re-reads price and stock from the catalogue service.

    Rollout and verification

    1. Compare before switching. Fetch sample product URLs from old and new builds and compare the fields in the HTML (title, price). Differences are bugs.
    2. Route by route. Switch category pages first, then product pages, behind a routing rule that can return one group to the old build.
    3. Verify cache behaviour. Check x-nextjs-cache and the CDN hit ratio, and confirm an edited price appears after the on-demand call.
    4. Verify outcomes and failure. Compare LCP and INP field data by route, indexed pages and link previews, and fail the catalogue API during a staging regeneration: the old page must keep serving.

    The 2 a.m. failure

    A promotion ends at midnight. At 02:00 support reports category pages still showing sale prices while the cart shows full prices.

    Figure 11. One invalidation call, several caches

    sequenceDiagram accTitle: Price revalidation reaching only one of several instances accDescr: The catalogue service calls the on-demand revalidation endpoint after the promotion ends. The load balancer sends that call to instance A, which invalidates its own page cache. Shoppers' requests are spread across instances A, B and C. Instances B and C still hold the old pages in their own caches and keep serving sale prices. participant K as Catalogue service participant L as Load balancer participant A as Instance A participant B as Instances B and C K->>L: revalidate /category/shoes L->>A: forwarded to one instance Note over A: Own cache invalidated Note over B: Own caches still hold the old page L->>B: shopper requests B-->>L: page with sale price

    How to read this: the invalidation is one request, and only the instance that receives it clears its cache.

    The cause is a documented ISR caveat: with several instances the default file-system cache is per instance, so on-demand revalidation only invalidates the instance that received the call. Detection: price mismatches between page and cart. First three checks: x-nextjs-cache on repeated requests (different instances answer differently), how many instances run, and whether a shared cache handler is configured. Mitigation: shorten the time-based revalidation for the affected routes, or redeploy to reset caches. Follow-ups: configure a shared cache handler as the Next.js documentation describes, alert on page versus cart price mismatches, and test invalidation against more than one instance.

    Architect's notes

    • Decide per page type, early, with one line each: strategy, freshness limit, personalisation. Changing later moves code between browser and server.
    • State the freshness limit as a business number. How old may a category price be? The answer chooses between ISR, streaming and SSR.
    • Treat displayed facts as a cache, and keep personal data out of shared caches by separating shell from per-user parts in the component tree, not by remembering headers.
    • Measure hydration, not just load. INP field data by route shows a page that paints early and stays unresponsive.
    • Defer exotic options. Partial prerendering, Server Components and islands are worth adopting when a measured problem calls for them.

    Common mistakes

    • Treating "SSR" as one thing. Plain, streamed, static and regenerated pages differ in first byte, caching and failure. Name the exact strategy.
    • Calling a page static while it reads cookies. Request-dependent data makes a page dynamic in Next.js, and a shared cache must not store it.
    • Hydration mismatches from time, randomness or window checks in render code. Render the same on both sides, then update after mount.
    • Putting the key element in a slow Suspense boundary. The largest content arrives last.
    • Forgetting that a crawler or preview fetcher is not a browser. If its output matters, inspect the initial HTML.

    Key takeaways

    • Every strategy answers two questions: where and when the HTML is produced.
    • CSR moves work to the browser and costs first paint and search visibility; it suits pages behind a login.
    • SSR sends content early but waits for the server first.
    • SSG and ISR turn pages into files: fastest and most resilient, with staleness as the price. ISR refreshes in the background using the stale-while-revalidate idea.
    • Streaming trades a complete page for an early shell, but commits the status code first and relies on layers not buffering.
    • Hydration is the shared cost, and a mismatch means the browser's first render differs from the server's HTML.
    • Selective hydration, islands and Server Components pay less hydration by hydrating less code.
    • Choose per page type, mix strategies in one product, keep personal data out of shared caches, and remember a displayed price is a cache: the step that charges must re-check.

    Practice exercises

    Exercise 1 - Starter (30 min): For each of the store's five page types, write the strategy you would choose and the maximum age of its data. Compare with the table in this lesson and note where you differ and why.

    Exercise 2 - Intermediate (1 hour): Fetch a client-rendered page and a server-rendered or static page with curl -s <url>. List what text a crawler would see in each, then open both with JavaScript disabled and describe what changes.

    Exercise 3 - Intermediate (2 hours): Build a small server-rendered page whose component prints new Date().toLocaleTimeString() and hydrate it. Reproduce the mismatch warning in development, then fix it with the render-the-same-then-update approach.

    Exercise 4 - Advanced (half day): In a framework that supports ISR, create a product page with revalidate set to 30 seconds (illustrative). Change the source data, request the page repeatedly and note when the new content appears. Add on-demand revalidation and repeat.

    In this course:

    Elsewhere on the site:

    Official documentation

    Enroll to keep reading

    Lesson 7 of 8 · Frontend System Design Foundations: How the Web Runs

    Decide where and when each page of a web app produces its HTML: compare CSR, SSR, SSG, ISR and streaming, and the hydration cost, caching and metrics of each.

    In the rest of this lesson

    • Static site generation (SSG)
    • Incremental static regeneration (ISR)
    • Streaming server rendering
    • Hybrid models
    • Comparison
    • How each strategy meets HTTP caches and CDNs

    Enrolling is free. It unlocks every lesson in Frontend System Design Foundations: How the Web Runs, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments