Lesson 02 / 19
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.
Contents
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.
// 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.
#!/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.
// 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?
To keep your progress and take notes, Log in
My notes
Log in to take notes.