Performance · Lesson 05
Server-side vs client-side rendering
Where HTML is assembled — on the server before the browser sees it, or in the browser after JavaScript loads — determines who calls your API, when, and at what cost. Your rendering choice is an API design choice.
By the end you'll be able to
- Explain SSR, CSR, and SSG and when each is appropriate.
- Describe how each model calls the API differently and the CORS and waterfall implications.
- Identify API design choices that reduce cascading fetch waterfalls in CSR apps.
Three ways to produce a web page
All three approaches start with the same question: where does the HTML that the user's browser paints come from?
Picture a restaurant. Server-side rendering (SSR) is like the kitchen plating the entire meal before the waiter brings it out — the browser receives a ready-to-eat HTML page. Client-side rendering (CSR) is like bringing a hot plate of plain rice to the table and letting the diner add the toppings themselves from small dishes fetched one at a time — the browser receives a nearly empty HTML shell and then assembles the content in JavaScript. Static site generation (SSG) is like a caterer who plated every meal the night before from known recipes — the HTML was built at deploy time, and the server just serves a pre-made file.
Server-side rendering (SSR)
On each request, the server runs application code, calls the data layer (database, microservice, or API), assembles the HTML, and sends a fully formed page. The browser paints immediately on first byte. React's Next.js getServerSideProps, Ruby on Rails, Django, and classic PHP all work this way.
Client-side rendering (CSR)
The server sends a minimal HTML file whose <body> is nearly empty. The browser downloads a JavaScript bundle, executes it, and the JS then calls the API to fetch data and renders the DOM. The first meaningful paint is delayed until the JS bundle loads, parses, runs, and the API response arrives. React (without a framework), Vue, Angular single-page apps, and most dashboards use CSR.
Static site generation (SSG)
At build time (not request time), a build tool calls the API or data layer, assembles HTML files for every route, and uploads the resulting files to a CDN. Each request is served from edge cache — no server computation. The content is stale until the next deploy. Astro, Next.js's static export, Hugo, and Eleventy work this way.
The API impact: who calls the API and when?
SSR: the server calls the API
In SSR the network hop from the rendering server to the API is on the data-center network (microseconds to low milliseconds). There is no browser involved in the API call. This means:
- No CORS — CORS is a browser-only mechanism. The rendering server can call internal APIs that are never exposed to the public internet. (CORS is covered in sec-04.)
- Secrets stay server-side — the rendering server can hold API keys; the browser never sees them.
- One round trip for the user — the browser makes one request and receives a complete page; the API call is hidden inside that round trip.
- Server scales with page views — every user page load triggers a server-side API call, so the API must sustain the request rate of page views, not just the rate of interactive actions.
CSR: the browser calls the API
In CSR the browser makes the API request over the public internet. This means:
- CORS is required — the API must return
Access-Control-Allow-Originheaders for browser fetch to succeed. - No secrets in requests — anything the browser sends is visible to the user; use tokens issued per session, not embedded API keys.
- Waterfall risk — if component A fetches user data, then component B uses the user ID to fetch their orders, the requests are sequential: user fetch → order fetch → render. That is the CSR waterfall described in perf-03.
- Extra round trips — the user pays for the network latency of every API call from their device.
SSG: the build system calls the API
SSG calls the API at build time, not at request time. The content is baked into static files. Requests hit a CDN edge node — near-zero latency. The trade-off is staleness: if the API data changes, the site must be rebuilt and redeployed to reflect it.
Worked example: the same page in SSR vs CSR
A user profile page needs the user record and their recent activity. Here is how each model fetches the data.
## SSR (Next.js-style pseudo-code)
# 1 — browser requests /profile/42
GET /profile/42 → SSR server
# 2 — SSR server fires two parallel API calls on the internal network
GET http://user-svc/users/42 # ~3 ms — internal
GET http://activity-svc/activity?user=42 # ~5 ms — parallel
# 3 — server assembles full HTML and returns it
HTTP/1.1 200 OK
Content-Type: text/html
# → browser paints immediately, no JS needed for initial content
---
## CSR (React SPA pseudo-code)
# 1 — browser gets an empty shell
GET /profile/42 → CDN serves index.html (empty div)
# → browser sees a spinner while JS loads (~300 ms bundle)
# 2 — JS mounts and fetches user (must cross public internet + CORS check)
GET https://api.example.com/users/42
Authorization: Bearer TOKEN
# ← 200 { "id": 42, "name": "Marcus" } (~120 ms on 4G)
# 3 — component B reads user.id and NOW fires the activity call (waterfall!)
GET https://api.example.com/activity?user=42
# ← 200 [...] (~110 ms)
# total user-perceived delay: bundle + call-1 + call-2 ≈ 530 ms before paint
A CSR app where component A fetches user data, then component B reads the user ID from context and fires its own fetch. These calls become sequential — B cannot start until A finishes. On mobile networks this chain of dependent calls adds hundreds of milliseconds per level. The fix: either lift all fetches to the page level and fire them in parallel (Promise.all), or introduce an aggregating API endpoint that returns everything the page needs in one call (an API-level BFF response).
How rendering choice shapes API design
SSR favours aggregated, server-optimised endpoints
Because the rendering server makes API calls on the fast internal network, you can afford more granular microservice calls and aggregate them on the BFF or rendering server. The browser never sees the extra round trips. The API endpoints do not need to be CORS-enabled for this flow.
CSR favours wide, browser-optimised endpoints
Each browser API call is expensive (public internet, TLS overhead, CORS preflight). A CSR page should aim for a small number of calls that return everything the page needs. This often means designing composite endpoints — or using GraphQL, which lets the client declare exactly what it needs in one request.
SSG favours cacheable, infrequently changing data
If your API data changes hourly or daily, SSG is a great fit. If it changes per-user or per-request, SSG does not apply — you fall back to SSR or CSR for personalised sections (sometimes combined: SSG for the shell, CSR for personalised widgets — called "islands architecture").
When asked about SSR vs CSR trade-offs, senior interviewers want to hear the API implications, not just "SSR is better for SEO." The full answer: SSR hides API calls on the server (no CORS, secrets safe, one user-facing round trip) but increases server cost per page view; CSR exposes API calls to the browser (CORS required, waterfall risk) but offloads rendering to the client. Rendering choice drives API aggregation strategy: CSR apps need fewer, wider endpoints; SSR apps can use smaller, composable ones stitched server-side.
If you are building a CSR dashboard, identify all the data a page needs and fire those fetches in parallel from a top-level component, not from nested child components that each trigger their own fetch after mounting. Alternatively, design a dedicated page-load endpoint (e.g., GET /v1/dashboard-init) that returns a composite response for the page in one call — trading endpoint proliferation for waterfall elimination.
Under the hood: the loading timeline, step by step
The key metric that separates SSR from CSR is First Contentful Paint (FCP) — when the user first sees real content rather than a blank screen or spinner. The difference is not just architectural preference: it is the number of sequential round trips the user must wait through before paint occurs.
SSR timeline
Every step below is on the critical path to the user seeing content:
t=0 Browser sends GET /profile/42
↓ network latency (e.g. 80 ms)
t=80 SSR server receives request
↓ server calls internal APIs in PARALLEL (fast datacenter net)
├── GET user-svc/users/42 ~3 ms ─┐
└── GET activity-svc/... ~5 ms ─┘ both finish ~5 ms
t=85 SSR server assembles HTML, starts streaming response
↓ network latency (80 ms return trip)
t=165 Browser receives first bytes of HTML → FCP ✓
(browser starts painting immediately; no JS required for initial content)
↓ JS bundle downloads and hydrates (~300 ms, background)
t=465 Page is fully interactive (TTI)
FCP: ~165 ms TTI: ~465 ms
Notice: the API calls are hidden inside the server's processing time and run on a low-latency internal network. The user waits through exactly one network round trip.
CSR timeline — and why the waterfall forms
CSR introduces extra sequential round trips on the public internet, each adding user-perceived latency:
t=0 Browser sends GET /profile/42
↓ network latency (80 ms)
t=80 CDN returns near-empty index.html (body: <div id="root"></div>)
↓ 80 ms return
t=160 Browser receives empty shell → FCP = blank/spinner ← user sees nothing yet
↓ browser requests JS bundle (another network round trip)
t=240 JS bundle arrives (say 300 ms download on 4G; overlap with above)
t=460 JS parses and executes, component mounts, fires first API call
↓ GET /api/users/42 across public internet (80 ms RTT)
t=540 User data arrives. Component B mounts, reads user.id, fires SECOND call
↓ GET /api/orders?user=42 (another 80 ms RTT) ← WATERFALL STEP
t=620 Orders data arrives. Component renders. FCP ✓ (real content)
FCP: ~620 ms vs SSR's ~165 ms — 455 ms slower for a two-level waterfall
Each additional sequential fetch adds another ~80 ms RTT (on this 80 ms network).
The critical insight: the user waited through three sequential network round trips (shell + bundle, call-1, call-2) before seeing any content. SSR needed only one. The bundle download and API calls are on the user's device network — not the fast datacenter link — so each hop is expensive.
Where the extra round trips come from in CSR
Each extra round trip in CSR is caused by one of these patterns:
- Bundle download. The browser cannot start any API call until it has downloaded, parsed, and executed the JS bundle. On a slow connection or with a large bundle, this alone can be 300–800 ms.
- Data-dependent fetches (waterfall). Component B reads a value from component A's response before firing its own fetch. The calls are serialised regardless of whether the server could serve them independently.
- CORS preflight. Non-simple cross-origin requests (Authorization header, JSON content-type, non-GET/POST) trigger an OPTIONS preflight before the real call. On a 80 ms network, the preflight costs another ~80 ms per unique endpoint hit — though
Access-Control-Max-Agecaches the preflight result.
How to debug & inspect it
Three browser DevTools metrics expose the SSR vs CSR timing story. Open DevTools → Network tab, hard-reload (Ctrl+Shift+R), and look at the waterfall.
Measuring TTFB and FCP
Spotting a client-side API waterfall in the Network tab
In the DevTools Network waterfall, a CSR waterfall is unmistakable: each API call's bar starts only after the previous one finishes. Look for a "staircase" pattern where fetch-2 is horizontally displaced (starts later) from fetch-1 by exactly the fetch-1 duration.
Confirming SSR is working correctly
| Symptom | Likely cause | Fix |
|---|---|---|
| High FCP despite low TTFB | CSR app: bundle download + API calls dominate | Move data fetching to server (SSR or BFF); or pre-fetch in parallel from page top |
| TTFB > 1s for SSR | Server's API calls are slow or sequential | Fire independent API calls in parallel on the SSR server; add caching |
| Network tab shows staircase fetches | Data-dependent sequential calls (waterfall) | Lift fetches to page level + Promise.all; or aggregate into one composite endpoint |
| API calls show OPTIONS preflight before each request | No Access-Control-Max-Age set; every cross-origin request preflights | Add Access-Control-Max-Age: 86400 to CORS preflight response |
| SSR page shows content then "flickers" on hydration | Client-side render output differs from server HTML (hydration mismatch) | Ensure data used to render server-side matches what client would use; avoid Date.now() in render |
| Blank white screen for 1–3 seconds on CSR | Large JS bundle blocking FCP | Code-split; lazy-load non-critical routes; consider SSR or SSG for initial shell |
Debug checklist:
- Open DevTools → Network. Hard-reload. Check the TTFB of the HTML document — SSR TTFB includes server processing; CSR TTFB is near-zero (static file).
- Check FCP in the Performance tab or Lighthouse. If FCP is much larger than TTFB, the gap is filled by bundle download + client-side API calls.
- Look for the staircase in the Network waterfall: if fetch-B starts only after fetch-A finishes, you have a waterfall. Confirm by checking if B actually needs data from A's response.
- Count CORS preflights: if every API call is preceded by an OPTIONS request, add
Access-Control-Max-Ageto cache the preflight result. - Run
curl … | grep -c "<h1\|<p"on the URL — zero matches means CSR; real content means SSR or SSG.
🧠 Quick check
1. A CSR app makes an API call from the browser. Which of the following is true that would NOT be true for the same call made from an SSR server?
CORS is enforced exclusively by browsers. A server making an HTTP call to another server has no same-origin policy — the CORS headers on the response are irrelevant to it. Only browser-initiated calls trigger CORS checks.
2. What causes a CSR "waterfall" of API calls?
A waterfall is caused by data dependency: component B reads a user ID from component A's result and only then issues its fetch. Modern browsers allow many concurrent connections to the same domain — the bottleneck is the sequential logic, not the browser.
3. For which kind of page is SSG the worst fit?
SSG bakes HTML at build time — the content is identical for all users and static until a rebuild. A personalised inbox that is unique per user and updates in real time cannot be pre-baked; it requires SSR or CSR.
4. An SSR page needs data from three microservices. How should the SSR server fetch them?
Firing three independent calls in parallel makes total latency ≈ max(t1, t2, t3). Sequential fetches make it ≈ t1 + t2 + t3. On internal networks even parallel calls take milliseconds, so parallelism is always the right default for independent data.
✍️ Exercise: spot and fix the waterfall
A CSR React app renders a user's order history page. The component tree looks like this:
// PageComponent mounts, fetches the current user
useEffect(() => {
fetch('/api/me').then(r => r.json()).then(setUser)
}, [])
// OrdersComponent: only renders after user is set; then fetches orders
useEffect(() => {
if (!user) return
fetch(`/api/orders?user=${user.id}`).then(r => r.json()).then(setOrders)
}, [user])
// ItemsComponent: fetches item details after orders arrive
useEffect(() => {
if (!orders) return
fetch(`/api/items?ids=${orders.map(o => o.item_id).join(',')}`)
.then(r => r.json()).then(setItems)
}, [orders])
Describe the waterfall, estimate the extra latency on a 100 ms round-trip network, and propose two different fixes.
Model answer:
There are three sequential calls: /api/me → /api/orders → /api/items. Each waits for the previous to complete. On a 100 ms round-trip network this costs at least 300 ms before the user sees any order data — and that is before rendering time.
Fix 1 — parallel fetches at the page level: if the user ID is already in a session token or cookie, skip /api/me and derive the user ID client-side. Fire /api/orders and /api/items together using the session-decoded user ID. Latency: ~100 ms (one level).
Fix 2 — composite API endpoint: add GET /api/order-page-init that the server assembles in one call: it fetches user, orders, and items in parallel internally (fast network) and returns a single JSON object. The browser makes one call (~100 ms) and receives everything it needs.
Rubric: ✓ correctly identifies three levels of waterfall ✓ estimates total latency correctly ✓ proposes at least one structural fix ✓ explains why it is faster — all four = strong answer.
Key takeaways
- SSR assembles HTML on the server: one user round trip, API calls on the fast server network, no CORS, secrets stay server-side.
- CSR assembles HTML in the browser: the browser makes API calls over the public internet, requiring CORS headers and risking sequential waterfalls.
- SSG pre-builds HTML at deploy time: zero runtime server cost, but content is stale until the next build.
- CSR waterfalls form when call B depends on call A's data — fix by parallelising independent fetches or introducing a composite endpoint.
- CSR apps need fewer, wider API endpoints; SSR apps can use more granular ones composed server-side.