Skip to content
academia.sh

Lesson 01 / 19

Client-Side Rendering

Producing markup in the browser; what the empty shell carries, the serial request chain leading up to first content, and the trade-off between the cost this model pays at first paint and the speed it gains on subsequent navigation.

Contents

The previous course built the application’s internal architecture: addresses mapped to views, state lived in one place, the data layer took on requests and retries, and forms shared validation rules. All of that defines what the application does. The question left open is where and when the markup sent to the user is produced.

The North Slope Measurement Station site makes this question concrete. The site has a station landing page, a measurement list that changes every minute, thousands of archive pages for past days, and a panel private to the station operator. None of these pages has to be produced in the same place. This course makes a separate decision for each one. The first model is the one that has the browser produce the markup.

What the Rendering Decision Is, and Is Not

Rendering, in this course, means data and template combining into HTML markup. The decision concerns which machine this combination happens on and at what moment: in the user’s browser, on the server answering the request, or during a build, long before any request arrives.

This should not be confused with the rendering pipeline covered in The Browser and the Web Platform course. The rendering pipeline is the browser turning the markup it holds into pixels through the layout, paint, and composite stages; that pipeline runs in the browser no matter which machine produced the markup. The rendering decision determines where the pipeline’s input comes from — it does not change the pipeline itself.

To keep the distinction intact, this course keeps two words separate throughout: producing the markup is rendering, the browser turning it into pixels is painting.

What the Server Sends

In client-side rendering, the server sends a document that carries no data, along with a script. The document’s job is to load the script and provide an empty container for the script to write into.

// client-server.mjs — server that renders the station page on the client
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 SHELL = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope Measurement Station</title>
<script src="/app.js" defer></script></head>
<body><div id="station"></div></body></html>
`;

const SCRIPT = `const root = document.getElementById("station");
const response = await fetch("/api/measurements");
const measurements = await response.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 wait = (ms) => new Promise((c) => setTimeout(c, ms));

createServer(async (request, response) => {
  response.sendDate = false; // to keep the output deterministic: no Date header is written
  if (request.url === "/") {
    response.setHeader("Content-Type", "text/html; charset=utf-8");
    response.setHeader("Content-Length", Buffer.byteLength(SHELL));
    response.writeHead(200).end(SHELL);
  } else if (request.url === "/app.js") {
    response.setHeader("Content-Type", "text/javascript; charset=utf-8");
    response.setHeader("Content-Length", Buffer.byteLength(SCRIPT));
    response.writeHead(200).end(SCRIPT);
  } else if (request.url === "/api/measurements") {
    await wait(40); // data source read work
    const body = JSON.stringify(MEASUREMENTS);
    response.setHeader("Content-Type", "application/json");
    response.setHeader("Content-Length", Buffer.byteLength(body));
    response.writeHead(200).end(body);
  } else {
    response.writeHead(404).end();
  }
}).listen(8171, "127.0.0.1", () => console.log("listening: 127.0.0.1:8171"));

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

#!/usr/bin/env bash
# Starts client-server.mjs, measures three requests in sequence, then stops it.
node client-server.mjs > /dev/null &
server=$!
sleep 1

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

echo "--- body of the first document ---"
curl -sS http://127.0.0.1:8171/

echo "--- does a measurement value appear in the document? ---"
curl -sS http://127.0.0.1:8171/ | grep -c -- "-4.2"

echo "--- the chain's three steps (in sequence) ---"
echo "1) /"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8171/
echo "2) /app.js"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8171/app.js
echo "3) /api/measurements"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8171/api/measurements

kill "$server"
--- body of the first document ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope Measurement Station</title>
<script src="/app.js" defer></script></head>
<body><div id="station"></div></body></html>
--- does a measurement value appear in the document? ---
0
--- the chain's three steps (in sequence) ---
1) /
  first byte 0.000493 s   body 197 B
2) /app.js
  first byte 0.000498 s   body 330 B
3) /api/measurements
  first byte 0.042769 s   body 151 B

Duration fields depend on the machine that runs the script and its load at the time; they come out different on every run. What matters is not the numbers themselves but the ratio between them: the first two requests take microseconds over the local connection, while the third takes roughly eighty times longer because of the 40 milliseconds of artificially added data work. In a real deployment, a network round trip is also added to each of these three requests.

The second output line is the model’s defining trait: the first document carries no measurement value at all. A client reading the document — a search engine crawler, a service that generates link previews, a reader that does not execute scripts — sees an empty container.

The Arithmetic of the Serial Chain

Each of the three requests depends on the previous one’s result. Until the document arrives, the script’s address is unknown; until the script runs, no data request is born. This dependency causes the durations to add up. The calculation below is not a measurement — it is the arithmetic of explicitly written assumptions.

// chain.mjs — the serial chain of client-side rendering up to first content
// Inputs are assumptions; the output is these assumptions' arithmetic, not a measurement.
const ROUND_TRIP = 60;       // ms, one trip
const SERVER_DOCUMENT = 5;   // ms, work to produce the shell
const SERVER_DATA = 40;      // ms, the data endpoint's work
const BANDWIDTH = 1.5 * 1024; // KB/s
const SCRIPT_KB = 180;
const EXECUTION_MS_KB = 0.35; // ms per KB of parsing + execution

const transfer = (kb) => (kb / BANDWIDTH) * 1000;

