cf.completefrontend
My progress
FUNDAMENTALS · MODULE 7

Client-side rendering

The server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.

The problem: a full page load for every click

Picture an analytics dashboard in the old style. Every screen is a separate HTML page that the server builds. The user opens a report, changes the date range, sorts a table, then opens a second report. That is four clicks, and each one throws away the whole page and asks the server for a new one.

Each of those page loads repeats the same work. The browser clears the screen, downloads a new document, reads the CSS again, runs the scripts again, and rebuilds the header, the sidebar, and the filters that did not change. The chart the user was looking at disappears and comes back. Anything they typed into a filter box is lost unless the server put it back.

For a news article you read once, that is fine. For a tool you keep open all day and click hundreds of times, it feels slow and jumpy. Teams wanted web apps that behave like desktop apps: the frame stays put, only the part that changed updates, and the next screen appears in a blink.

The idea: send a program, not a page

Client-side rendering (CSR) answers this by moving the work of building pages from the server into the browser. The server sends one small HTML file that is almost empty, plus a JavaScript program. The program runs in the browser, asks an API for data as JSON, and builds the page in the DOM (the Document Object Model, the live tree of elements the browser draws).

From then on, the program stays loaded. When the user clicks to another screen, the program changes the URL, fetches only the data that screen needs, and redraws only the part of the page that changed. The browser never loads a new document. An app built this way is called a single-page application (SPA), because the browser only ever loads one HTML page.

Gmail made this style famous in 2004, using hand-written JavaScript and background requests. Frameworks such as Backbone and AngularJS made it common around 2010, and React, Vue, and Angular made it the default way to build a web app for most of the 2010s. Today a React app built with Vite and no server rendering is still pure CSR.

Two timelines of the same 8-second visit with three clicks, at 3, 5, and 7 seconds. The multi-page site shows content after 1 second, then blanks the screen for 0.8 seconds on every click. The single-page app stays blank for 2.2 seconds at the start, then each click only updates part of the page in 0.2 seconds while the frame stays on screen.blank screenpage on screenpart updatesclickclickclickmulti-pageSPA0s2s4s6s8s
The trade in one picture. The single-page app pays more up front (2.2 s blank against 1 s), then every click is a small update instead of a blank screen and a new page.

The trade you are making

CSR swaps one cost for another. You pay more at the very start, because the browser must download and run the program before it can show anything. In return, every click after that is cheap, because the program and the frame are already there.

  • What you gain: fast screen changes, state that survives navigation (open panels, scroll positions, half-filled forms), simple hosting as plain static files, and a clean split between a frontend and a JSON API.
  • What you pay: a slow first visit, a blank screen until the JavaScript runs, more work for slow phones, and HTML that is empty to anything that does not run JavaScript, such as many crawlers and the link-preview bots of chat and social apps.

This lesson is about knowing that trade well enough to make it on purpose. By the end you will know exactly what the browser does on a CSR load, where the time goes, how client routing works, what search engines really see, how to make CSR fast, and when it is the right choice. Later lessons in this module cover the other side: server-side rendering, static generation, streaming, islands, and React Server Components.

Check your understanding · Exercise 1

A user opens a client-rendered dashboard for the first time. What does the server's first HTML response contain? Select all that apply.

The mental model: the HTML is a launcher

Think of a flat-pack furniture store. It does not deliver a finished table; it ships a box of parts and instructions, and you build the table at home. Shipping is cheap and the same box suits every customer.

But you cannot put a plate on the table until it is built, and a slow builder takes much longer than a fast one. Once it is built, moving it around the room is easy.

A CSR app works the same way. The server ships the box (HTML, CSS, JavaScript) and the browser does the building. A cheap phone is the slow builder. And once the app is built, moving between screens is quick, because nothing has to be shipped again.

The whole HTML of a CSR app

Here is the complete index.html that a Vite build of a React app produces, give or take a few tags. It is about 400 bytes. Read it closely: there is no text a user could read, no heading, no data. It is a launcher.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Acme Dashboard</title>
    <script type="module" crossorigin src="/assets/index-8c2d7e1a.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-3f9a1c4b.css" />
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
The built index.html of a typical Vite app. File names contain a hash of their content, and module scripts are deferred: they run after the document is parsed.

Three details matter. The title is the same for every URL, because this is the same file for every URL. The script has type="module", which makes it deferred: the browser downloads it in parallel with parsing and runs it after the document is parsed, so it does not block the HTML. And the root div is empty, so the first paint the browser can make is a blank page with the background color from the CSS.

The entry point that fills the root

The bundle starts with a few lines like these. They find the empty div, hand it to the framework, and tell the framework what to render inside it. Everything the user will ever see in this tab is created from here.

tsx
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import { router } from "./routes";
import "./index.css";

const container = document.getElementById("root")!;
createRoot(container).render(<RouterProvider router={router} />);
The entry point of a client-rendered React app. createRoot takes over the empty element; the router decides which screen to render for the current URL.

Three layers, three jobs

It helps to see a CSR app as three layers that never mix. At the bottom, a static host or CDN serves files: the same index.html for every app URL, and hashed JS and CSS files. In the middle, the app runs in the tab: the framework, the router, the components, and a cache of server data. At the top, a separate API returns JSON and knows nothing about pages.

