---
title: 'Server-Side Rendering'
source: 'https://academia.sh/en/courses/rendering-strategies/server-side-rendering'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:07+00:00'
license: 'CC BY-SA 4.0'
---

# Server-Side Rendering

Producing markup while answering a request; how time to first byte grows, the serial request chain collapsing to a single round trip, the round trip's weight, and the computation cost the server pays per request.

The previous lesson showed that client-side rendering needs three serial requests up to
first content: document, script, data. Two of these three requests arise from carrying
data and markup separately. Since the server can already read the data, it can process
the template itself and send the content ready-made.

This lesson makes that change and tests the result with the same measures: time to first
byte, body size, and whether content is present in the document.

## The Same Page, Two Models

For the comparison to be fair, both models use the same data source, the same delay, and
the same template. The server below serves both: the `/client` path gives the empty
shell, the `/server` path gives the document with markup already in place.

```js
// two-models.mjs — a single server serving the same station page in two models
import { createServer } from "node:http";

const MEASUREMENTS = [
  { time: "06:00", temperature: -4.2, humidity: 72 },
  { time: "07:00", temperature: -3.8, humidity: 70 },
  { time: "08:00", temperature: -2.1, humidity: 66 },
];

const wait = (ms) => new Promise((c) => setTimeout(c, ms));
const readMeasurements = async () => { await wait(40); return MEASUREMENTS; }; // data source work

const list = (measurements) =>
  measurements.map((m) => `<li>${m.time} ${m.temperature} C, ${m.humidity}%</li>`).join("");

const document = (body, script) => `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope Measurement Station</title>
<script src="${script}" defer></script></head>
<body>${body}</body></html>
`;

const SHELL = document('<div id="station"></div>', "/app.js");

const CLIENT_SCRIPT = `const root = document.getElementById("station");
const measurements = await (await fetch("/api/measurements")).json();
root.innerHTML = "<h1>North Slope Measurement Station</h1><ul>" +
  measurements.map((m) => "<li>" + m.time + " " + m.temperature + " C, " + m.humidity + "%</li>").join("") +
  "</ul>";
`;

const SERVER_SCRIPT = `document.querySelector("#refresh")
  .addEventListener("click", () => location.reload());
`;

const send = (response, type, body) => {
  response.setHeader("Content-Type", type);
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
};

const HTML = "text/html; charset=utf-8";
const JS = "text/javascript; charset=utf-8";

createServer(async (request, response) => {
  response.sendDate = false;
  switch (request.url) {
    case "/client":
      return send(response, HTML, SHELL);
    case "/app.js":
      return send(response, JS, CLIENT_SCRIPT);
    case "/api/measurements":
      return send(response, "application/json", JSON.stringify(await readMeasurements()));
    case "/server": {
      const measurements = await readMeasurements();
      const body = `<div id="station"><h1>North Slope Measurement Station</h1>` +
        `<ul>${list(measurements)}</ul><button id="refresh">Refresh</button></div>`;
      return send(response, HTML, document(body, "/interaction.js"));
    }
    case "/interaction.js":
      return send(response, JS, SERVER_SCRIPT);
    default:
      return response.writeHead(404).end();
  }
}).listen(8172, "127.0.0.1", () => console.log("listening: 127.0.0.1:8172"));
```

The only difference is that the `/server` branch reads the data while producing the
response and writes the list into the markup. The template function is identical in both;
what differs is where it is called.

## Measurement

The script below runs in the same directory as this file. Port 8172 is chosen arbitrarily
and must be free; if it is in use, change it in both files.