const steps = [
  ["document request (trip)", ROUND_TRIP],
  ["producing the shell", SERVER_DOCUMENT],
  ["shell transfer (0.2 KB)", transfer(0.2)],
  ["script request (trip)", ROUND_TRIP],
  [`script transfer (${SCRIPT_KB} KB)`, transfer(SCRIPT_KB)],
  ["script parsing + execution", SCRIPT_KB * EXECUTION_MS_KB],
  ["data request (trip)", ROUND_TRIP],
  ["data endpoint's work", SERVER_DATA],
  ["data transfer (0.1 KB)", transfer(0.1)],
  ["building the tree + paint", 8],
];

let total = 0;
console.log("step".padEnd(30) + "time".padStart(8) + "cumulative".padStart(12));
for (const [name, duration] of steps) {
  total += duration;
  console.log(name.padEnd(30) + duration.toFixed(1).padStart(8) + total.toFixed(1).padStart(12));
}
console.log("-".repeat(50));
console.log("first contentful paint".padEnd(30) + total.toFixed(1).padStart(20));

// What's visible in the shell: just an empty document after the first two steps.
const emptyDocument = ROUND_TRIP + SERVER_DOCUMENT + transfer(0.2);
console.log("empty shell on screen".padEnd(30) + emptyDocument.toFixed(1).padStart(20));
console.log("empty screen share".padEnd(30) +
  ((emptyDocument / total) * 100).toFixed(0).padStart(19) + "%");
step                              time  cumulative
document request (trip)           60.0        60.0
producing the shell                5.0        65.0
shell transfer (0.2 KB)            0.1        65.1
script request (trip)             60.0       125.1
script transfer (180 KB)         117.2       242.3
script parsing + execution        63.0       305.3
data request (trip)               60.0       365.3
data endpoint's work              40.0       405.3
data transfer (0.1 KB)             0.1       405.4
building the tree + paint          8.0       413.4
--------------------------------------------------
first contentful paint                       413.4
empty shell on screen                         65.1
empty screen share                             16%

Three line items dominate the table: three network round trips, the script’s transfer, and the script’s execution. All three stand ahead of the first content the user sees. By the time the shell reaches the screen, only a sixth of the total time has elapsed; for the remaining five-sixths, there is no content on screen.

This table also shows how much each improvement is worth. Halving the script gains about 90 milliseconds. Merging the data request with the document request removes a round trip, along with the data endpoint’s work, from the chain. The duration of the network round trips, however, does not shrink with a code change — it depends on distance, and the sixth lesson takes up that line item separately.

What the Model Gains

The first load is expensive, but the cost buys something, and dismissing that return means rejecting the model unfairly.

Once the script has run once, subsequent navigation becomes independent of the network. The client-side router built in the Application Architecture: Routing, State and Data course does not request a new document when the address changes; it produces the new view from the state already on hand. Moving from the measurement list to an archive page happens without downloading a document.

The server side is stateless and cheap. The document the server sends is identical for every user; it produces nothing that varies per person. A document like this can be served as a static file, cached with a long lifetime, and served from a content delivery network’s copies.

The client–server boundary is sharp. The server serves only data endpoints; the responsibility for producing markup sits entirely with the client. The same data endpoints can also be used by a desktop or mobile shell; this boundary makes it easier to replicate the interface.

Interfaces that stay open for long stretches, take frequent interaction, and do not need to return to the server on every interaction are this model’s natural territory. The station operator’s panel — the screen where thresholds are edited, charts are zoomed, and records are filtered — is an example.

Where the Cost Grows Heavier

The same trade-off turns the model into the wrong choice in three situations.

On pages where first visits are frequent, the cost is paid every time. A visitor who arrives from a search result, reads a single page, and leaves never benefits from the speed of subsequent navigation; they see only the first load’s delay.

For clients that read the markup, there is no content. The output’s second line already showed this: no measurement value appears in the document. For pages whose content depends on being read by a machine, this alone is decisive.

On slow devices, executing the script becomes more expensive than transferring it. The parsing-and-execution line item, which shows 63 milliseconds in the table, grows by the same factor on a device with several times less processing power; and as bandwidth increases, this line item takes up a larger share of the total. The script’s size determines not only the download time but also the work the device spends.

A fourth fragility is the script never running at all. If the transfer is interrupted, an error stops the script, or an extension intervenes, what the user sees is an empty document. With markup produced on the server, the same failure only loses the interaction, not the content.

Summary

  • The rendering decision determines which machine and moment data and template turn into markup on; the browser’s rendering pipeline runs on the client in every case, independent of this decision.
  • In client-side rendering, the first document carries no data; measurement values do not appear in the markup, and clients that read the document without running the script see an empty container.
  • The path to first content is serial: the document, script, and data requests wait on each other; three network round trips, the script’s transfer, and the script’s execution are added to the total time.
  • After the first load, the model makes navigation independent of the network and leaves the server stateless; it suits long-session, interaction-heavy screens.
  • The cost grows heavier when first visits are frequent, when the markup is read by a machine, and when device power is low.

Next Step

Two of this lesson’s chain’s three network round trips arose from carrying data and markup separately. If the two are merged into a single response, the chain shortens: the server can read the data and process the template itself, sending the content ready-made. This moves the moment first content appears earlier, but it requires the server to do work for every request, and it turns a response that was easy to cache into one that varies per person. The next lesson measures that trade-off: when the same station page is produced on the server, how do time to first byte, body size, and content change?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close