This split is a big reason teams like CSR. The frontend deploys as a folder of files, with no server code to run or scale. The API serves the web app, the mobile apps, and partners in the same JSON. And each team can ship on its own schedule.

Three zones side by side. A CDN or static host holds index.html, the same for every URL, and hashed files such as index-8c2d.js, cached for a year. The browser tab runs the router, the components that build the DOM, and a cache of server data. The API returns only JSON and talks to the database. Arrows show files going from the CDN to the browser once, and data requests going from the browser to the API on every screen.CDN / static hostindex.htmlsame for every URLindex-8c2d.jscached for a yearchunksone per routeplain filesBrowser tabRouterURL to screenComponentsbuild the DOMData cacheJSON from the APIrenders the pageAPIAPI serverauth, dataDBno HTML at all
Three layers with separate jobs: the host serves plain files, the browser does all the rendering, and the API only returns data. The frontend deploys as a folder of files; the same API can serve the mobile apps too.

Check your understanding · Exercise 2

A CSR app serves the same index.html for /, /reports/42, and /settings. What follows from that? Select all that apply.

How a CSR page loads, step by step

Let us follow one first visit with concrete numbers. A user taps a link to app.example.com/reports/42 on a mid-range Android phone over a slow 4G connection. We will use the settings of Lighthouse's mobile "Slow 4G" throttling: a 150 ms round trip, about 1.6 Mbps of download speed (roughly 200 KB per second), and a CPU slowed down four times. The timings below come from a simple model built on those settings, not from a real trace, but their shape matches what you see in one.

The app is a typical React dashboard: a 400-byte index.html, a 20 KB stylesheet, and a 250 KB JavaScript bundle (compressed; about 900 KB once unzipped). The report data is a 30 KB JSON response from /api/reports/42 on the same origin.

The five steps

  1. Connect and fetch the HTML (about 600 ms). DNS lookup, a TCP connection, and a TLS 1.3 handshake cost about three round trips, then the request for /reports/42 costs one more. The static host does not know what a report is; it returns the same index.html as for any app path. The screen is still white.
  2. Fetch the CSS and JS (about 1,400 ms more, so 2,000 ms). The browser's parser finds the link and script tags and requests both files at once. The 270 KB take about 1.35 seconds at 200 KB per second. When the CSS arrives the browser can paint, but there is nothing to paint except a background color.
  3. Parse, compile, and run the bundle (about 900 ms, so 2,900 ms). The JavaScript engine parses 900 KB of code, compiles what it needs, and runs the entry point. That uses the rule of thumb from the estimation lesson: about 1 second of main-thread work per MB of unzipped JavaScript on a mid-range phone. React starts, the router reads /reports/42, and the report screen mounts. It has no data, so it renders a header and a spinner: this is the first contentful paint (FCP), the first moment any text or image appears.
  4. Fetch the data (about 400 ms, so 3,300 ms). Only now does code exist that knows which data to ask for. The component calls fetch("/api/reports/42"): one round trip, about 100 ms of server time, and 150 ms to download 30 KB.
  5. Render with data (about 100 ms, so 3,400 ms). React renders the table and the chart. The chart, the biggest element on screen, is painted: this is the largest contentful paint (LCP). Event handlers were attached during the same render, so the page responds to clicks right away.
A sequence diagram of a first visit on slow 4G. The browser asks the CDN for /reports/42 and gets a 400-byte index.html at 0.6 seconds. It asks for the JS and CSS and has 270 KB at 2.0 seconds. It runs the bundle until 2.9 seconds. Only then does it ask the API for /api/reports/42, gets 30 KB of JSON at 3.3 seconds, and shows content at 3.4 seconds.BrowserCDNAPIGET /reports/42index.html, 400 BGET index.js, index.css270 KBrun the bundleGET /api/reports/42JSON, 30 KBcontent0.6 s2.0 s2.9 s3.3 s3.4 s
Three requests in a row before the page is useful. The request to /api/reports/42 cannot start until the code that knows about it has downloaded and run.

Why the steps cannot overlap

Look at what each step needs. The browser cannot request the bundle until it has read the HTML that names it. It cannot run the bundle until the bundle has fully arrived. And the app cannot request the data until the code that knows the URL /api/reports/42 has run.

So the first visit pays at least three round trips in a row (HTML, then JS, then data) plus all the CPU time in between. This is called a request waterfall: each request waits for the one before it to finish.

The waterfall is the central weakness of CSR, and most of the speed tricks in chapter 6 either shorten one step or let two steps overlap. The critical rendering path lesson explains the browser side of this chain in more detail.

Step through the same visit below and watch what the user sees at each moment. Notice how long the screen stays empty compared with how quickly the last two steps go.

A first visit to a CSR dashboard on slow 4GStep 1 of 6: Request