```bash
#!/usr/bin/env bash
# Starts two-models.mjs, compares the two models with the same measures, then stops it.
node two-models.mjs > /dev/null &
server=$!
sleep 1
curl -sS -o /dev/null http://127.0.0.1:8172/client   # warm-up request: not part of the measurement

FORMAT='first byte %{time_starttransfer} s   body %{size_download} B\n'

echo "--- client-side rendering: first document ---"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8172/client
echo "measurement values in the document: $(curl -sS http://127.0.0.1:8172/client | grep -o -- '-4.2\|-3.8\|-2.1' | wc -l | tr -d ' ')"

echo "--- server-side rendering: first document ---"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8172/server
echo "measurement values in the document: $(curl -sS http://127.0.0.1:8172/server | grep -o -- '-4.2\|-3.8\|-2.1' | wc -l | tr -d ' ')"

echo "--- server-produced body ---"
curl -sS http://127.0.0.1:8172/server

echo "--- client model: sum of the three requests up to content ---"
total=0
for path in /client /app.js /api/measurements; do
  s=$(curl -sS -o /dev/null -w '%{time_starttransfer}' "http://127.0.0.1:8172$path")
  printf '  %-19s %s s\n' "$path" "$s"
  total=$(echo "$total + $s" | bc)
done
printf '  %-19s %s s\n' "total" "$total"

kill "$server"
```

```
--- client-side rendering: first document ---
first byte 0.000578 s   body 197 B
measurement values in the document: 0
--- server-side rendering: first document ---
first byte 0.042784 s   body 369 B
measurement values in the document: 3
--- server-produced body ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope Measurement Station</title>
<script src="/interaction.js" defer></script></head>
<body><div id="station"><h1>North Slope Measurement Station</h1><ul><li>06:00 -4.2 C, 72%</li><li>07:00 -3.8 C, 70%</li><li>08:00 -2.1 C, 66%</li></ul><button id="refresh">Refresh</button></div></body></html>
--- client model: sum of the three requests up to content ---
  /client             0.000518 s
  /app.js             0.000564 s
  /api/measurements   0.042691 s
  total               .043773 s
```

Duration fields depend on the machine and its load at the time; they come out different on
every run. The interpretation rests on the ratios.

Three changes were measured. **Time to first byte grew by two orders of magnitude** —
from 0.0006 seconds to 0.0428 seconds: for the empty shell the server does no work at all,
while for the ready-made document it has to wait on the data source. The shell's duration
sits in the local connection's noise floor and swings noticeably from run to run; what
stays fixed is that in server-side rendering, the first byte is delayed by **exactly the
data source's work**. **The body grew by about 1.9 times**: the markup now carries all
three measurement lines. **The document has content**: the count of measurement values,
zero in the client model, is three.

## The Limit of a Measurement Without a Network Round Trip

The last section must be read carefully. In the client model, the total time up to
content is 0.0438 seconds; the server model's single request takes 0.0428 seconds. The two
are nearly identical.

The reason is that the measurement is taken over the loopback interface: on a request to
127.0.0.1, the network round trip is close to zero. The measurement therefore isolates
only the **server's work**, and that work is the same 40 milliseconds in both models.
Server-side rendering's gain does not come from the server's work — it comes from **how
many times a round trip is made**.

The round trip's weight is calculated separately. The inputs to the calculation below come
from the measurement above and from the previous lesson's script assumptions.

```js
// round-trip-effect.mjs — the round trip's weight in the two models
// Server work and transfer are taken from this lesson's local measurement: data work 43 ms,
// shell and document transfer stayed under 1 ms on the local connection, so 1 ms was counted.
const DATA_WORK = 43;        // ms
const TRANSFER = 1;          // ms, per response
const SCRIPT_DOWNLOAD = 117; // ms, 180 KB / 1.5 MB/s
const SCRIPT_EXECUTION = 63; // ms

// client model: document trip + script trip + data trip, with script download and execution in between
const client = (trip) =>
  trip + TRANSFER + trip + SCRIPT_DOWNLOAD + SCRIPT_EXECUTION + trip + DATA_WORK + TRANSFER;

// server model: a single trip, the server reads the data and sends the markup ready-made
const server = (trip) => trip + DATA_WORK + TRANSFER;

console.log("round trip".padEnd(11) + "client".padStart(9) + "server".padStart(9) +
  "diff".padStart(9) + "ratio".padStart(8));
for (const trip of [0, 20, 60, 150]) {
  const c = client(trip), s = server(trip);
  console.log(`${trip} ms`.padEnd(11) + `${c} ms`.padStart(9) + `${s} ms`.padStart(9) +
    `${c - s} ms`.padStart(9) + (c / s).toFixed(1).padStart(8));
}
```

