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.
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.
<!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>
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.
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} />);
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.
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
- 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/42costs one more. The static host does not know what a report is; it returns the sameindex.htmlas for any app path. The screen is still white. - Fetch the CSS and JS (about 1,400 ms more, so 2,000 ms). The browser's parser finds the
linkandscripttags 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. - 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. - 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. - 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.
/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
/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
| Metric | What it measures | Good target | What CSR does to it |
|---|---|---|---|
| TTFB | Time to the first byte of the HTML response | 0.8 s or less | Usually great: index.html is a tiny static file, often served from a CDN near the user. |
| FCP | First Contentful Paint: when any text or image first appears | 1.8 s or less | Late: nothing can appear until the bundle has run, unless the HTML has a static shell. |
| LCP | Largest Contentful Paint: when the biggest element in view appears | 2.5 s or less | Later still: the biggest element usually needs data, which is fetched only after the bundle runs. |
| INP | Interaction to Next Paint: how fast the page reacts to taps and keys | 200 ms or less | Mixed: route changes are quick, but a big bundle and heavy renders can block the main thread. |
| CLS | Cumulative Layout Shift: how much content jumps around | 0.1 or less | At 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.
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
- 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. - 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. - 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.
- Handle Back and Forward. When the user presses Back, the browser moves along the history stack and fires a
popstateevent. The router readslocation.pathnameand renders that route again.
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));
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.
/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.
# 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
/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
ScrollRestorationcomponent does this. - The page title. Each page had its own
title. The router must setdocument.titleon 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.
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.
Traps that are specific to CSR
- Soft 404s. The fallback rule returns
200withindex.htmlfor 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 real404, or addingmeta name="robots" content="noindex"from JavaScript on the error page. - Links that are not links. Googlebot follows
aelements with anhref. 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,
/#/pricingand/#/aboutare 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.
Decision Part of your CSR app must be public and findable. How do you give readers real HTML?
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().
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") },
]);
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.
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
noscriptmessage. 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.
- 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:preloadErrorevent 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.jsonwith their own, and turn the next route change into a full page load when they differ.
Other failures to plan for
| Symptom | Cause | Fix |
|---|---|---|
| Users still see last week's version | index.html was cached with a long max-age, so it keeps pointing at old bundles | Serve index.html with no-cache; only hashed files get long lifetimes |
| A refresh on any deep link shows a 404 | No fallback rule on the static host | Rewrite unknown paths to index.html, keep real 404s for assets |
| A protected page flashes, then redirects to login | The app renders before it knows whether the user is signed in | Render a neutral loading state until the session check finishes |
| The tab gets slower over a long day | Listeners, timers, and subscriptions not cleaned up when routes unmount | Clean up in effects; profile heap snapshots; see the memory leaks lesson |
| Data on screen is hours old | A tab left open never refetches | Refetch on window focus and on reconnect, as server-state libraries do |
| Clicks feel slow on cheap phones | Long tasks from big renders or third-party scripts block the main thread | Measure 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.
- SEO does not apply. Crawlers cannot sign in, so the pages are invisible to search whatever you do. The main cost of CSR disappears.
- 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.
- 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.
- 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.htmlto 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.
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
| Level | What a strong answer covers |
|---|---|
| Junior | What 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-level | The 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. |
| Senior | Choosing 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?
The logged-in dashboard is CSR: it is behind a login so SEO does not matter, users return daily so the bundle is cached, and sessions are long with many filter and route changes. I would serve it from a CDN, split it by route so the chart library loads only with report pages, start each report's data request in a route loader, and paint a shell from index.html.
Shared public report links are different. They need a correct title and image in chat previews, and a first-time viewer should see the numbers fast. I would render those routes on the server, or at least inject per-report Open Graph tags at the edge, making sure the preview leaks nothing private. The marketing site would be static.
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
pushStateandpopstate, 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.htmlfor 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-cacheforindex.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?
I would push back for the marketing pages. They are found through search and shared links, so they need real HTML, and they are often a visitor's first contact, so first-load speed matters most there. A CSR page shows nothing until the bundle downloads and runs, and every shared link would preview with the same title and image.
I would prerender the marketing pages (or server-render them if they change often) and keep the logged-in app as CSR. They can share a design system, components, and even a repository, while using a different rendering strategy per route.
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?
First I measure on a throttled or real mid-range phone: Lighthouse with mobile throttling and the Performance panel. I look at three things: how long the bundle takes to download, how long the main thread spends parsing and running it, and when the data request starts. Field data from real users tells me how many people are affected.
Then I fix the biggest parts: split the bundle by route and drop heavy libraries from the first screen, start the data request in a route loader instead of after mount, paint a shell from index.html so the screen is never white, and make sure hashed files are cached for a year so returning users skip the download. If the slow page is public, I consider rendering it on the server.
Question 3
After deploying an SPA to a static host, refreshing on /settings/billing shows a 404. Why, and how do you fix it?
On refresh the browser requests /settings/billing from the server. The static host looks for that file, finds nothing, and returns 404, because only the client router knows the path. I add a fallback rule that serves index.html for any path that is not a real file, while missing assets such as old /assets/*.js files still get a real 404.
Then the app itself must handle paths that do not exist by rendering a not-found screen, and, if the page is public, by telling crawlers with a noindex tag or a redirect to a URL that returns a real 404.
Question 4
Does Google index client-rendered pages? Is SEO a solved problem for CSR?
Google does render JavaScript with an up-to-date Chromium, so CSR pages usually get indexed. But rendering happens in a second phase after crawling, sometimes with a delay; it is a single load with no user interaction; and if the bundle errors or a needed request is blocked or slow, Google indexes an empty or half-built page. Soft 404s and links that are not real a elements are common CSR traps.
And Google is the best case. Link-preview bots and many other crawlers, including the major AI crawlers from OpenAI and Anthropic, read only the raw HTML. So for pages that must be found and shared, I would not rely on client rendering; I would prerender or server-render them.
Question 5
Why would you choose CSR over SSR for a logged-in analytics dashboard, given that SSR shows content sooner?
Because the reasons SSR wins mostly do not apply. Crawlers cannot see the dashboard, users open it daily with the bundle cached, and they make many navigations per session, which CSR makes fast. The HTML would be per-user, so SSR could not cache it at a CDN and every request would cost server rendering.
CSR lets me serve the app as static files from a CDN, keep all per-user work in the API the product needs anyway, and avoid hydration. I would still control the cold load with route splitting, loaders, and a shell, and I would measure LCP on a mid-range phone to check that the trade holds.
References
- Understand the JavaScript SEO basics (Google Search Central) How Googlebot crawls, queues, renders, and indexes JavaScript pages, with advice on soft 404s and the History API.
- Dynamic rendering as a workaround (Google Search Central) Why Google no longer recommends serving bots a separate prerendered page.
- Rendering on the Web (web.dev) The classic comparison of client, server, and static rendering and their costs.
- History API (MDN)
pushState,replaceState, andpopstate, the building blocks of client routing. - Web Vitals (web.dev) Definitions and thresholds for LCP, INP, and CLS.
- Building for production: load error handling (Vite) The
vite:preloadErrorevent for recovering from missing chunks after a deploy. - The rise of the AI crawler (Vercel) Measurements showing that major AI crawlers fetch but do not run JavaScript.
- The Open Graph protocol The meta tags link-preview bots read from your raw HTML.
Read next
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Static generation and incremental regenerationRender pages to HTML once, ahead of time, and serve the same files from a CDN to everyone; incremental regeneration then refreshes single pages after launch, so you get static speed without rebuilding the whole site for every edit.
- Single-page vs multi-page appsWhether each click loads a new document from the server or the app swaps views inside one page: a choice that shapes first load, navigation speed, memory over long sessions, the back button, and accessibility.
- Choosing a rendering strategyHow to pick the rendering strategy for each page by asking who sees it, how often it changes, how interactive it is, and whether strangers must find it, then mixing strategies route by route in one app instead of choosing one for everything.
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.