A phone screen next to six milestones of a client-rendered page load on slow 4G: request sent at 0 seconds, empty HTML at 0.6, CSS and JS downloaded at 2.0, app mounted with a spinner at 2.9, data at 3.3, and the chart rendered at 3.4 seconds. The screen is white until 2.9 seconds./reports/42white screen0.0 s request sent0.6 s HTML: empty div2.0 s CSS and JS done2.9 s app mounts: FCP3.3 s JSON arrives3.4 s chart: LCP
The user taps a link to /reports/42. The phone looks up the name, opens a connection, and agrees on encryption: about three round trips of 150 ms before the request can go out.

Request: The user taps a link to /reports/42. The phone looks up the name, opens a connection, and agrees on encryption: about three round trips of 150 ms before the request can go out.

Visible and usable at the same moment

CSR has one quiet advantage over server rendering here. Because the same JavaScript that builds the page also attaches the event handlers, content appears and becomes usable at about the same time. With server rendering, users can see a button seconds before the JavaScript that makes it work has arrived, and taps in that gap do nothing. A CSR page never shows a button that does not work yet; it shows nothing instead.

Check your understanding · Exercise 3

In the example, why does the request for /api/reports/42 start only at about 2,900 ms? Select all that apply.

Time to first content: where the seconds go

The blank screen of CSR is not one problem but two that add up: waiting for bytes, and waiting for the CPU. To fix it you need to know which one dominates for your users, and the metrics tell you.

The metrics that see the blank screen

MetricWhat it measuresGood targetWhat CSR does to it
TTFBTime to the first byte of the HTML response0.8 s or lessUsually great: index.html is a tiny static file, often served from a CDN near the user.
FCPFirst Contentful Paint: when any text or image first appears1.8 s or lessLate: nothing can appear until the bundle has run, unless the HTML has a static shell.
LCPLargest Contentful Paint: when the biggest element in view appears2.5 s or lessLater still: the biggest element usually needs data, which is fetched only after the bundle runs.
INPInteraction to Next Paint: how fast the page reacts to taps and keys200 ms or lessMixed: route changes are quick, but a big bundle and heavy renders can block the main thread.
CLSCumulative Layout Shift: how much content jumps around0.1 or lessAt risk: spinners replaced by tables and images that load late push content down.

The targets are Google's "good" thresholds for the Core Web Vitals (LCP, INP, CLS) and its guidance for TTFB and FCP, measured at the 75th percentile of real visits. The pattern in the right-hand column is the story of CSR: the server answers fast, and then the user waits for their own device.

Bytes and CPU, the two halves

Bytes are the download time of the bundle. 250 KB compressed is about a quarter of a second on good 4G and more than a second on a slow connection. This half depends on the network, and HTTP caching can remove it for returning visitors.

CPU is the time to unzip, parse, compile, and run the code, then render the components. Caching barely helps here: a cached bundle still has to run on every load. And this half depends on the device. A budget Android phone can take four to eight times longer than a laptop for the same JavaScript, and those phones are a large share of the world's web traffic.

That is why, byte for byte, JavaScript is the most expensive thing you can send. A 250 KB image is decoded off the main thread and shown; 250 KB of JavaScript must be parsed and run on the main thread before the page can do anything.

First visits and repeat visits

A returning user who has the hashed bundle in their HTTP cache skips most of the download. The HTML is revalidated (one short round trip), the JS and CSS come from disk in a few milliseconds, and the browser may even reuse compiled code from V8's code cache. The remaining cost is running the app and fetching the data.

This is why CSR suits apps people open every day and hurts sites people visit once. For a daily dashboard, the expensive first load happens once per deploy. For a product page reached from a search result, the first load is the only load, and the user leaves if it is slow. Try both cases, and different devices, below.

Bundle (compressed)
Network
Device
Visit
A waterfall of a first visit with a 250 KB bundle on Slow 4G and a mid-range phone. Steps: connect 0.45 s, get HTML 0.15 s, download JS 1.4 s, run JS 0.90 s, fetch data 0.40 s, render 0.10 s. Content appears at 3.4 s.content at 3.4 sconnectget HTMLdownload JSrun JSfetch datarender0s1s2s3s4s
A rough model of one load. Each bar waits for the one above it, and the real content appears only when the last bar ends. Shaded bars are the device working; try a budget phone on Wi-Fi, then a repeat visit, and see which bars shrink.

Time to content: 3.4 s, of which 29% is the device running code and rendering.

Check your understanding · Exercise 4

A CSR dashboard shows content in 0.9 s on a developer's laptop and in 4 s on a budget phone, both on the same fast Wi-Fi. Which changes would shrink the gap? Select all that apply.

SPA routing: changing pages without loading pages