```
round trip    client   server     diff   ratio
0 ms          225 ms    44 ms   181 ms     5.1
20 ms         285 ms    64 ms   221 ms     4.5
60 ms         405 ms   104 ms   301 ms     3.9
150 ms        675 ms   194 ms   481 ms     3.5
```

The table says two things at once. The absolute difference **grows** as the round trip
lengthens: at a 150-millisecond round trip, the gap between the two approaches half a
second. The ratio, however, **shrinks**, because the round trip is added to both models
and the denominator grows too. The zero-round-trip row shows that script download and
execution alone take 180 milliseconds: this line item does not disappear even if there is
no network at all.

## The Cost the Server Pays

The time gained comes with a bill, and that bill is per request.

**Computation work repeats.** The empty shell is the same byte sequence for every user and
can be produced once and served forever. The server-produced document is rebuilt on every
request: the data is read, the template is processed, the strings are concatenated. As
request volume grows, this work grows linearly and becomes the line item that determines
how the server is sized.

**Caching the response gets harder.** The distinction built in the Caching Strategies
lesson finds its counterpart here: the empty shell is an asset that can be stored with a
long lifetime, while the server-produced document is variable content. If the content
varies per person, the response cannot be stored in any shared layer; if it does not vary,
it can be stored, but the freshness window is bounded by how often the content changes.
Whether personalization touches the whole page or a small part of it is therefore a design
decision.

**The data source's delay is written directly into time to first byte.** In the client
model, the user sees at least a shell while waiting; in the server model, when the data
source slows down the user sees nothing, because the first byte has not been sent yet. As
long as the entire response is produced in a single piece, this dependency cannot be
broken.

**The failure surface widens.** When the data source does not respond, the client model's
shell still loads and the interface can show an error state; in the server model, the
document itself cannot be produced. This is why an endpoint that renders on the server
sets a timeout on its data calls and defines a fallback markup that can still be produced
with missing data.

## The Markup That Arrives Is Not Interactive

The measured body has a button, but the button does not work. The markup was produced on
the server; event listeners are attached on the client, after the script runs. A gap
remains between the moment the document reaches the screen and the moment the button
responds.

This gap is the model's natural consequence, and it separates two measurable quantities:
**first contentful paint** happens when the document arrives, **time to interactive**
waits on the script running. Server-side rendering moves the first one earlier and leaves
the second one where it was. In the gap between them, the user sees the content, clicks,
and gets no response.

The length of the gap depends on the script's size and the device's power. The fifth
lesson names this gap and takes up ways to narrow it.

## Summary

- In server-side rendering, the markup is produced while the request is answered; the
  document carries content, and a single network round trip is enough to reach first
  content.
- The measurement shows that time to first byte grows by the data source's work, that the
  body grows larger, and that measurement values appear in the markup.
- A measurement taken over the loopback interface isolates the network round trip; the
  difference between the two models grows in absolute terms as the round trip's cost
  rises, and shrinks in proportional terms.
- The cost is per request: the computation repeats, the response's cacheability is bounded
  by how variable the content is, and the data source's delay is written directly into the
  first byte.
- The markup that arrives from the server is not interactive; a gap that waits on the
  script running remains between first contentful paint and time to interactive.

## Next Step

Server-side rendering accepts doing the same work on every request. For the station's
landing page and for past days' archive pages, this is wasteful: their content does not
change per request, and the same input always produces the same markup. Instead of
repeating a computation that produces the same output on every request, it is possible to
do it once and write the result to a file. The next lesson examines that shift: what
happens to time to first byte when the work moves from the moment a request arrives to the
moment of a build, and where does the cost get written — to build time, or to freshness?
