API Design

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.

⏱ 13 min Difficulty: core Prereq: perf-04-data-fetching-pagination.html

By the end you'll be able to

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:

CSR: the browser calls the API

In CSR the browser makes the API request over the public internet. This means:

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.

SSR Browser requests page SSR server calls API API server fast internal net full HTML data CSR Browser gets empty shell Browser JS fetch user Browser JS fetch orders (wait!) API public net + CORS call 1 call 2
In SSR the browser makes one round trip and receives a complete page; the API call is hidden on the server network. In CSR each JS fetch is a browser-initiated network call, and calls that depend on earlier results form a sequential waterfall.

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
⚠️ Common trap

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").

🎯 Interview angle

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.

✅ Do this, not that

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.

0 ms 165 ms ~465 ms ~620 ms SSR browser request + server + API + HTML FCP ✓ 165 ms JS hydration (background, non-blocking) CSR empty shell + JS bundle download JS runs → GET /api/users/42 GET /api/orders (waits for user.id!) render with data FCP ✓ ~620 ms +455 ms extra user wait (2-level waterfall on 80 ms network)
SSR: one user-visible round trip; FCP at ~165 ms. CSR: shell + two sequential API calls before real content; FCP at ~620 ms. Each waterfall level adds one full network RTT.

Where the extra round trips come from in CSR

Each extra round trip in CSR is caused by one of these patterns:

  1. 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.
  2. 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.
  3. 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-Age caches 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

# In Chrome DevTools Lighthouse (or web-vitals library): # TTFB (Time to First Byte) — measures server response speed # SSR: TTFB includes server processing + API calls, typically 100–400 ms # CSR: TTFB is near-zero (CDN serves static file) but FCP is much later # FCP (First Contentful Paint) — when user sees real content SSR: FCP ≈ TTFB + network RTT — often 200–500 ms total CSR: FCP ≈ bundle download + N × API RTT — 500–2000 ms on slow networks # Quick web-vitals check in the browser console: import('https://unpkg.com/web-vitals').then(({getFCP,getTTFB}) => { getTTFB(m => console.log('TTFB', m.value)); getFCP(m => console.log('FCP', m.value)); });

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.

# What a staircase looks like in the Network tab timeline: # # 0ms ──────────────────────────── 800ms # [index.html ] ← shell, 160 ms # [main.js bundle ] ← 300 ms download # [/api/me ] ← 120 ms, starts after bundle # [/api/orders] ← 110 ms, starts after /me # # Each indented step = one extra sequential RTT the user waits through. # If /api/orders started at the SAME time as /api/me → no waterfall.

Confirming SSR is working correctly

$ curl -s https://myapp.example.com/profile/42 | grep -c "<p\|<h1\|<li" 47 # 47 content tags → page is server-rendered; browser has real HTML on arrival. $ curl -s https://csr-app.example.com/profile/42 | grep -c "<p\|<h1\|<li" 0 # 0 content tags → CSR shell; browser must run JS before any content appears.
SymptomLikely causeFix
High FCP despite low TTFBCSR app: bundle download + API calls dominateMove data fetching to server (SSR or BFF); or pre-fetch in parallel from page top
TTFB > 1s for SSRServer's API calls are slow or sequentialFire independent API calls in parallel on the SSR server; add caching
Network tab shows staircase fetchesData-dependent sequential calls (waterfall)Lift fetches to page level + Promise.all; or aggregate into one composite endpoint
API calls show OPTIONS preflight before each requestNo Access-Control-Max-Age set; every cross-origin request preflightsAdd Access-Control-Max-Age: 86400 to CORS preflight response
SSR page shows content then "flickers" on hydrationClient-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 CSRLarge JS bundle blocking FCPCode-split; lazy-load non-critical routes; consider SSR or SSG for initial shell

Debug checklist:

  1. Open DevTools → Network. Hard-reload. Check the TTFB of the HTML document — SSR TTFB includes server processing; CSR TTFB is near-zero (static file).
  2. 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.
  3. 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.
  4. Count CORS preflights: if every API call is preceded by an OPTIONS request, add Access-Control-Max-Age to cache the preflight result.
  5. 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

Sources & further reading