Once the app is running, it takes over navigation from the browser. A client router (React Router, TanStack Router, Vue Router, Angular's router) makes the URL bar, the Back button, and links behave as users expect, while never asking the server for a new HTML page. It is the part of CSR that makes the app feel fast, and the part with the most subtle bugs.

The History API in four moves

  1. Catch the click. The router listens for clicks on same-origin links and calls event.preventDefault(), so the browser does not start a normal page load.
  2. Change the URL. It calls history.pushState(state, "", "/reports/43"). The address bar updates and a new entry goes on the tab's history stack, but no request is made.
  3. Render the match. It matches the new path against its route table, loads the data that route needs, and renders the matching components in place of the old ones.
  4. Handle Back and Forward. When the user presses Back, the browser moves along the history stack and fires a popstate event. The router reads location.pathname and renders that route again.
ts
document.addEventListener("click", (event) => {
  const link = (event.target as Element).closest("a");
  if (!link || link.origin !== location.origin || link.target || link.hasAttribute("download")) return;
  // Let the browser handle new-tab, new-window, and download clicks.
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
  event.preventDefault();
  history.pushState(null, "", link.href);
  renderRoute(location.pathname);
});

window.addEventListener("popstate", () => renderRoute(location.pathname));
The heart of every client router. Real routers add matching, data loading, scroll handling, and much more, but these two listeners are the core.

Note what the router does not touch. Ctrl-click, Cmd-click, Shift-click, middle-click, download links, and links with a target fall through to the browser, so people can still open a report in a new tab. A router that breaks this, or a "link" that is really a div with a click handler, is a common and annoying bug. Use real a elements with real href values: browsers, screen readers, and crawlers all depend on them.

Routes form a tree

Modern routers do not map one URL to one component. They map URL segments to a tree of nested layouts. On /reports/42/sharing, the app layout renders the header and sidebar, the reports layout renders the report tabs, and the leaf renders the sharing panel. Moving to /reports/42/history swaps only the leaf; the parts above it stay mounted with their state.

A tree of nested routes. The root route renders the app layout with header and sidebar. Under it, reports renders the report tabs and settings renders the settings page. Under reports, the id route loads one report. Under that, two leaves: sharing and history. The path for /reports/42/sharing is highlighted from the root down to the sharing leaf./AppLayout: header, sidebarreportsReportsLayout: tabssettingsSettings page:idReport: loads datasharingleafhistoryleafURL: /reports/42/sharingnext click: only theleaf re-renders
The URL /reports/42/sharing matches one branch of the tree. Going to /reports/42/history swaps only the leaf; the layouts above it stay mounted, and their loaders can all start in parallel on a fresh load.

Nesting is also how good routers avoid request waterfalls inside the app. Each route in the matched branch can declare a loader, and the router starts all the loaders for the branch in parallel as soon as the URL changes, before rendering anything. Fetching inside each component's effect instead makes the child wait for the parent to render, then fetch, then render the child, then fetch again.

The server still has to cooperate

Client routing only works once the app is loaded. If the user refreshes on /reports/42, or opens it from a bookmark, the browser makes a real request for that path. The static host looks for a file called reports/42, finds nothing, and returns a 404, because only the client router knows that path exists.

The fix is a fallback (or rewrite) rule: serve index.html for any path that is not a real file. Every static host has one. Keep real 404s for missing assets, so an old /assets/index-1a2b.js does not come back as HTML with a 200.

text
# nginx: hashed assets must exist, or get a real 404
location /assets/ {
  try_files $uri =404;
}

# everything else: try the file, then the folder, then fall back to the app
location / {
  try_files $uri $uri/ /index.html;
}

# Netlify _redirects: same idea, status 200 means rewrite, not redirect.
# Files that exist are served first, so this only catches unknown paths.
/*    /index.html    200
Two common fallback rules. Without one, every deep link and every refresh outside the home page is a 404. The nginx version also keeps real 404s for missing files under /assets/.

What the browser used to do for free

A full page load gives you several behaviors without any code. A client router takes them away, so it has to put each one back.

  • Scroll position. A new page starts at the top; Back returns to where you were. A router must scroll to the top on new routes and restore the old position on Back. React Router's ScrollRestoration component does this.
  • The page title. Each page had its own title. The router must set document.title on every route change, or every tab and history entry says "Acme Dashboard".
  • Focus and announcements. After a page load, screen readers announce the new page and focus starts at the top. After a client route change, nothing is announced and focus stays on the link that was clicked, which may no longer exist. Move focus to the new page's main heading, or announce the change in a polite live region.
  • Loading feedback. The browser shows a spinner in the tab during a page load. A client navigation shows nothing unless the app adds a progress bar or keeps the old screen up until the new data is ready.
  • Errors. A failed page load shows the browser's error page. A failed route render can leave a white screen, so wrap routes in error boundaries that show a useful message.

Click around the small app below. Watch the history stack grow, and count how many HTML documents the browser actually loads compared with a classic multi-page site.

Site type
Click
Host fallback rule (SPA only)
A browser window at /reports showing All reports, next to the tab's history stack: /reports, with entry 1 current. 1 HTML documents and 1 JSON requests so far.app.example.com/reportsAll reportshistory stack/reports
The history stack is the tab's list of entries; Back moves the highlight down it. In the single-page app, links add entries without loading HTML. Turn the fallback rule off and press Refresh to see why static hosts need one.

HTML documents loaded: 1. JSON requests: 1. First load: index.html, the bundle, then one JSON request for the reports list.

Check your understanding · Exercise 5

You replace full page loads with a client router. Which of these must the app now handle itself? Select all that apply.

SEO, link previews, and other readers that skip JavaScript

Humans are not the only readers of your pages. Search engine crawlers, the bots that build link previews in chat apps, and AI crawlers all fetch your URLs too. Each of them decides for itself whether to run your JavaScript, and a CSR page is only as good as the HTML a reader sees. For most of them, that HTML is an empty div.

How Google handles a CSR page

Google does run JavaScript. Googlebot processes a page in three phases: it crawls (fetches the raw HTML), puts the page in a queue to be rendered by a headless, up-to-date Chromium, and then indexes the rendered result. Links it finds in the raw HTML can be crawled right away; links that only exist after rendering wait for the render.

Google's documentation says a page may sit in the render queue for a few seconds, but it can take longer. In practice CSR pages usually do get indexed. The risks are in the details: the render is a single load with no clicks or scrolling by a user, cookies and storage are not kept between page loads, and if the bundle throws an error, or a script or API it needs is blocked by robots.txt or too slow, Google indexes whatever was on screen, which may be a spinner.

Google's pipeline for a JavaScript page, left to right. Crawl fetches the raw HTML, which for a CSR page is an empty root div. The page waits in a render queue for seconds or longer. A headless Chromium renders it, running the JavaScript once with no user interaction. Then the rendered HTML is indexed, and links found only after rendering are queued for crawling.Crawlraw HTMLRender queueseconds or moreRenderheadless ChromeIndexrendered pageCSR raw HTML:an empty root divruns JS onceno clicks, no scrollfresh storagelinks foundgo back tothe crawl
Google does run your JavaScript, but in a second phase. If the render fails, times out, or needs a request that is blocked, what gets indexed is whatever was on screen.

Traps that are specific to CSR

  • Soft 404s. The fallback rule returns 200 with index.html for every path, including ones that do not exist. The app shows "not found", but the status says success. Google calls this a soft 404 and suggests either a JavaScript redirect to a URL that returns a real 404, or adding meta name="robots" content="noindex" from JavaScript on the error page.
  • Links that are not links. Googlebot follows a elements with an href. Navigation done with click handlers on buttons or divs gives it nothing to follow, so whole sections may never be found.
  • Content behind interaction. Text that appears only after a click, a hover, or an infinite scroll is not in the rendered page Google sees.
  • Fragment URLs. With hash routing, /#/pricing and /#/about are the same URL to a crawler. Google's guidance is to use the History API instead.

Readers that do not run JavaScript at all

Google is the generous reader. Many others fetch only the raw HTML. The bots behind link previews in Slack, WhatsApp, LinkedIn, Facebook, and X look for the title and Open Graph tags such as og:title and og:image in the HTML they receive, and do not run scripts. In a CSR app every URL returns the same index.html, so every shared link previews as the home page.

AI crawlers are similar. An analysis Vercel published in December 2024 found that none of the major AI crawlers it measured ran JavaScript, including OpenAI's GPTBot and Anthropic's ClaudeBot; some fetched JavaScript files but did not execute them. Google's Gemini, which uses Googlebot's infrastructure, was the exception. Other search engines vary in how much JavaScript they render.

The safe rule: if a page must be read by something other than a logged-in human, assume the reader sees only the raw HTML, and test with each tool you care about.

A matrix of four readers against three questions for a CSR page. Googlebot runs JavaScript, and sees the content and the per-route title, but later. Link preview bots and many AI crawlers do not run JavaScript and see neither. A signed-in user runs JavaScript and sees everything.runs JSsees contentsees route titleGooglebotyeslatelatePreview botsnononoMany AI botsnononoSigned-in useryesyesyes
What each reader gets from the same CSR URL. Only humans and Google see the real page, and Google sees it after a render queue. Everything else gets the empty launcher.

Decision Part of your CSR app must be public and findable. How do you give readers real HTML?

Prerender at build time

Render each public route to a static HTML file during the build, with its real content and tags.

The app still boots on top of it in the browser.

Pros

  • Real HTML for every reader, served from a CDN.
  • No server to run; pages are as fast as static files.

Cons

  • Only works for content known at build time.
  • Thousands of pages make builds slow; the SSG and ISR lesson covers the fixes.

Server-side render

Render each request to HTML on a server, then let the client take over.

Frameworks such as Next.js, Remix and React Router, Nuxt, and SvelteKit do this.

Pros

  • Real, fresh HTML for any URL, including personalized ones.
  • Faster first content for humans too.

Cons

  • A server to run, scale, and keep fast.
  • Hydration cost and new failure modes; see the SSR lesson.

Inject tags at the edge

Keep the app as CSR, but have an edge function rewrite title and Open Graph tags in index.html per route.

Pros

  • Cheap fix for link previews.
  • No change to the app itself.

Cons

  • The body is still empty, so it does not help content indexing.
  • One more moving part that must know every route's data.

Dynamic rendering

Detect bots by user agent and serve them a prerendered snapshot, while humans get the CSR app.

Pros

  • No change to the app for humans.

Cons

  • Google calls it a workaround, not a long-term solution.
  • Two versions of every page to keep in sync, and bot detection by user agent is fragile.

What we pick: Do not fight CSR's nature. Public pages (marketing, docs, product and article pages) should be prerendered or server-rendered, and the logged-in app can stay CSR. If only link previews matter, for example shareable report links inside a private app, injecting tags at the edge is enough.

Check your understanding · Exercise 6

Users share links to reports from your CSR app in Slack, and every preview shows "Acme Dashboard" with the home page image. Why, and what fixes it? Select all that apply.

Making a client-rendered app fast

You cannot remove the chain of HTML, then JavaScript, then data. You can make each link shorter, start some links earlier so they overlap, and give the user something to look at while they wait. Here are the techniques that matter most, roughly in order of payoff.

1. Paint a shell from the HTML

The HTML is the only thing that arrives early, so put something in it. An app shell is a static version of the frame: the header bar, the sidebar outline, and grey placeholder blocks where content will go, with the few lines of CSS they need inlined in a style tag. The browser can paint it soon after the HTML arrives, often a second or more before the bundle runs.

One catch: a stylesheet link in the head blocks the first paint until that file has downloaded. For the shell to paint early, keep that stylesheet small or load it in a way that does not block rendering.

The shell does not make the real content arrive sooner, but it changes how the wait feels: the user sees the app taking shape instead of a white page. It also cuts layout shift, as long as the placeholders have the same size as the real header and sidebar. When the app mounts, createRoot replaces the shell.

2. Ship only the code for this route

A dashboard with a chart library, a rich-text editor, and a date-picker does not need all three to show the login page. Route-based code splitting turns each route into its own chunk with a dynamic import(), so the first screen downloads and runs only its own code. Bundlers such as Vite and webpack create the chunks automatically from each import().

tsx
import { createBrowserRouter } from "react-router";

export const router = createBrowserRouter([
  {
    path: "/reports/:id",
    // The chart code is a separate chunk, fetched only for this route.
    lazy: () => import("./routes/report"),
    // Starts as soon as the URL matches, in parallel with the chunk.
    loader: ({ params }) => fetch(`/api/reports/${params.id}`).then((r) => r.json()),
  },
  { path: "/settings", lazy: () => import("./routes/settings") },
]);
A route that loads its code and its data at the same time. In React Router, a route's lazy function can also return the loader; here the loader is kept in the main bundle so the data request does not wait for the chunk.

Watch for a new waterfall when you split: entry chunk, then route chunk, then the chunks the route imports. Vite adds link rel="modulepreload" tags for the chunks the entry needs so they download in parallel, and good routers fetch a route's chunk and data together, as above.

3. Start the data request early

In the worked example, the data request started only after the bundle ran and the component mounted. Two changes pull it earlier. First, use route loaders (React Router, TanStack Router) or start the query when the route matches, not inside a component's effect. Second, for data every page needs, like the current user, preload it from the HTML with link rel="preload" href="/api/me" as="fetch" crossorigin, so the request runs in parallel with the bundle download.

Two load waterfalls for the same app on slow 4G and a mid-range phone. Before: one 250 KB bundle downloads until 2.0 seconds, runs until 2.9, then the data request runs until 3.3, and the chart renders at 3.4 seconds. After: the HTML paints a shell at 0.66 seconds, a 90 KB entry chunk downloads until 1.2 seconds and runs until about 1.5, then the route chunk and the data request download in parallel, and the chart renders at about 2.3 seconds.requestJS downloadrun JSpaintbefore: first paint 2.9 s, LCP 3.4 sHTML and JSdatamain threadafter: shell 0.66 s, LCP 2.3 sHTML and JSdatamain thread0s1s2s3s
Same app, same phone. A shell in the HTML, a smaller entry chunk, and a route loader that fetches data while the route's code downloads bring the first paint from 2.9 s to 0.66 s and the LCP from 3.4 s to about 2.3 s.

4. Cache hashed files forever, revalidate the HTML

Build tools put a hash of each file's content in its name, like index-8c2d7e1a.js. A changed file gets a new name, so the old name can be cached forever: Cache-Control: public, max-age=31536000, immutable. The HTML is the one file whose name never changes, so it gets Cache-Control: no-cache, which means "store it, but check with the server before each use".

A new deploy then reaches users on their next visit, and returning users load the bundle from disk. The HTTP caching lesson explains both headers.

5. Set a JavaScript budget and keep it

Bundles grow one reasonable dependency at a time. A date library here, an icon set there, and a year later the first screen ships 900 KB. Pick a budget for the entry chunk (for example 150 KB compressed), check it in CI with a size check, and look at a bundle analyzer before adding any large dependency. Prefer libraries that let the bundler drop the parts you do not import.

6. Make the next route instant

Once the first screen is up, use idle time for the next one. Many routers can prefetch a route's chunk and data when its link is hovered, focused, or scrolled into view, so the click finds everything ready. A service worker can also precache the app's files for repeat visits and offline use, which the service worker lessons cover.

Check your understanding · Exercise 7

You add a static app shell (header, sidebar, grey placeholders) to index.html. What changes? Select all that apply.

Failure modes in production

A server-rendered page that half-fails still shows its HTML. A client-rendered page that fails often shows nothing at all, because everything on screen depends on one program running correctly. These are the failures CSR teams meet most, how they show up, and how to fix them.

The white screen

Since React 16, an error thrown while rendering that no error boundary catches unmounts the whole tree. One bad value in one widget, such as report.owner.name when owner is null, and the entire app turns into a blank page. The same happens if the bundle fails to load or uses syntax an old browser does not understand: the root div simply stays empty.

  • Wrap each route, and risky widgets such as charts, in an error boundary that shows a message and a retry button, so one failure costs one panel instead of the whole app.
  • Put a fallback message in the HTML shell ("Loading. If this takes long, check your connection and reload.") that the app replaces when it mounts, plus a noscript message. Then a bundle that never runs still leaves the user something to read.
  • Report errors from the browser to a monitoring service, with source maps, so you see white screens your own testing missed.

Version skew after a deploy

A user opens the app at 9:00 and keeps the tab open. At 11:00 you deploy. The new build has new chunk names, and if the old files were deleted from the host, the open tab is now running old code that points at files that no longer exist.

At 11:05 the user clicks Settings, the router asks for settings-a1.js, and gets a 404 (or worse, index.html with a 200, which the browser refuses to run as a JavaScript module). The click does nothing, or the screen goes white.

On the left, a tab opened at 9:00 still runs build A, and at 11:05 its router asks for settings-a1.js. On the right, the CDN after an 11:00 deploy holds index.html for build B, main-b7.js, and settings-b2.js, while the old main-a3.js and settings-a1.js were deleted. The request finds nothing, so the click fails.tab opened 9:00runs build A11:05: clicks Settingssettings-a1.js?404: the click failsCDN after the 11:00 deployindex.html (build B)main-b7.jssettings-b2.jsmain-a3.js (deleted)settings-a1.js (deleted)
Version skew. Hashed names never collide, so the fix is simple: keep old files for a few days after each deploy, and reload on the next navigation when a chunk still fails to load.
  • Keep old assets for a while. Upload new hashed files next to the old ones and delete old ones days later, not at deploy time. Hashed names never collide, so this is safe.
  • Recover when it happens anyway. Vite fires a vite:preloadError event when a dynamic import fails; listening for it and reloading the page loads the new version. Do the reload at a navigation, not in the middle of someone typing.
  • Check the version on navigation. Some apps compare a version number from the API or a small version.json with their own, and turn the next route change into a full page load when they differ.

Other failures to plan for

SymptomCauseFix
Users still see last week's versionindex.html was cached with a long max-age, so it keeps pointing at old bundlesServe index.html with no-cache; only hashed files get long lifetimes
A refresh on any deep link shows a 404No fallback rule on the static hostRewrite unknown paths to index.html, keep real 404s for assets
A protected page flashes, then redirects to loginThe app renders before it knows whether the user is signed inRender a neutral loading state until the session check finishes
The tab gets slower over a long dayListeners, timers, and subscriptions not cleaned up when routes unmountClean up in effects; profile heap snapshots; see the memory leaks lesson
Data on screen is hours oldA tab left open never refetchesRefetch on window focus and on reconnect, as server-state libraries do
Clicks feel slow on cheap phonesLong tasks from big renders or third-party scripts block the main threadMeasure INP, split long tasks, and load third-party scripts late

Check your understanding · Exercise 8

After a deploy, some users report that clicking "Settings" does nothing until they reload. What is the most likely cause, and what helps? Select all that apply.

When CSR is the right call

Everything so far points to one rule. CSR is a good deal when the first load is paid rarely and the clicks after it are many, and when nobody but a signed-in human needs to read the page. That describes a large and important class of products.

Dashboards and tools behind a login

Analytics dashboards, admin panels, CRMs, email and chat clients, project trackers, and internal tools all fit CSR well, and for the same four reasons.

  1. SEO does not apply. Crawlers cannot sign in, so the pages are invisible to search whatever you do. The main cost of CSR disappears.
  2. Users return. People open these apps every day, so the bundle is usually in the HTTP cache and the slow first load happens about once per deploy.
  3. Sessions are long. A user may change screens a hundred times in a day. Each client navigation saves a full page load, and the state kept between screens (filters, open panels, drafts) is worth a lot.
  4. The HTML is personal anyway. A server-rendered dashboard must be rendered per user on every request, and a CDN cannot cache it. CSR serves one static index.html to everyone from the CDN and keeps all per-user work in the API, which you need in any case.

Apps that are mostly a work area

Some apps have little that HTML could show before the JavaScript runs. A design canvas, a code editor, a map, a spreadsheet, or a video editor is an empty work area until its engine starts. Server rendering would paint a frame around a blank canvas. CSR is the honest choice, and the effort goes into a fast-loading engine instead.

Apps that must work offline or ship inside a native wrapper (Electron, Tauri, Capacitor) are also client-rendered by nature: the files are on the device, and there is no server to render anything.

In real products

  • Gmail popularized the single-page app in 2004. Google turned off its old basic HTML view in January 2024; the JavaScript app is now the only web version.
  • Figma's editor is a canvas drawn by a rendering engine written in C++ and compiled to WebAssembly. The page around it is a client-rendered app; server HTML would have little to show.
  • Grafana, the open-source monitoring dashboard, ships a React single-page app that its Go server hands out as one HTML page for every route.
  • Linear keeps a copy of a team's data in the browser (in IndexedDB) and renders from it, so most screen changes do not wait for the network at all. That is CSR pushed to its strongest form.

Notice the common split. Most of these companies serve their marketing site, pricing, docs, and blog as static or server-rendered pages on the main domain, and the product as a client-rendered app, often on app. or behind /app. Public pages need search and fast first visits; the app needs fast clicks. Modern frameworks let you choose per route inside one codebase, which the choosing-a-strategy lesson covers.

A decision tree. First: must crawlers or link previews read the page? If yes, use static generation or server rendering. If no: is it behind a login, with returning users and long sessions? If yes, use CSR. If no: is it mostly a work area such as a canvas or editor? If yes, use CSR. If no, it is a mixed case: choose per route.Crawlers or previews?must non-JS readers see ityesSSG or SSRcovered in later lessonsnoBehind a login?users return, sessions are longyesCSRdashboards, admin, toolsnoMostly a work area?canvas, editor, mapyesCSRits engine needs JSnoMixed: choose per routesee choosing a rendering strategy
Ask who must read the page before asking how fast it must be. Most products end up with both answers: static or server-rendered public pages, and a client-rendered app behind the login.

Where CSR is a poor fit

  • Public content found by search or shared links: articles, docs, product pages, landing pages. They need real HTML for crawlers and previews.
  • First-visit pages: a user arriving from an ad or a search result pays the full cold load, and every extra second loses some of them.
  • Audiences on slow phones and networks, unless the first screen is kept very light.
  • Mostly static pages with a few interactive parts. Sending a whole app to show a blog post is the case islands architecture was made for.

Check your understanding · Exercise 9

For which of these is plain CSR a sensible default? Select all that apply.

Using CSR in a system design interview

Rendering comes up in almost every frontend system design interview, usually right after requirements, when you sketch the architecture. Interviewers are not looking for one right answer. They want to see that you pick a strategy for a reason tied to the requirements, and that you know what it costs.

When to bring it up

  • When you draw the architecture. Say where rendering happens before you draw boxes: "This is a dashboard behind a login, so I will render on the client and serve the app from a CDN."
  • When a non-functional requirement touches it. SEO, first-load speed, low-end devices, or offline use each push the choice one way. Name the link out loud.
  • When the problem has public and private parts. A shop, a social network, or a docs site with an editor usually wants different strategies per route. Saying so early is a strong signal.

What to draw

Keep the picture to three boxes and two arrows: a CDN serving index.html and hashed bundles, the app in the browser with its router and a client cache of server data, and the API returning JSON. Then draw the load as a short waterfall (HTML, JS, data) and mark where first content appears. That one sketch lets you discuss every trade-off in this lesson.

How deep to go at each level

LevelWhat a strong answer covers
JuniorWhat CSR is (empty HTML plus a bundle), that it fits apps behind a login, and that it is weak for SEO and first load.
Mid-levelThe HTML, JS, data waterfall and why it is serial; client routing with the History API and the fallback rule; route splitting, loaders, caching headers; what Google and preview bots really see.
SeniorChoosing per route and defending it with numbers (devices, bundle budget, LCP target); version skew and deploy strategy; error boundaries and monitoring; accessibility of route changes; when to move a route to SSR or SSG and what that costs.

Think first

The interviewer says: "Design the frontend for an analytics dashboard used by marketing teams. Some reports can be shared by public link." How do you talk about rendering?

Check your understanding · Exercise 10

You chose CSR for a logged-in project tracker. Which follow-up points make the answer stronger? Select all that apply.

Summary

  • In CSR the server sends a near-empty HTML launcher plus a JavaScript bundle; the browser fetches data as JSON and builds the page in the DOM.
  • The first load is a serial waterfall: HTML, then JavaScript, then data. Nothing useful appears until all three finish, and on cheap phones the CPU time to run the bundle often costs more than the download.
  • After the first load, a client router changes screens with pushState and popstate, so each click costs only its data. The app must restore what the browser did for free: titles, scroll, focus, loading feedback.
  • Static hosts need a fallback rule that serves index.html for app paths, or every refresh on a deep link is a 404.
  • Google renders JavaScript, later and less reliably than plain HTML; link-preview bots and many other crawlers do not run it at all. Public pages that must be found or shared need prerendered or server-rendered HTML.
  • Make CSR fast with an app shell, route-based code splitting, data requests that start in route loaders, immutable caching for hashed files with no-cache for index.html, and a JavaScript budget.
  • Plan for CSR's own failures: white screens without error boundaries, version skew after deploys, and client route guards mistaken for security.
  • CSR is the right default for apps behind a login with returning users and long sessions, such as dashboards, admin panels, and editors; pair it with static or server-rendered public pages.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

A product manager wants the marketing pages built inside the same client-rendered React app as the logged-in product. What do you say?

Question 2

Users on budget Android phones see a 4-second white screen on your CSR app. How do you find and fix the cause?

Question 3

After deploying an SPA to a static host, refreshing on /settings/billing shows a 404. Why, and how do you fix it?

Question 4

Does Google index client-rendered pages? Is SEO a solved problem for CSR?

Question 5

Why would you choose CSR over SSR for a logged-in analytics dashboard, given that SSR shows content sooner?

References

Read next