---
title: 'Rendering Strategies and Infrastructure'
source: 'https://academia.sh/en/courses/rendering-strategies'
language: en
updated: '2026-08-17T13:22:01+00:00'
license: 'CC BY-SA 4.0'
lessons: 19
outcomes:
    - 'Chooses client, server, and build-time rendering by their trade-offs'
    - 'Configures bundling and asset optimization'
    - 'Sets up deployment and environment management'
---

# Rendering Strategies and Infrastructure

The decision about where and when an interface's markup is produced; the measurable trade-offs of the client, server, build-time, and edge models, together with the build, bundling, and deployment infrastructure that follows from that decision.

## Rendering Models

Producing markup on the client, on the server, at build time, or at the edge; incremental regeneration, hydration and streaming, and the criteria for choosing among these models.

### Client-Side Rendering

Source: https://academia.sh/en/courses/rendering-strategies/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.

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.

```js
// 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.

```bash
#!/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.

```js
// 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?

### Server-Side Rendering

Source: https://academia.sh/en/courses/rendering-strategies/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 roughly forty-sevenfold** — 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?

### Static Site Generation

Source: https://academia.sh/en/courses/rendering-strategies/static-site-generation

Producing markup at build time and writing it to a file; the same template called at two separate moments, build duration that grows linearly with the page count, and static output's freshness limit.

Server-side rendering accepts doing the same work on every request. For the station's
archive pages, this work is pure repetition: a day's measurement records do not change
once that day closes, and the same input always produces the same markup. Instead of
repeating on every request a computation that gives the same output, it is possible to do
it once and store the result.

This lesson makes that shift: the work moves from the moment a request arrives to **build
time**. What gets measured is what this gains for time to first byte, and where the cost
gets written.

#### The Template Stays the Same, the Moment It Is Called Changes

For the comparison to be meaningful, both paths must use the same template. The template
and the data source are put in a separate module; both the builder and the server call it.

```js
// page.mjs — the archive page's data source and template; both callers use this
export const wait = (ms) => new Promise((c) => setTimeout(c, ms));

// Daily measurement record: derived deterministically from the day, the data source is a stand-in.
export function dailyMeasurements(day) {
  return Array.from({ length: 24 }, (_, hour) => ({
    time: String(hour).padStart(2, "0") + ":00",
    temperature: (-8 + ((day * 7 + hour * 3) % 130) / 10).toFixed(1),
    humidity: 40 + ((day * 11 + hour * 5) % 55),
  }));
}

export function page(day, measurements) {
  const rows = measurements
    .map((m) => `<tr><td>${m.time}</td><td>${m.temperature}</td><td>${m.humidity}</td></tr>`)
    .join("");
  return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope - day ${day}</title></head>
<body><h1>North Slope Measurement Station</h1><h2>Day ${day}</h2>
<table><thead><tr><th>Time</th><th>Temperature</th><th>Humidity</th></tr></thead>
<tbody>${rows}</tbody></table></body></html>
`;
}
```

This is what makes static generation a **calling-moment** decision rather than a
technique: nothing in the code changes, only when the `page` function runs changes.

#### Production at Build Time

The builder walks the list of pages to produce, and for each one calls the template and
writes the result to disk.

```js
// build.mjs — builds the archive pages at build time and measures the duration
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dailyMeasurements, page } from "./page.mjs";

const OUTPUT = "output";

function build(pageCount) {
  rmSync(OUTPUT, { recursive: true, force: true });
  mkdirSync(OUTPUT, { recursive: true });
  const start = performance.now();
  let bytes = 0;
  for (let day = 1; day <= pageCount; day++) {
    const body = page(day, dailyMeasurements(day));
    writeFileSync(`${OUTPUT}/day-${day}.html`, body);
    bytes += Buffer.byteLength(body);
  }
  return { duration: performance.now() - start, bytes };
}

build(50); // warm-up run: not part of the measurement

console.log("pages".padStart(7) + "time (ms)".padStart(12) + "per page (ms)".padStart(19) +
  "output (KB)".padStart(13));
let previousDuration = null, previousCount = null;
for (const n of [100, 500, 1000, 5000]) {
  const { duration, bytes } = build(n);
  let ratio = "";
  if (previousDuration !== null) {
    ratio = `   pages x${(n / previousCount).toFixed(0)} -> time x${(duration / previousDuration).toFixed(1)}`;
  }
  console.log(String(n).padStart(7) + duration.toFixed(1).padStart(12) +
    (duration / n).toFixed(3).padStart(19) + (bytes / 1024).toFixed(0).padStart(13) + ratio);
  previousDuration = duration; previousCount = n;
}
```

The `node build.mjs` command runs in the same directory as this file, and writes an
`output` directory next to it.

```
  pages   time (ms)      per page (ms)  output (KB)
    100         5.2              0.052          137
    500        28.2              0.056          686   pages x5 -> time x5.4
   1000        52.2              0.052         1373   pages x2 -> time x1.8
   5000       293.3              0.059         6872   pages x5 -> time x5.6
```

Durations depend on the machine, disk speed, and load at the time; they come out different
on every run. The column that matters is the last one: when the page count grows fivefold,
the duration also grows roughly fivefold. The build is **linear**, and the per-page cost
stays constant.

At this scale, the per-page cost goes more into writing files than into processing the
template. The sign of this is that the measured ratio climbs slightly above five as the
page count grows: as directory entries multiply, file system work does not get cheaper.

#### Two Paths, the Same Body

The same page can be served from two sources and compared: the file on disk, and the
template called at request time.

```js
// static-server.mjs — serves the same page two ways: a file on disk, and on-demand production
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { wait, dailyMeasurements, page } from "./page.mjs";

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

createServer(async (request, response) => {
  response.sendDate = false;
  const staticMatch = request.url.match(/^\/static\/day-(\d+)\.html$/);
  const onDemandMatch = request.url.match(/^\/on-demand\/day-(\d+)$/);
  let body;
  if (staticMatch) {
    body = await readFile(`output/day-${staticMatch[1]}.html`, "utf8");
  } else if (onDemandMatch) {
    await wait(40); // work of reading the day's records from the data source
    body = page(Number(onDemandMatch[1]), dailyMeasurements(Number(onDemandMatch[1])));
  } else {
    return response.writeHead(404).end();
  }
  response.setHeader("Content-Type", HTML);
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
}).listen(8173, "127.0.0.1", () => console.log("listening: 127.0.0.1:8173"));
```

```bash
#!/usr/bin/env bash
# First builds the pages, then compares the two paths with the same measure.
node build.mjs > /dev/null   # produces the pages; its table is run separately
node static-server.mjs > /dev/null &
server=$!
sleep 1
curl -sS -o /dev/null http://127.0.0.1:8173/static/day-1.html   # warm-up

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

echo "--- file on disk ---"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8173/static/day-7.html
echo "--- on-demand production ---"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8173/on-demand/day-7

echo "--- are the two bodies the same? ---"
curl -sS http://127.0.0.1:8173/static/day-7.html > a.html
curl -sS http://127.0.0.1:8173/on-demand/day-7 > b.html
if cmp -s a.html b.html; then echo "same"; else echo "different"; fi
rm -f a.html b.html

kill "$server"
```

```
--- file on disk ---
  first byte 0.000862 s   body 1398 B
--- on-demand production ---
  first byte 0.042864 s   body 1398 B
--- are the two bodies the same? ---
same
```

The bodies are identical byte for byte; the times to first byte differ by roughly forty-seven times —
0.0009 seconds versus 0.0429 seconds. What reaches the user has not changed; producing it has been moved
backward in time.

The source of the difference is the data source's 40-millisecond work. On the static path,
this work was done once, at build time, and is never done at request time. In on-demand
production, it is redone on every request: a thousand visitors means the same computation
a thousand times.

#### What Static Output Brings

Being a file on disk carries consequences that come from being a file.

**It requires no server runtime.** The response is a file read; it can be served without
an application process, a data source connection, or a runtime environment. This means a
content delivery network can copy the output as is and serve it.

**The response is the same for everyone.** The same address gives every client the same
bytes; this lets the response be stored with a long lifetime in shared layers. The
distinction from the Caching Strategies lesson sits at its most favorable side here:
content that never changes is content that does not even need a validation request.

**Load becomes independent of request volume.** In server-side rendering, cost grew with
the number of requests; in static generation, cost grows with **the number of pages** and
is paid once. The difference between ten visitors and ten thousand visitors is only
bandwidth.

**The failure surface narrows.** If there is no code running at request time, there is no
failure that can arise at request time either. A template error surfaces during the build
and is caught before it reaches production.

#### Where It Cannot Be Applied

Static generation has a single condition: a page's output must depend only on **inputs
known at build time**. This condition breaks down in three situations.

If a page varies per user, it cannot be generated this way. The station operator's panel
is different for each person; at build time, it is not known who it is being produced for.

If content changes between builds, the output goes stale. The live measurement list
changes every minute; a file built once a day can never report the age of the value it
shows. In static generation, freshness is bounded by build frequency.

If the page count grows past what a build can carry, the method jams. The measured
linearity looks like good news, but linear growth has an end: 0.05 milliseconds per page
means five seconds at a hundred thousand pages, and over a minute at a million pages. In a
real build, the per-page work is larger than in this example, and rebuilding the entire
site for a single typo fix becomes unacceptable.

This third limit leads to a question that arises from within static generation itself: is
it possible to refresh only what changed, without regenerating every page?

#### Summary

- Static generation calls the template that produces markup at build time, not at request
  time; the template itself does not change.
- The measurement shows the two paths' bodies are identical byte for byte, while their
  times to first byte differ by roughly forty-seven times; the difference is the data
  source's work being moved earlier in time.
- Build duration grows linearly with the page count, and the per-page cost stays constant;
  total cost depends on the page count, not on request volume.
- Static output requires no runtime, gives everyone the same response, and can be cached
  with a long lifetime; because no code runs at request time, the failure surface narrows.
- The method cannot be applied if the output depends on inputs unknown at build time, if
  content changes between builds, or if the page count outgrows what a build can carry.

#### Next Step

The third limit suggests there is a place between the two. A page can be generated
statically without having to be treated as valid forever: it can be given a lifetime, and
when that lifetime expires, the next request can receive the old copy while a new version
is produced in the background. This keeps the runtime cost low without condemning
freshness to the build's frequency. The next lesson builds and measures this mechanism:
the freshness window, the background revalidation queue, and the hit and miss counts.

### Incremental Regeneration

Source: https://academia.sh/en/courses/rendering-strategies/incremental-regeneration

Selective refreshing of static output; the freshness window, the background revalidation queue, miss–hit counting, and the trade-off window length sets up between production load and content delay.

One of static generation's three limits was that freshness was tied to build frequency. A
page may have been produced at build time, but it does not have to be treated as valid
forever: it can be given a lifetime, and when that lifetime expires, the next request can
receive the old copy while a new version is prepared in the background.

This mechanism is the server-side counterpart of the stale-while-revalidate behavior seen
on the browser side in the Caching Strategies lesson. This lesson builds it as a rendering
model and measures two quantities: how many requests see how much stale content, and how
many regeneration jobs are born.

#### Three Response States

When a page request arrives, the store is checked, and one of three states occurs.

**Miss**: no copy of that page exists in the store. The request waits for production to
finish and ends up paying server-side rendering's cost. This state happens at most once
per page.

**Fresh hit**: the copy in the store was produced within the freshness window. It is
served directly; no production job is born.

**Stale serve**: a copy exists, but its window has expired. The copy is served as is, and
regeneration is queued in the background. The request is not held up; the refresh is
reflected in subsequent requests.

How these three states look on a server can be observed directly.

```js
// incremental-server.mjs — server with a freshness window and background refresh
import { createServer } from "node:http";

const WINDOW = 3000;          // ms, how long a copy counts as fresh
const PRODUCTION_TIME = 1000; // ms, cost of producing the page

let sourceVersion = 0;
setInterval(() => { sourceVersion += 1; }, 1000).unref(); // the data changes every second

let record = null;       // { body, version, producedAt }
let refreshing = false;

const wait = (ms) => new Promise((c) => setTimeout(c, ms));

async function produce() {
  await wait(PRODUCTION_TIME);
  return { body: `measurement list version ${sourceVersion}\n`, version: sourceVersion, producedAt: Date.now() };
}

createServer(async (request, response) => {
  response.sendDate = false;
  if (request.url !== "/measurements") return response.writeHead(404).end();

  let state;
  if (!record) {
    state = "miss";                        // no copy: the request waits on production
    record = await produce();
  } else if (Date.now() - record.producedAt < WINDOW) {
    state = "fresh";                       // inside the window: served directly
  } else {
    state = "stale";                       // outside the window: the old copy is served
    if (!refreshing) {                     // refresh is queued only once
      refreshing = true;
      produce().then((next) => { record = next; refreshing = false; });
    }
  }
  response.setHeader("X-State", state);
  response.setHeader("Content-Type", "text/plain; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(record.body));
  response.writeHead(200).end(record.body);
}).listen(8174, "127.0.0.1", () => console.log("listening: 127.0.0.1:8174"));
```

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

```bash
#!/usr/bin/env bash
# Starts incremental-server.mjs, makes requests at four different moments, then stops it.
node incremental-server.mjs > /dev/null &
server=$!
sleep 1

request() {
  printf '%-34s' "$1"
  curl -sS -D headers -w '   time %{time_total} s   ' http://127.0.0.1:8174/measurements | tr -d '\n'
  grep -i '^x-state' headers | tr -d '\r'
}

request "1) first request"
request "2) right after"
sleep 4
request "3) after the window fills"
sleep 2
request "4) after the refresh finishes"
rm -f headers

kill "$server"
```

```
1) first request                  measurement list version 2   time 1.008993 s   X-State: miss
2) right after                    measurement list version 2   time 0.000826 s   X-State: fresh
3) after the window fills         measurement list version 2   time 0.001321 s   X-State: stale
4) after the refresh finishes     measurement list version 7   time 0.001347 s   X-State: fresh
```

Duration fields depend on the machine. The decisive ratio is this: the miss takes about
one second, while the other three requests stay under a few milliseconds — the gap between
them is roughly a thousandfold. The third request serves a copy whose window has expired,
and it is fast too; what waits is only the freshness, not the user.

The fourth line shows the mechanism's characteristic behavior: the served version jumps
from 2 to 7. Background production captures the source at the moment it **finishes**, not
the moment it started; in the meantime, the source has advanced several versions.

#### The Freshness Window Does Not Tell the Real Age

The "fresh" stamp on a response reports not that the copy is identical to its source, but
that it was **produced within the window**. The two are not the same thing. The simulation
below exposes this distinction over a virtual clock; its output is independent of the
machine.

```js
// incremental.mjs — freshness window and background refresh queue model
// A virtual clock is used: the output is deterministic, not machine-dependent.

const DEFAULT_WINDOW = 60;      // seconds
const PRODUCTION_DURATION = 2;  // seconds, regenerating a page in the background
const DURATION = 1800;          // seconds, length of the simulation
const REQUEST_COUNT = 300;

// The data's version: the measurement list changes every 60 seconds, the archive never changes.
const sourceVersion = (path, t) => (path === "/measurements" ? Math.floor(t / 60) : 0);

// Deterministic pseudo-random sequence (linear congruential generator).
function generator(seed) {
  let x = seed;
  return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
}

// Request stream: paths are chosen by weight, times are produced in increasing order.
function requestStream() {
  const random = generator(20260728);
  const weight = [["/measurements", 6], ["/day-1", 2], ["/day-2", 1], ["/day-3", 1]];
  const pool = weight.flatMap(([path, n]) => Array(n).fill(path));
  const requests = [];
  for (let i = 0; i < REQUEST_COUNT; i++) {
    requests.push({
      t: Math.floor(random() * DURATION),
      path: pool[Math.floor(random() * pool.length)],
    });
  }
  return requests.sort((a, b) => a.t - b.t);
}

function run(window, keepLog) {
  const store = new Map();     // path -> { version, producedAt }
  const queue = new Map();     // path -> completion time
  const counts = { fresh: 0, stale: 0, miss: 0, production: 0 };
  const delays = [];
  const log = [];

  for (const { t, path } of requestStream()) {
    // If the queued refresh's duration has elapsed, write it to the store.
    const completesAt = queue.get(path);
    if (completesAt !== undefined && completesAt <= t) {
      store.set(path, { version: sourceVersion(path, completesAt), producedAt: completesAt });
      queue.delete(path);
      counts.production += 1;
    }

    const record = store.get(path);
    let outcome;
    if (!record) {
      // Miss: no copy, the request waits on production.
      store.set(path, { version: sourceVersion(path, t), producedAt: t });
      counts.miss += 1; counts.production += 1;
      outcome = "miss";
    } else if (t - record.producedAt < window) {
      counts.fresh += 1;
      outcome = "fresh";
    } else {
      // The old copy is served, the refresh is queued (a second one is not added if it is already queued).
      counts.stale += 1;
      if (!queue.has(path)) queue.set(path, t + PRODUCTION_DURATION);
      outcome = "stale";
    }

    const served = store.get(path);
    const delay = sourceVersion(path, t) - served.version;
    delays.push(delay);
    if (keepLog && path === "/measurements" && log.length < 14) {
      log.push(`${String(t).padStart(4)}s  ${outcome.padEnd(7)}` +
        `served version ${String(served.version).padStart(2)}   source version ` +
        `${String(sourceVersion(path, t)).padStart(2)}   delay ${delay}` +
        (queue.has(path) ? "   [queued]" : ""));
    }
  }
  const average = delays.reduce((a, b) => a + b, 0) / delays.length;
  return { counts, average, worst: Math.max(...delays), log };
}

const first = run(DEFAULT_WINDOW, true);
console.log(`--- /measurements requests, first 14 (window ${DEFAULT_WINDOW} s) ---`);
for (const line of first.log) console.log(line);

console.log(`\n--- ${REQUEST_COUNT} requests, ${DURATION} s, as the window changes ---`);
console.log("window".padStart(8) + "fresh".padStart(7) + "stale".padStart(7) + "miss".padStart(7) +
  "production".padStart(12) + "avg delay".padStart(14) + "worst".padStart(9));
for (const window of [10, 60, 300, 1800]) {
  const { counts, average, worst } = run(window, false);
  console.log(`${window} s`.padStart(8) + String(counts.fresh).padStart(7) +
    String(counts.stale).padStart(7) + String(counts.miss).padStart(7) +
    String(counts.production).padStart(12) + average.toFixed(2).padStart(14) +
    String(worst).padStart(9));
}
```

```
--- /measurements requests, first 14 (window 60 s) ---
   5s  miss   served version  0   source version  0   delay 0
   9s  fresh  served version  0   source version  0   delay 0
  20s  fresh  served version  0   source version  0   delay 0
  28s  fresh  served version  0   source version  0   delay 0
  37s  fresh  served version  0   source version  0   delay 0
  90s  stale  served version  0   source version  1   delay 1   [queued]
  92s  fresh  served version  1   source version  1   delay 0
  92s  fresh  served version  1   source version  1   delay 0
  98s  fresh  served version  1   source version  1   delay 0
 107s  fresh  served version  1   source version  1   delay 0
 116s  fresh  served version  1   source version  1   delay 0
 117s  fresh  served version  1   source version  1   delay 0
 146s  fresh  served version  1   source version  2   delay 1
 154s  stale  served version  1   source version  2   delay 1   [queued]

--- 300 requests, 1800 s, as the window changes ---
  window  fresh  stale   miss  production     avg delay    worst
    10 s     98    198      4         179          0.14        1
    60 s    216     80      4          73          0.38        2
   300 s    276     20      4          23          1.42        5
  1800 s    296      0      4           4          9.38       29
```

The log's line at the 146th second is the lesson's crucial point: the response is stamped
**fresh**, but the delay is one. The copy was produced at the 92nd second; the 60-second
window lasts until the 152nd second, yet the source advanced a version at the 120th
second. The window measures the copy's age, not the content's correctness.

The line at the ninetieth second shows how the queue works: the old copy was served, the
refresh was queued, and because it completed two seconds later, the request at the 92nd
second saw the new version.

#### Window Length Is a Trade-off

The lower table shows the window length pulling two quantities in opposite directions.
When the window is reduced to a tenth of a second, average delay drops to 0.14 versions,
but the production count rises to 179. When the window is stretched to thirty minutes,
production drops to four — only the first miss for each page — but average delay jumps to
9.38 versions, and the worst case to 29 versions.

The relationship between them is not linear. Stretching the window from 10 seconds to 60
seconds cuts production by 2.5 times while raising average delay by only 2.7 times;
stretching it from 300 seconds to 1800 seconds cuts production by 5.8 times while raising
delay by 6.6 times. The gain curve flattens: past a certain point, lengthening the window
takes more from freshness than it saves.

It is also meaningful that the miss count is four in every row: there are four distinct
paths, and each path experiences a miss only once. The count of requests that wait depends
on the page count, not on request volume.

#### Points of Attention in Practice

**The queue is deduplicated.** In the model, a second refresh is not added while a path is
already in the queue. Without this safeguard, a hundred concurrent requests to a popular
page whose window has expired would spawn a hundred production jobs. If many copies'
windows expire at the same time, the production load spikes suddenly; adding a small
random offset to the windows spreads out that spike.

**The first miss is made predictable.** The full production cost is paid the first time a
page is requested. Pages with known traffic are prerendered during the build so that this
cost is paid before launch; the rest are born on the first request.

**Invalidation completes the window.** The window is a time-based estimate. When it is
known for certain that content has changed — a measurement is corrected, a page is taken
down — directly invalidating the corresponding record removes the wait entirely.
Time-based windows and event-based invalidation are used together.

**Stale content is not hidden in the interface.** A user looking at a measurement list
should know when the value they see was produced. Writing the production time on the page
does not remove the delay, but it stops the delay from being misleading.

#### Summary

- In incremental regeneration, a request falls into one of three states: a miss makes
  production wait, a fresh hit is served directly, and a stale serve gives the copy while
  queuing a refresh.
- The measurement shows a miss pays the production cost, while the other two states take
  on the order of a thousandth as long; what waits is the freshness, not the user.
- The freshness window measures the copy's age, not how well it matches the source; a copy
  produced within the window can still be behind the source.
- Shortening the window reduces delay and increases production load; because the gain
  curve flattens, past a certain length the savings come at a higher cost to freshness.
- The miss count depends on the page count, not on request volume; deduplicating the queue
  keeps concurrent requests from multiplying the production load.

#### Next Step

The four models covered so far all argued about **where** the markup gets produced, and
one thing stayed common to every one of them: the response is produced in a single piece,
sent in a single piece, and is not interactive when it reaches the client. The gap measured
in the Server-Side Rendering lesson — the interval between the moment content appears and
the moment the button works — still stands. The next lesson names that gap and narrows it
from two directions: sending the response piece by piece, and giving interactivity not to
the whole page but only to the parts that genuinely need it.

### Hydration and Streaming

Source: https://academia.sh/en/courses/rendering-strategies/hydration-and-streaming

Server-produced markup gaining interactivity on the client; hydration's cost, its partial and lazy forms, moving the first byte earlier with chunked delivery, and the balance the two techniques strike together.

The Server-Side Rendering lesson left a gap open: the moment the body reaches the screen
and the moment the button responds are not the same moment. The markup was produced on the
server; event listeners are attached only after the script runs. This lesson names that
interval and narrows it from two directions.

The process that closes the gap is called **hydration** in this course: taking the static
markup that arrives from the server and matching it with code running on the client to
make it interactive. The term is used consistently throughout the course. The second
technique is sending the response piece by piece rather than as a single chunk; this is
called **streaming**.

#### What Hydration Does

In the document that arrives from the server, the tree is already built. The client
code's job is not to rebuild the tree but to **match** its own component tree against the
tree that arrived: finding which node corresponds to which component, attaching event
listeners, and setting up state objects.

This matching has three costs. The components' code must be downloaded. The downloaded
code must be parsed and executed. A match must be made for every node in the tree, and
listeners must be attached. The third item grows linearly with the node count as the page
grows.

Matching also has a correctness condition: the tree the client produces must be identical
to the server's. If a value was written on the server based on the clock at that moment,
and the client sees a different clock, the two trees diverge. This divergence does not
stay silent; the part that fails to match is either produced from scratch or left as an
inconsistent interface. This is why markup meant to be hydrated must be **deterministic**:
the same input must produce the same output on both sides.

#### Sending the Response Piece by Piece

The document has to arrive before hydration can begin. If the document is produced as a
single chunk, the slowest data source holds up the entire page. Sending it in pieces
breaks that link: the server sends each section the moment it is ready.

```js
// stream-server.mjs — serves the same page in two delivery forms
import { createServer } from "node:http";

const wait = (ms) => new Promise((c) => setTimeout(c, ms));

// The page's three sections; each comes from a separate data source and takes 200 ms.
const SECTIONS = [
  ["heading", '<h1>North Slope Measurement Station</h1>'],
  ["measurements", '<ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li><li>08:00 -2.1 C</li></ul>'],
  ["comment", '<p>19 measurements below freezing in the last 24 hours.</p>'],
];
const read = async ([, body]) => { await wait(200); return body; };

const START = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>North Slope</title></head><body>`;
const END = `</body></html>\n`;

createServer(async (request, response) => {
  response.sendDate = false;
  if (request.url === "/single-chunk") {
    const chunks = [];
    for (const section of SECTIONS) chunks.push(await read(section));
    const body = START + chunks.join("") + END;
    response.setHeader("Content-Type", "text/html; charset=utf-8");
    response.setHeader("Content-Length", Buffer.byteLength(body));
    response.writeHead(200).end(body);
  } else if (request.url === "/streamed") {
    response.setHeader("Content-Type", "text/html; charset=utf-8");
    response.writeHead(200);            // Content-Length is not written: chunked delivery begins
    response.write(START);              // the shell heads out without waiting
    for (const section of SECTIONS) response.write(await read(section));
    response.end(END);
  } else {
    response.writeHead(404).end();
  }
}).listen(8175, "127.0.0.1", () => console.log("listening: 127.0.0.1:8175"));
```

Seeing when the chunks arrive requires a reader; `curl` reports only the first and last
moments.

```js
// reader.mjs — writes down the moments a response's chunks arrive
// Usage: node reader.mjs /streamed
import { get } from "node:http";

const path = process.argv[2] ?? "/streamed";
const start = performance.now();
const elapsed = () => (performance.now() - start).toFixed(0).padStart(4);

get({ host: "127.0.0.1", port: 8175, path }, (response) => {
  console.log(`${elapsed()} ms  response header   Transfer-Encoding: ` +
    `${response.headers["transfer-encoding"] ?? "(none)"}   ` +
    `Content-Length: ${response.headers["content-length"] ?? "(none)"}`);
  response.on("data", (chunk) => {
    const preview = chunk.toString().replace(/\n/g, " ").slice(0, 46);
    console.log(`${elapsed()} ms  chunk ${String(chunk.length).padStart(3)} B   ${preview}`);
  });
  response.on("end", () => console.log(`${elapsed()} ms  response finished`));
});
```

The script below runs in the same directory as these two files. Port 8175 is chosen
arbitrarily and must be free; if it is in use, change it in all three files.

```bash
#!/usr/bin/env bash
# Starts stream-server.mjs, compares the two delivery forms, then stops it.
node stream-server.mjs > /dev/null &
server=$!
sleep 1

echo "--- time to first byte (curl) ---"
for path in /single-chunk /streamed; do
  printf '  %-14s ' "$path"
  curl -sS -o /dev/null -w 'first byte %{time_starttransfer} s   total %{time_total} s\n' \
    "http://127.0.0.1:8175$path"
done

echo "--- /single-chunk: arrival of the chunks ---"
node reader.mjs /single-chunk
echo "--- /streamed: arrival of the chunks ---"
node reader.mjs /streamed

kill "$server"
```

```
--- time to first byte (curl) ---
  /single-chunk  first byte 0.611729 s   total 0.611903 s
  /streamed      first byte 0.001132 s   total 0.606853 s
--- /single-chunk: arrival of the chunks ---
 611 ms  response header   Transfer-Encoding: (none)   Content-Length: 285
 613 ms  chunk 285 B   <!doctype html> <html lang="en"><head><meta ch
 614 ms  response finished
--- /streamed: arrival of the chunks ---
   3 ms  response header   Transfer-Encoding: chunked   Content-Length: (none)
   3 ms  chunk  99 B   <!doctype html> <html lang="en"><head><meta ch
 206 ms  chunk  40 B   <h1>North Slope Measurement Station</h1>
 408 ms  chunk  72 B   <ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li>
 610 ms  chunk  59 B   <p>19 measurements below freezing in the last 
 610 ms  chunk  15 B   </body></html> 
 612 ms  response finished
```

Duration fields depend on the machine; they come out different on every run. The
interpretation rests on two ratios.

**Time to first byte shrank by roughly five hundred times.** In the single-chunk response,
the first byte waits on all three sections; in the streamed response, the shell heads out
without asking any data source first. **Total time did not change**: both responses finish
in about 610 milliseconds. Streaming does not reduce the work; it starts sending the
work's result **without waiting** for it.

The difference in the headers is this behavior's counterpart at the transport layer.
Because the body's length is unknown when delivery begins, `Content-Length` cannot be
written; transport is chunked with `Transfer-Encoding: chunked`, and each chunk reports its
own length.

The gain finds its counterpart on screen too, because the document parser processes
incoming bytes without waiting for the rest: once the shell arrives, style files can be
requested; once the heading arrives, it can be painted. Content appears in sequence.

#### The Constraints Streaming Brings

The response's header goes out with the first chunk and cannot be changed afterward. This
has three consequences.

**The status code cannot be taken back.** If the third section's data cannot be retrieved,
500 cannot be returned; the header has already gone out as 200. The error has to be told
as a section inside the body. This is why, in a streamed response, every section defines
its own error markup.

**Redirection cannot be done.** Decisions that could cancel the entire response, such as
authentication, must be made before the first byte is sent.

**Order becomes a design decision.** Sections go out in the order they are written in the
document. If the slowest section is placed first, streaming's gain disappears. This is why
sections are ordered not by their speed but by what the user needs to see first, and a
loading indicator is written in place of slow sections until they arrive.

#### Splitting Up Hydration

Streaming moves the document earlier; hydration's cost stays where it was. The way to
reduce it is to not hydrate the entire page.

**Partial hydration** hydrates only the interactive components. The measurement table is a
block of text; it listens to no event and holds no state. Hydrating a section like this is
wasted work.

**Lazy hydration** means not hydrating interactive components immediately either. A chart
that sits outside the first screen can be hydrated when it enters the viewport, or on
first interaction. The intersection observer, introduced in the Observers lesson, is the
natural tool for this trigger.

```js
// hydration.mjs — first-load cost of the same page under three hydration modes
// Inputs are assumptions; the output is these assumptions' arithmetic, not a measurement.

const CORE_KB = 25;      // shared runtime shipped in every mode
const PARSING_MS_KB = 0.35;
const NODE_MS = 0.02;    // cost of wiring one node up for interaction

// The station page's component breakdown.
const COMPONENTS = [
  { name: "page shell",        nodes: 120, interactive: false, kb: 4,  firstScreen: true },
  { name: "navigation menu",   nodes: 40,  interactive: true,  kb: 6,  firstScreen: true },
  { name: "measurement table", nodes: 520, interactive: false, kb: 9,  firstScreen: true },
  { name: "filter bar",        nodes: 30,  interactive: true,  kb: 14, firstScreen: true },
  { name: "chart",             nodes: 260, interactive: true,  kb: 48, firstScreen: false },
  { name: "comment box",       nodes: 45,  interactive: true,  kb: 12, firstScreen: false },
  { name: "footnotes",         nodes: 90,  interactive: false, kb: 3,  firstScreen: true },
];

const sum = (list, field) => list.reduce((t, b) => t + b[field], 0);
const TOTAL_KB = sum(COMPONENTS, "kb");
const INTERACTIVE = COMPONENTS.filter((b) => b.interactive);

// Three modes: what gets hydrated on first load, and whose code is needed at all, differ.
const MODES = [
  ["full hydration", () => true, COMPONENTS],
  ["partial hydration", (b) => b.interactive, INTERACTIVE],
  ["lazy hydration", (b) => b.interactive && b.firstScreen, INTERACTIVE],
];

console.log("mode".padEnd(24) + "first load KB".padStart(15) + "nodes".padStart(8) +
  "first load ms".padStart(15) + "later KB".padStart(11) + "never sent".padStart(13));
for (const [name, selector, needed] of MODES) {
  const hydrated = COMPONENTS.filter(selector);
  const firstLoadKb = CORE_KB + sum(hydrated, "kb");
  const nodes = sum(hydrated, "nodes");
  const duration = firstLoadKb * PARSING_MS_KB + nodes * NODE_MS;
  const later = sum(needed, "kb") - sum(hydrated, "kb");
  const never = TOTAL_KB - sum(needed, "kb");
  console.log(name.padEnd(24) + firstLoadKb.toFixed(0).padStart(15) + String(nodes).padStart(8) +
    duration.toFixed(1).padStart(15) + later.toFixed(0).padStart(11) +
    never.toFixed(0).padStart(13));
}

const totalNodes = sum(COMPONENTS, "nodes");
const interactiveNodes = sum(INTERACTIVE, "nodes");
console.log(`\ntotal nodes ${totalNodes}, interactive nodes ${interactiveNodes} ` +
  `(${((interactiveNodes / totalNodes) * 100).toFixed(0)}%)`);
```

```
mode                      first load KB   nodes  first load ms   later KB   never sent
full hydration                      121    1105           64.4          0            0
partial hydration                   105     375           44.3          0           16
lazy hydration                       45      70           17.1         60           16

total nodes 1105, interactive nodes 375 (34%)
```

The last line is the basis for the decision: only 34 percent of the page's nodes are
interactive. Full hydration also performs matching for the remaining 66 percent and ships
those components' code.

In the partial mode, first-load time drops from 64.4 milliseconds to 44.3 milliseconds;
the gain comes from the nodes. In the lazy mode, the time falls to 17.1 milliseconds,
because the first load has only 70 nodes and 45 kilobytes; 60 kilobytes are left for
later. The last column shows that the static components' 16 kilobytes of code are **never
sent at all**: this is not a deferred cost but one that disappears entirely.

The lazy mode's cost is paid on the first touch of the deferred component. When the user
clicks the chart, 48 kilobytes of code are requested, and the interaction is delayed by
that much. This is why deferral is applied to components outside the first screen, or ones
unlikely to be used — not to where the first interaction is expected.

#### What the Two Together Produce

Streaming and partial hydration touch two different line items and do not substitute for
each other. Streaming moves the document's first byte earlier: the user sees content
sooner. Partial hydration moves the moment of time to interactive earlier: the user can
click sooner.

When the two are used together, every section of the page matures at its own pace. The
shell arrives immediately, sections appear in sequence, and interactive sections come
alive as their code arrives. This arrangement's natural measure is not a single number; it
is a per-section pair of "appeared" and "hydrated" moments.

The cost paid is complexity. Section boundaries, section order, each section's loading and
error state, hydration triggers — all of it has to be defined explicitly. If a page has
three sections and two deferred components, the number of states to test is many times
more than for a single-chunk response.

#### Summary

- Hydration is matching the static markup that arrives from the server against the
  client's component tree to make it interactive; it has three costs: downloading the
  code, parsing it, and matching per node.
- Matching's condition is determinism: if the tree the server and the client produce
  diverges, the affected section is either produced from scratch or stays inconsistent.
- Streaming moves the first byte earlier but does not change the total time; because the
  body's length is unknown, chunked transfer is used instead of `Content-Length`.
- Streaming's cost is that the header cannot be taken back: status code and redirection
  decisions must be made before the first byte is sent, and errors must be told inside the
  body.
- Partial hydration hydrates only interactive components, lazy hydration hydrates only
  those on the first screen; on the measured page, first-load time drops from 64.4
  milliseconds to 44.3 and 17.1 milliseconds respectively.

#### Next Step

These two techniques leave the distance between the server and the user as it was. The
first lesson's chain calculation assumed a 60-millisecond network round trip, and this
line item did not shrink with a code change: the signal has to cross the physical distance
in between. If the server sits in a single place, a distant user pays this cost on every
round trip. The next lesson takes up bringing rendering geographically closer to the user:
converting distance into latency, what a layer running at the edge can and cannot do, and
the division of labor with the origin server.

### Edge Rendering

Source: https://academia.sh/en/courses/rendering-strategies/edge-rendering

Bringing rendering geographically closer to the user; converting distance into latency, the edge node's passthrough, combination, and full-production forms, the edge runtime's constraints, and the question of where the data sits.

The previous two techniques changed the response's delivery form and the moment
interactivity is gained; they left the distance between the server and the user as it was.
The first lesson's chain calculation assumed a 60-millisecond network round trip, and it
was said that this line item does not shrink with a code change: it depends on the
distance the signal has to cross.

This lesson takes up shortening that path: moving part or all of the rendering to a node
that sits close to the user.

#### Distance Sets a Lower Bound

Light travels through fiber slower than through vacuum, at roughly 200,000 kilometers per
second. This gives a lower bound that no software can beat.

```js
// distance.mjs — converting geographic distance to latency and the round trips the edge gains
// Light travels through fiber at roughly 200,000 km/s: this is a lower bound,
// real paths are not straight lines, and processing time is added at every hop.
const SPEED = 200000; // km/s
const trip = (km) => (2 * km / SPEED) * 1000; // ms, round trip

console.log("distance (km)".padStart(14) + "one way (ms)".padStart(14) + "trip (ms)".padStart(11));
for (const km of [50, 500, 2500, 9000, 16000]) {
  console.log(String(km).padStart(14) + (trip(km) / 2).toFixed(1).padStart(14) +
    trip(km).toFixed(1).padStart(11));
}

// The number of round trips a page pays before first appearance:
// 1 transport handshake + 1 encryption handshake + 1 document request + 1 subresource trip.
const TRIP_COUNT = 4;
const SERVER_WORK = 40;  // ms, data source + template
const EDGE_WORK = 2;     // ms, the edge node's own work

const USER_TO_EDGE = 50;      // km
const USER_TO_ORIGIN = 9000;  // km

const originOnly = TRIP_COUNT * trip(USER_TO_ORIGIN) + SERVER_WORK;
const edgeHit = TRIP_COUNT * trip(USER_TO_EDGE) + EDGE_WORK;
const edgeMiss = edgeHit + trip(USER_TO_ORIGIN - USER_TO_EDGE) + SERVER_WORK;

console.log("\nscenario".padEnd(34) + "duration (ms)".padStart(15) + "relative to origin".padStart(20));
for (const [name, duration] of [
  ["origin server only", originOnly],
  ["edge, has a copy", edgeHit],
  ["edge, had to ask origin", edgeMiss],
]) {
  console.log(name.padEnd(34) + duration.toFixed(1).padStart(15) +
    (duration / originOnly).toFixed(2).padStart(20));
}
```

```
 distance (km)  one way (ms)  trip (ms)
            50           0.3        0.5
           500           2.5        5.0
          2500          12.5       25.0
          9000          45.0       90.0
         16000          80.0      160.0

scenario                           duration (ms)  relative to origin
origin server only                          400.0                1.00
edge, has a copy                              4.0                0.01
edge, had to ask origin                     133.5                0.33
```

The upper table is a design constraint on its own: a single round trip to a server nine
thousand kilometers away cannot come in under 90 milliseconds. This number is optimistic;
real paths are not straight lines, and processing time is added at every hop.

The lower table shows the round trips' **multiplying** effect. If a page's appearance needs four round
trips, this comes to 360 milliseconds on a distant server, and the data source's 40-millisecond work drops
to a tenth of the total. Speeding up the server's work is wasted effort here; what needs shrinking is the
length of the round trips.

The third row also states the edge's limit: if the edge node cannot satisfy the request
and has to ask the origin, the handshakes get closer, but the content still comes from far
away, and the duration drops to a third of the origin's, not to a hundredth.

#### The Edge Node's Three Forms of Work

Having a node at the edge says nothing on its own; what decides is what that node does.
Three forms can be measured separately. In the origin server below, geographic distance is
modeled as a fixed delay added before the response; it is not a real network measurement.

```js
// origin-server.mjs — the distant origin server
// Geographic distance is modeled as a fixed delay added before the response.
import { createServer } from "node:http";

const DISTANCE = 80;   // ms, cost of a round trip to the origin
const DATA_WORK = 40;  // ms, reading from the data source

const wait = (ms) => new Promise((c) => setTimeout(c, ms));

const SHELL_CONTENT = '<h1>North Slope Measurement Station</h1>' +
  '<ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li><li>08:00 -2.1 C</li></ul>';

const send = (response, body) => {
  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
};

createServer(async (request, response) => {
  response.sendDate = false;
  await wait(DISTANCE);
  if (request.url === "/page") {
    await wait(DATA_WORK);
    send(response, `<!doctype html><html lang="en"><body>${SHELL_CONTENT}` +
      `<p>Operator: (produced at origin)</p></body></html>\n`);
  } else if (request.url === "/section/personal") {
    await wait(5); // small, person-specific section
    send(response, "<p>Operator: A. Carter</p>");
  } else if (request.url === "/data/measurements") {
    await wait(DATA_WORK);
    response.setHeader("Content-Type", "application/json");
    const body = JSON.stringify([
      { time: "06:00", temperature: -4.2 },
      { time: "07:00", temperature: -3.8 },
      { time: "08:00", temperature: -2.1 },
    ]);
    response.setHeader("Content-Length", Buffer.byteLength(body));
    response.writeHead(200).end(body);
  } else {
    response.writeHead(404).end();
  }
}).listen(8176, "127.0.0.1", () => console.log("origin: 127.0.0.1:8176"));
```

```js
// edge-server.mjs — a node close to the user, able to run code, at the edge
// Access to the origin server is over 8176; the distance to the user is ignored.
import { createServer } from "node:http";

const ORIGIN = "http://127.0.0.1:8176";

// What the edge holds: the shell from a build output, measurements from the origin.
const SHELL = (content, personal) =>
  `<!doctype html><html lang="en"><body>${content}${personal}</body></html>\n`;
const HEADING = "<h1>North Slope Measurement Station</h1>";
let measurementsCopy = null;

const send = (response, body) => {
  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(body));
  response.writeHead(200).end(body);
};

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

createServer(async (request, response) => {
  response.sendDate = false;
  if (request.url === "/passthrough") {
    // The edge only passes through: all the work happens at the origin.
    const originResponse = await fetch(`${ORIGIN}/page`);
    send(response, await originResponse.text());
  } else if (request.url === "/combine") {
    // The shell is ready at the edge; only the small personal section is requested from origin.
    const section = await (await fetch(`${ORIGIN}/section/personal`)).text();
    send(response, SHELL(HEADING + list(measurementsCopy ?? []), section));
  } else if (request.url === "/at-edge") {
    // The whole page is produced from the edge's own copy; the origin is never asked.
    send(response, SHELL(HEADING + list(measurementsCopy ?? []), "<p>Operator: (general)</p>"));
  } else if (request.url === "/refresh") {
    // Refreshes the edge's data copy from the origin.
    measurementsCopy = await (await fetch(`${ORIGIN}/data/measurements`)).json();
    send(response, `<p>${measurementsCopy.length} measurements received</p>\n`);
  } else {
    response.writeHead(404).end();
  }
}).listen(8177, "127.0.0.1", () => console.log("edge: 127.0.0.1:8177"));
```

The script below runs in the same directory as these two files. Ports 8176 and 8177 are
chosen arbitrarily and must be free; if they are in use, change them in all three files.

```bash
#!/usr/bin/env bash
# Starts two servers, measures three divisions of work, then stops them.
node origin-server.mjs > /dev/null &
origin=$!
node edge-server.mjs > /dev/null &
edge=$!
sleep 1

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

printf '%-38s' "0) edge refreshing its copy"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/refresh

printf '%-38s' "1) direct to origin server"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8176/page

printf '%-38s' "2) edge only passing through"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/passthrough

printf '%-38s' "3) edge combining"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/combine

printf '%-38s' "4) edge producing entirely itself"
curl -sS -o /dev/null -w "$FORMAT" http://127.0.0.1:8177/at-edge

echo "--- body produced at the edge ---"
curl -sS http://127.0.0.1:8177/at-edge

kill "$origin" "$edge"
```

```
0) edge refreshing its copy           first byte 0.147992 s   body 31 B
1) direct to origin server            first byte 0.125484 s   body 201 B
2) edge only passing through          first byte 0.130341 s   body 201 B
3) edge combining                     first byte 0.094478 s   body 190 B
4) edge producing entirely itself     first byte 0.000806 s   body 190 B
--- body produced at the edge ---
<!doctype html><html lang="en"><body><h1>North Slope Measurement Station</h1><ul><li>06:00 -4.2 C</li><li>07:00 -3.8 C</li><li>08:00 -2.1 C</li></ul><p>Operator: (general)</p></body></html>
```

The zeroth row is not part of the measurement; it is the preparation step where the edge
fills its data copy. Duration fields depend on the machine, and the interpretation rests
on the ratios.

**The second row is the edge used wrong.** A node that only passes through adds its own
work to the origin's duration and is **slower** than direct access — roughly four percent
in the measured run. The gap's size varies from run to run, but not its sign: a node in
between carries no gain by itself; the gain comes from the work it takes on.

**The third row is division of labor.** The shell and the measurement list are ready at
the edge; only the small, person-specific section is requested from the origin. The
duration drops to roughly three-quarters of direct access. The gain does not come from
never going to the origin — it comes from asking the origin for **little**.

**The fourth row is full production at the edge.** The origin is never asked; the duration
shrinks by two orders of magnitude — roughly a hundred and fifty times in the measured
run. This condition requires everything needed to produce the page to be present at the
edge.

#### The Constraints of the Edge Runtime

Running code at the edge is not the same as running code on the origin server. The
constraints arise from what the edge is by definition: many tenants run side by side on
many nodes, each with few resources.

**The process is not long-lived.** In-memory state that persists outside a request cannot
be assumed; a counter kept on one node is not present on another. State is held either in
the request itself or in a shared store.

**The processing and memory budget is tight.** Heavy template processing, a large
dependency tree, or a long-running computation does not suit the edge. Code meant to run
at the edge is smaller than code running on the origin server, and this is a limit, not a
preference.

**The first call's cost is separate.** A startup duration arises when a piece of code that
has not been called for a while gets reloaded. The milliseconds the edge gains can be on
the same order as this duration; this is why rarely called paths do not get faster by
being moved to the edge.

**The environment is not the same from node to node.** Code running at the edge may not
have all the capabilities of code on the origin server. A file system, long-lived
connections, and some libraries may be absent, and code is written with that assumption.

#### The Real Question Is Where the Data Sits

The gain in the measurement's fourth row arose from the edge carrying a copy of the
measurement list. This copy was fetched from the origin in the zeroth step, and it starts
aging from the moment it was fetched.

This gives edge rendering's real rule: **rendering can move to the edge only as far as its
data can move with it.** If the code moved to the edge asks the origin for data on every
request, the distance is paid again; the third row measures this. The full gain is only
realized when the data is also at the edge.

Moving data to the edge takes three forms, each with its own cost. Rarely changing data is
copied; its cost is freshness, and the window calculation from the Incremental
Regeneration lesson holds here too. Small, user-bound data travels in the request itself;
its cost is that it must be signed so it cannot be tampered with. Everything else stays at
the origin, paying the distance for those sections.

This three-way split requires the page to be divided into sections. On the station page,
the shell and the measurement list can be copied, the operator's name can travel in the
request, and edit permissions are checked at the origin. A page that is a single piece
cannot be split this way, and the entire page follows its most restrictive section's rule.

#### Summary

- Geographic distance sets a lower bound on latency: for nine thousand kilometers, a
  single round trip does not come in under 90 milliseconds, and it is multiplied by the
  round trip count paid per page.
- Having a node at the edge is not a gain on its own; a node that only passes through is
  slower than direct access.
- An edge that asks the origin for only a small piece brings the duration down to
  three-quarters of direct access; an edge that never asks brings it under one percent.
- The edge runtime does not assume long-lived memory state, works with a tight processing
  and memory budget, incurs a startup duration on the first call, and has narrower
  capabilities than the origin server's.
- Rendering can move to the edge only as far as its data can move; in a design where the
  page is not split into sections, the whole page follows the most restrictive section's
  rule.

#### Next Step

Six models have been measured separately, and each came out ahead in a specific situation.
What remains is tying these measurements to a single decision. The North Slope site has
seven distinct page types, and there is no reason for all of them to share a model: the
landing page never changes, the measurement list changes every minute, the archive pages
number in the thousands, the panel is personal. The next lesson places this inventory on
three axes — content freshness, personalization, and scale — and builds a decision rule
that derives the model for each page from these axes.

### Strategy Selection

Source: https://academia.sh/en/courses/rendering-strategies/strategy-selection

Choosing the rendering model per page; tying the content freshness, personalization, and scale axes to measurement, a sequential decision rule, and comparing uniform policies against the mixed policy by daily work and violation count.

Six models were measured separately, and each one came out ahead in a specific situation.
Client-side rendering freed subsequent navigation from the network, server-side rendering
moved first content earlier, static generation zeroed out the work done at request time,
incremental regeneration freed freshness from build frequency, streaming with hydration
narrowed the interval into sections, edge rendering cut the distance.

One question remains: which one for a given page? This lesson takes that question out of
the realm of taste and ties it to three measurable axes.

#### Three Axes

**Content freshness** is how old the information a page shows can be at most, written in
seconds. This is not a preference but a requirement that comes from the nature of the
content: the station's founding information never ages, a daily archive does not change
once it closes, and the live measurement list turns misleading once it is more than a
minute old.

Writing down freshness separates two things: how often the content **changes**, and the
value shown's **acceptable age**. A measurement may change every minute, but if the page
displays the measurement's timestamp, a five-minute age may be acceptable. The decision is
made on the second one.

**Personalization** is whether the response varies per user, and it takes three values. If
the response is the same for everyone, there is no personalization. If only a small part
of the page depends on the person — a name, a permission badge, a threshold warning —
personalization is partial. If the page's structure is built per user, it is full.

This three-way distinction matters because partial personalization preserves the
cacheability of the rest of the page. The third measurement in the Edge Rendering lesson
was this distinction's counterpart: with the shell ready at the edge, requesting only the
small section from the origin brought the duration down to three-quarters.

**Scale** is two separate numbers and must not be confused. **Page count** determines
build cost; **request volume** determines runtime cost. The linearity measured in the
Static Site Generation lesson concerns the first; the per-request cost in the Server-Side
Rendering lesson concerns the second. Four thousand archive pages and eight hundred daily
panel requests produce two entirely different limits.

#### Inventory and Decision Rule

Once the axes are written down, the decision becomes a table lookup. The rule below is
tested in order; the first rule that holds wins, and the order is not arbitrary:
personalization is tested first because it removes cacheability entirely; scale comes next
because it closes off the build path; freshness comes last and sets the window.

```js
// decision.mjs — the station site's page inventory, model selection, and policy comparison
// Inputs are assumptions; the output is these assumptions' arithmetic, not a measurement.

const DAY = 86400;               // seconds
const PRODUCTION_MS = 40;        // server work to produce one page
const SECTION_MS = 5;            // small section requested from origin in edge composition
const PAGE_THRESHOLD = 2000;     // above this, producing everything on every build is expensive

// freshness: how old content can be before it is served (seconds), Infinity = never changes
// personal: "none" | "partial" | "full"
const INVENTORY = [
  { path: "/",                  freshness: DAY,      personal: "none",    pages: 1,    requests: 4000 },
  { path: "/measurements",      freshness: 60,       personal: "none",    pages: 1,    requests: 30000 },
  { path: "/measurements/:day", freshness: Infinity, personal: "none",    pages: 4000, requests: 12000 },
  { path: "/panel",             freshness: 5,        personal: "full",    pages: 1,    requests: 800 },
  { path: "/map",               freshness: 7 * DAY,  personal: "none",    pages: 1,    requests: 2500 },
  { path: "/about",             freshness: Infinity, personal: "none",    pages: 3,    requests: 900 },
  { path: "/warning",           freshness: 60,       personal: "partial", pages: 1,    requests: 5000 },
];

// Decision rule: tested in order, the first rule that holds wins.
function model(s) {
  if (s.personal === "full") return "server-side rendering";
  if (s.personal === "partial") return "edge composition";
  if (s.pages > PAGE_THRESHOLD) return "incremental regeneration";
  if (s.freshness === Infinity) return "static site generation";
  if (s.freshness >= 3600) return "static site generation";
  if (s.freshness >= 30) return "incremental regeneration";
  return "server-side rendering";
}

// Daily server work (seconds). In incremental regeneration, the first miss is paid once,
// so the steady state is what counts; content that never changes is not regenerated.
function dailyWork(s, choice) {
  switch (choice) {
    case "static site generation":
      return (s.pages * PRODUCTION_MS) / 1000;                     // one build per day
    case "server-side rendering":
      return (s.requests * PRODUCTION_MS) / 1000;
    case "edge composition":
      return (s.requests * SECTION_MS) / 1000;
    case "incremental regeneration": {
      if (s.freshness === Infinity) return 0;
      const production = Math.min(s.requests, s.pages * (DAY / s.freshness));
      return (production * PRODUCTION_MS) / 1000;
    }
    default:
      return 0;
  }
}

console.log("path".padEnd(20) + "freshness".padStart(10) + "personal".padStart(10) +
  "pages".padStart(7) + "requests".padStart(10) + "   chosen model");
for (const s of INVENTORY) {
  const f = s.freshness === Infinity ? "never" : `${s.freshness} s`;
  console.log(s.path.padEnd(20) + f.padStart(10) + s.personal.padStart(10) +
    String(s.pages).padStart(7) + String(s.requests).padStart(10) + "   " + model(s));
}

// Policies: three uniform choices, and the mixed choice the decision rule produces.
const POLICIES = [
  ["all server-side", () => "server-side rendering"],
  ["all static", () => "static site generation"],
  ["all incremental", () => "incremental regeneration"],
  ["decision rule", model],
];

console.log("\npolicy".padEnd(18) + "daily work (s)".padStart(16) +
  "freshness violation".padStart(22) + "personalization violation".padStart(28));
for (const [name, choose] of POLICIES) {
  let work = 0, freshnessViolation = 0, personalViolation = 0;
  for (const s of INVENTORY) {
    const choice = choose(s);
    work += dailyWork(s, choice);
    // Static generation builds once a day: it cannot satisfy a freshness need shorter than a day.
    if (choice === "static site generation" && s.freshness < DAY) freshnessViolation += 1;
    // A person-specific section cannot be produced by models that give everyone the same response.
    if (s.personal !== "none" &&
        (choice === "static site generation" || choice === "incremental regeneration")) {
      personalViolation += 1;
    }
  }
  console.log(name.padEnd(18) + work.toFixed(1).padStart(16) +
    String(freshnessViolation).padStart(22) + String(personalViolation).padStart(28));
}
```

```
path                 freshness  personal  pages  requests   chosen model
/                      86400 s      none      1      4000   static site generation
/measurements             60 s      none      1     30000   incremental regeneration
/measurements/:day       never      none   4000     12000   incremental regeneration
/panel                     5 s      full      1       800   server-side rendering
/map                  604800 s      none      1      2500   static site generation
/about                   never      none      3       900   static site generation
/warning                  60 s   partial      1      5000   edge composition

policy             daily work (s)   freshness violation   personalization violation
all server-side             2208.0                     0                           0
all static                   160.3                     3                           2
all incremental              147.2                     0                           2
decision rule                114.8                     0                           0
```

The upper table's third row justifies the rule's order: the archive pages never change and
belong to static generation by plain logic, but four thousand pages are above the
threshold. The rule therefore sends them to incremental regeneration instead — pages are
produced the first time they are requested, then, because they do not change, never
produced again. This row's contribution to the daily work total is zero.

The seventh row is partial personalization's counterpart. The warning page's body is the
same for everyone; only the threshold warning depends on the person. If the whole page
were produced on the server, 40 milliseconds would be paid per request; with edge
composition, it drops to 5 milliseconds.

#### Comparing the Policies

The lower table shows, in three columns, why a uniform policy is not enough.

**All server-side** violates no constraint and is the most expensive: 2208 seconds of
server work per day, roughly nineteen times the decision rule. This is the cost of
subjecting every page to the most restrictive page's rule.

**All static** comes cheap but cannot satisfy three page types' freshness requirement or
two page types' personalization. These columns are not measured in currency; an
unsatisfied requirement is a flaw that cheapness cannot make up for.

**All incremental** closes the freshness violation — the window can be tuned to the
content's requirement — but it cannot solve personalization, because a shared copy cannot
be person-specific.

**The decision rule** is both violation-free and the cheapest. This is not a coincidence:
because every page gets the cheapest model its own constraints allow, the total comes out
lowest too. The mixed policy's cost is not the average of the uniform policies' costs — it
is their lower bound.

#### Criteria the Axes Do Not Capture

The three axes give most of the decision; not all of it. Four more criteria exist, and
they can change the choice.

**The markup being read by a machine.** If the content needs to be read by clients that do
not execute scripts, client-side rendering is eliminated. The zero measured in the first
lesson was this constraint's concrete form.

**First-visit rate.** If most visitors see a single page and leave, the speed client-side
rendering gains on subsequent navigation never gets used; the first-load cost is paid
continuously. The same interface reverses when it is a panel left open for hours a day.

**Failure resilience.** What happens when the data source does not respond varies by
model: static output keeps being served, incremental regeneration gives the old copy,
server-side rendering cannot produce the document at all. Pages that need to stay visible
during an outage are chosen by this criterion.

**Operational complexity.** On a site where five separate models coexist, every model has
its own deployment path, its own caching behavior, and its own failure mode. The
server-hours the decision rule saves are weighed together with the rise in testing and
diagnostic burden. Working with a small number of models is also a choice.

#### The Decision Gets Revisited

The values in the inventory are not fixed. The archive page count grows by one every day,
and once it crosses the threshold, the choice changes. When a person-specific section is
added to a page, the personalization column turns from "none" to "partial", and the model
changes. When request volume grows tenfold, server-side rendering's cost grows by the same
factor.

This is why the decision rule is kept as a table embedded in code, and the inventory's
values are fed from measurement: page count from the build output, request volume from
access logs, freshness requirements from the content's owner. An inventory filled in with
guesses gives a wrong rule's correct-looking result.

#### Summary

- The rendering model is chosen per page type, not for the site as a whole; the decision's
  three measurable axes are content freshness, personalization level, and scale.
- Scale is two separate numbers: page count determines build cost, request volume
  determines runtime cost.
- A sequential decision rule — personalization first, then scale, freshness last — maps
  the inventory to models and makes the choice reproducible.
- On the measured inventory, the mixed policy is both violation-free and cheaper than the
  cheapest uniform policy; every page gets the cheapest model its own constraints allow.
- Beyond the three axes, four more criteria enter the decision: the markup being read by a
  machine, first-visit rate, expected behavior when the data source is cut off, and
  operational complexity.

#### Next Step

The strategy is chosen: every page type knows which model produces it. But every model
assumes the same thing — that a runnable output is ready. Static generation needs a
builder to run, server-side rendering needs a bundle to load onto the server, edge
composition needs a small module to ship to the edge, and hydration needs component code
to land on the client. What produces these outputs is a process that resolves the
dependency relationship among source files and decides which code goes into which piece.
The next topic opens with that: the Module Bundling lesson takes up how the dependency
graph is built, and by which criteria it is split into how many pieces.

## Build and Bundling

The source tree turning into executable output; producing chunks from the dependency graph, code splitting, tree shaking, the asset pipeline, content-hashed naming, the development server, and build-time configuration.

### Module Bundling

Source: https://academia.sh/en/courses/rendering-strategies/module-bundling

The source tree turning into an executable output; extracting the dependency graph from entry points, evaluation order, producing output chunks by usage signature, and the runtime inside a chunk.

The strategy has been chosen. Which page of the North Slope Measurement Station site
gets produced where is settled: the landing page at build time, the measurement list on
the server, the operator's panel on the client, the routing decision at the edge. These
decisions shared one assumption, and that assumption has not been met yet. Every
strategy requires an executable **output**: build-time generation requires a program
ready to stamp out pages; server-side rendering requires a module ready to load on the
server; client-side rendering requires a script ready to ship to the browser.

The source tree is none of these. It holds dozens of small files, import relationships
between them, and specifiers the target environment cannot resolve on its own. This
lesson's question is: from the dependency relationship between source files, which
output chunks get produced, and by what criterion?

#### Source Layout and Output Layout Are Not the Same Thing

The Bundler Concept lesson in the Modules, Tooling and the Ecosystem course established
this distinction: source layout is designed for readability, output layout for the
target environment's constraints. That lesson produced a single output from a single
entry. The question changes the moment a rendering strategy selects more than one
target. If the same source tree produces both a script bound for the browser and a
module meant to run on the server, the two outputs share modules. How that overlap is
handled determines both output size and caching behavior.

**Module bundling** is the work of finding the set of modules reachable from the entry
points and splitting that set into as many files as the target requires. It contains two
separate decisions: extracting who depends on whom in the graph, and splitting that
graph into chunks.

#### The Application's Source Tree

The same source tree is used throughout the course. The block below produces it.

```bash
mkdir -p source

cat > source/units.js <<'EOF'
export const TEMP_MIN = -60;
export const TEMP_MAX = 60;
export const isWithinRange = (c) => c >= TEMP_MIN && c <= TEMP_MAX;
export const fahrenheit = (c) => c * 9 / 5 + 32;
EOF

cat > source/date.js <<'EOF'
const two = (s) => String(s).padStart(2, "0");
export const hourMinute = (d) => two(new Date(d).getUTCHours()) + ":" + two(new Date(d).getUTCMinutes());
export const dayKey = (d) => new Date(d).toISOString().slice(0, 10);
EOF

cat > source/format.js <<'EOF'
import { hourMinute } from "./date.js";
import { isWithinRange, fahrenheit } from "./units.js";

export function measurementLine(m) {
  const flag = isWithinRange(m.temperature) ? "" : " (out of range)";
  return hourMinute(m.timestamp) + "  " + m.temperature.toFixed(1) + " C  %" + m.humidity + flag;
}
export const dualUnit = (m) => m.temperature.toFixed(1) + " C / " + fahrenheit(m.temperature).toFixed(1) + " F";
EOF

cat > source/template.js <<'EOF'
import { measurementLine } from "./format.js";

export const list = (measurements) =>
  "<ul>" + measurements.map((m) => "<li>" + measurementLine(m) + "</li>").join("") + "</ul>";
export const heading = (station) => "<h1>" + station + "</h1>";
EOF

cat > source/chart.js <<'EOF'
import { TEMP_MIN, TEMP_MAX } from "./units.js";

const scale = (v, length) => ((v - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * length;

export function temperaturePath(measurements, width, height) {
  const step = measurements.length > 1 ? width / (measurements.length - 1) : 0;
  return measurements
    .map((m, i) => (i ? "L" : "M") + (i * step).toFixed(1) + " " +
      (height - scale(m.temperature, height)).toFixed(1))
    .join(" ");
}
EOF

cat > source/archive.js <<'EOF'
import { dayKey } from "./date.js";
import { list } from "./template.js";

export function splitByDay(measurements) {
  const bucket = new Map();
  for (const m of measurements) {
    const d = dayKey(m.timestamp);
    bucket.set(d, [...(bucket.get(d) ?? []), m]);
  }
  return bucket;
}
export const archivePage = (day, measurements) => "<h2>" + day + "</h2>" + list(measurements);
EOF

cat > source/entry-browser.js <<'EOF'
import { list, heading } from "./template.js";
import { temperaturePath } from "./chart.js";

export function start(root, station, measurements) {
  root.innerHTML = heading(station) + list(measurements);
  return temperaturePath(measurements, 320, 120);
}
EOF

cat > source/entry-server.js <<'EOF'
import { list, heading } from "./template.js";
import { splitByDay, archivePage } from "./archive.js";

export function buildPages(station, measurements) {
  const daily = [...splitByDay(measurements)].map(([d, l]) => archivePage(d, l));
  return { live: heading(station) + list(measurements), archive: daily };
}
EOF
```

Eight modules, two entry points. The browser entry paints the screen and produces the
temperature chart; the server entry produces the same list along with the daily archive
pages. The format, template, units, and date modules are used by both.

#### Extracting the Graph

The graph is found by a traversal starting from the entry points. Each module is a node,
each import a directed edge. The Breadth-First Search lesson in the Data Structures
course runs here over the file system: a path comes off the queue, its source is read,
its specifiers are resolved, and the neighbors found are pushed onto the queue.

```js
// graph.mjs — dependency graph and output chunks from the source tree.
import { readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRIES = ["source/entry-browser.js", "source/entry-server.js"];
const IMPORT_RE = /^\s*(?:import|export)[^"']*from\s*["']([^"']+)["']/gm;

function extractGraph(entries) {
  const graph = new Map();                      // path -> { size, neighbors[] }
  const queue = [...entries];
  while (queue.length > 0) {
    const filePath = queue.shift();
    if (graph.has(filePath)) continue;
    const source = readFileSync(filePath, "utf8");
    const neighbors = [...source.matchAll(IMPORT_RE)]
      .map(([, token]) => relative(".", join(dirname(filePath), token)));
    graph.set(filePath, { size: statSync(filePath).size, neighbors });
    queue.push(...neighbors);
  }
  return graph;
}

function closure(graph, root) {                  // all nodes reachable from root
  const visited = new Set(), stack = [root];
  while (stack.length > 0) {
    const filePath = stack.pop();
    if (visited.has(filePath)) continue;
    visited.add(filePath);
    stack.push(...graph.get(filePath).neighbors);
  }
  return visited;
}

function evaluationOrder(graph, root) {      // post-order traversal: dependency first
  const order = [], visited = new Set();
  (function visit(filePath) {
    if (visited.has(filePath)) return;
    visited.add(filePath);
    for (const n of graph.get(filePath).neighbors) visit(n);
    order.push(filePath);
  })(root);
  return order;
}

const graph = extractGraph(ENTRIES);
const name = (p) => p.replace("source/", "");
const edgeCount = [...graph.values()].reduce((t, d) => t + d.neighbors.length, 0);

console.log("-- graph --");
console.log("node:", graph.size, " edge:", edgeCount);
for (const [filePath, d] of [...graph].sort()) {
  console.log("  " + name(filePath).padEnd(20) + String(d.size).padStart(5) + " B  -> " +
    (d.neighbors.map(name).join(", ") || "(leaf)"));
}

console.log("-- evaluation order (browser entry) --");
console.log("  " + evaluationOrder(graph, ENTRIES[0]).map(name).join(" -> "));

const closures = new Map(ENTRIES.map((e) => [e, closure(graph, e)]));
console.log("-- entry closures --");
for (const [e, c] of closures) {
  const bytes = [...c].reduce((t, p) => t + graph.get(p).size, 0);
  console.log("  " + name(e).padEnd(20) + String(c.size).padStart(2) + " module  " +
    String(bytes).padStart(5) + " B");
}

// Signature: the set of entries that request the module. Same signature = same output chunk.
const signature = new Map();
for (const filePath of graph.keys()) {
  const requesters = ENTRIES.filter((e) => closures.get(e).has(filePath));
  const key = requesters.map(name).join(" + ");
  if (!signature.has(key)) signature.set(key, []);
  signature.get(key).push(filePath);
}

console.log("-- output chunks --");
let totalChunks = 0;
for (const [key, modules] of [...signature].sort((a, b) => b[0].length - a[0].length)) {
  const bytes = modules.reduce((t, p) => t + graph.get(p).size, 0);
  totalChunks += bytes;
  console.log("  requested by: " + key);
  console.log("    " + String(bytes).padStart(5) + " B  " + modules.map(name).sort().join(", "));
}
const sourceTotal = [...graph.values()].reduce((t, d) => t + d.size, 0);
console.log("-- total --");
console.log("  source:", sourceTotal, "B   chunks:", totalChunks, "B");
console.log("  no module copied:", sourceTotal === totalChunks);
```

```
$ node graph.mjs
-- graph --
node: 8  edge: 10
  archive.js            383 B  -> date.js, template.js
  chart.js              439 B  -> units.js
  date.js               222 B  -> (leaf)
  entry-browser.js      257 B  -> template.js, chart.js
  entry-server.js       314 B  -> template.js, archive.js
  format.js             417 B  -> date.js, units.js
  template.js           244 B  -> format.js
  units.js              174 B  -> (leaf)
-- evaluation order (browser entry) --
  date.js -> units.js -> format.js -> template.js -> chart.js -> entry-browser.js
-- entry closures --
  entry-browser.js     6 module   1753 B
  entry-server.js      6 module   1754 B
-- output chunks --
  requested by: entry-browser.js + entry-server.js
     1057 B  date.js, format.js, template.js, units.js
  requested by: entry-browser.js
      696 B  chart.js, entry-browser.js
  requested by: entry-server.js
      697 B  archive.js, entry-server.js
-- total --
  source: 2450 B   chunks: 2450 B
  no module copied: true
```

The byte counts are the files' real sizes; these values come out when the source files
are produced exactly as in the block above. Even a one-line edit changes the numbers.

#### Evaluation Order

The graph's first use is ordering. A module's body must run after its dependencies'
bodies have run; otherwise it reaches for a binding that is not yet defined. Post-order
traversal gives this order: `date.js` and `units.js` come first, the entry point comes
last. This is the Topological Sort lesson from the Data Structures course applied to the
graph.

The ordering shows up in the output in two ways. In a chunk written to a single file, the
module bodies are laid out in this order. In output split across multiple files, the
order turns into a loading order for the files: a chunk cannot run until the chunk it
depends on has been evaluated.

If the graph has a cycle, post-order traversal does not give a single correct order. The
module systems topic's discussion of circular dependencies applies here as well: the
bundler does not break the cycle, it only picks an order, and the order it picks
determines which binding gets read early.

#### The Entry Point Set

How many entry points a build has is a direct consequence of the rendering strategy
decision. Code running in the browser needs one entry, code running on the server a
separate entry, a module running at the edge a third entry. Each entry has its own
closure, and the closures intersect: in the example, four of the six modules in each
entry's closure are shared.

Entries differ not only in scope but in target. Two outputs produced from the same
source are written in different formats: the server output as a module the runtime can
load directly, the browser output as a script to attach to the document. Which
dependencies get left external also depends on the target — on the server entry, modules
the runtime supplies itself are not pulled into the graph; on the browser entry no such
source exists, and everything must go into the output.

This means a single file in the source tree can appear in two different forms across two
different outputs. That shared modules must work under both targets is a constraint
placed on the source layout: the shared layer holds only environment-independent code.

#### The Splitting Criterion

Shared modules can be handled two ways. They can be copied into each entry's output;
then the same code sits twice, in two files. Or they can be placed in a separate chunk
and loaded from both entries; then one more file is produced.

The decision rests on a single piece of information readable off the graph: which
entries request a module. This set is called the module's **usage signature**. Modules
with the same signature are needed together, and so can be placed together. Different
signatures produce different chunks.

This is why three chunks show up in the output: the four modules both entries request
sit in one shared chunk, the two modules only the browser requests sit in one chunk, the
two modules only the server requests sit in a third chunk. The check on the last line is
the criterion's proof: if the chunks' total equals the source's total, no module has been
copied into two chunks.

The Code Splitting and Lazy Loading lesson in the Application Architecture course used
the same criterion for route boundaries; there, the set that made up the signature was
routes. The criterion is the same, the source that produces the signature differs:
entries in this lesson, routes there.

#### What's Inside a Chunk

A chunk is not just module bodies written one after another. Each module's own scope
must be preserved, its exports must be visible to other modules, and a module must be
evaluated once even if it is imported more than once. Three things inside the chunk
provide this: module wrappers, a registry that guarantees single evaluation, and a
mapping that translates specifiers into output keys. The Bundler Concept lesson in the
Modules, Tooling and the Ecosystem course built this structure working end to end.

The existence of a shared chunk adds one more thing: a cross-chunk export surface. If the
browser chunk uses two names from `template.js`, the shared chunk must give those names
out. A chunk boundary is therefore not only a file boundary but also an interface
boundary: every name that crosses it must stay visible in the output, and cannot be
renamed away or eliminated.

Scope hoisting is bounded at this point. Modules inside the same chunk can be melted into
a single scope; names that cross a chunk boundary cannot be melted. Increasing the number
of chunks therefore affects not only the request count but also the range of transforms
that can be applied.

#### What the Graph Does Not Say

The graph built here comes only from static imports, and that is one part of a real
build's input.

The specifiers were found with a regular expression. An import-looking string inside a
template literal matches just as well as a line inside a comment does. Real tools parse
the source and work over the abstract syntax tree; extension completion, directory entry
points, bare-specifier resolution, and resolving conditional entries are also that
layer's job.

Static import is not the only kind of edge that enters the graph. Dynamic import is an edge too, but its
timing differs; style files, images, and fonts produce edges as well. Each of these changes how chunks
form; the next lesson adds the dynamic import, and the fourth adds the asset edges.

Last, the graph says a module's entirety is needed; it does not say which name inside it
is used. `units.js` in the shared chunk exports four names, and two of them might never
be used. Seeing that distinction requires bringing the graph down from module level to
name level.

#### Summary

- Module bundling is two decisions: extracting the set of modules reachable from the
  entry points, and splitting that set into output chunks.
- The dependency graph is built by traversal from the entry points; post-order traversal
  gives the evaluation order that places dependencies before what depends on them.
- The number of entry points comes from the rendering strategy decision; each entry has
  its own closure, its own output format, and its own external-dependency boundary.
- Modules are split into chunks by usage signature — the set of entries that request
  them; the chunks' total size equaling the source's total is the proof that no module
  was copied.
- A chunk boundary is also an interface boundary: names that cross it must be preserved
  in the output, and scope hoisting is bounded to inside a chunk.

#### Next Step

The chunks in this lesson were all born from entry points, and all of them were needed
at first load. But not every part of an application is needed at the same time: the
station operator's panel is wanted only when that panel opens, the export screen only
when that button is pressed. The source has a syntax that marks this distinction — an
`import()` call adds an edge to the graph but separates it in time. The next lesson takes
up this boundary: how chunks born from dynamic import are computed, how what downloads
at first load is separated from what downloads later, and how the point at which
increasing the chunk count turns into a loss is measured.

### Code Splitting

Source: https://academia.sh/en/courses/rendering-strategies/code-splitting

Separating static and dynamic edges; determining chunk roots, computing what downloads at first load versus later, per-request cost, and merging over-split chunks.

The chunks in the previous lesson were all born from entry points, and all of them were
needed at first load. All six modules in the browser entry's closure downloaded before
the user touched anything. But that is not true of the North Slope Measurement Station
application: the measurement list is visible on every visit, while the station
operator's panel is needed only once that panel opens. The panel's chart rendering and
export code should not be among the bytes a visitor who has only come to read the page
downloads.

The source has a syntax that marks this distinction. An `import()` call adds a
dependency to the graph but separates it from the first load. This lesson's question is
how that marker changes the output chunks, and where splitting pays off and where it
turns into a loss.

#### Two Kinds of Edge

The graph has two kinds of edge, and the distinction is time.

A **static edge** is a top-of-source import declaration. By the rule established in the
module systems topic, these declarations are resolved before the module is evaluated: a
dependency must be ready before the module that depends on it. Two modules connected by
a static edge are needed at the same time.

A **dynamic edge** is an `import()` call. The call returns a promise; the dependency is
not loaded until the call executes. Two modules connected by a dynamic edge are not
needed at the same time, and a time boundary sits between them.

This time boundary turns into a file boundary on the output side. A dynamic import's
target can be placed in a separate file along with its own dependencies; that file
downloads when the call executes. Such a target is called a **chunk root**. In a build,
the set of chunk roots is the union of the entry points and the dynamic import targets.

#### The Panel Splits Off

The panel and its dependencies are added to the source tree; instead of importing the
chart directly, the browser entry requests the panel dynamically. The block below writes
over the previous lesson's source tree.

The block below writes over the `source/` tree set up in the Module Bundling lesson;
that lesson's setup block must be run in this directory before running this script.

```bash
mkdir -p source

cat > source/alert.js <<'EOF'
import { TEMP_MIN, TEMP_MAX } from "./units.js";

export const thresholdBreaches = (measurements) =>
  measurements.filter((m) => m.temperature < TEMP_MIN || m.temperature > TEMP_MAX);
EOF

cat > source/csv-export.js <<'EOF'
import { dualUnit } from "./format.js";
import { dayKey } from "./date.js";

const escape = (a) => (a.includes(",") ? "\"" + a + "\"" : a);

export function buildCsv(measurements) {
  const headers = ["day", "timestamp", "temperature", "humidity"];
  const rows = measurements.map((m) =>
    [dayKey(m.timestamp), m.timestamp, dualUnit(m), m.humidity].map(escape).join(","));
  return [headers.join(","), ...rows].join("\n");
}
EOF

cat > source/panel.js <<'EOF'
import { temperaturePath } from "./chart.js";
import { buildCsv } from "./csv-export.js";
import { heading } from "./template.js";

export async function renderPanel(root, station, measurements) {
  const { thresholdBreaches } = await import("./alert.js");
  const breaches = thresholdBreaches(measurements);
  root.innerHTML = heading(station + " panel") +
    "<svg><path d=\"" + temperaturePath(measurements, 320, 120) + "\"/></svg>" +
    "<p>" + breaches.length + " breaches</p>";
  return { csv: () => buildCsv(measurements) };
}
EOF

cat > source/entry-browser.js <<'EOF'
import { list, heading } from "./template.js";

export function start(root, station, measurements) {
  root.innerHTML = heading(station) + list(measurements);
  return {
    async openPanel(panelRoot) {
      const { renderPanel } = await import("./panel.js");
      return renderPanel(panelRoot, station, measurements);
    },
  };
}
EOF
```

Two dynamic boundaries formed: the entry point requests the panel, and the panel
requests the threshold-alert module dynamically as well. The second is a nested
boundary, and its cost will be measured separately.

#### From Chunk Roots to Chunks

The previous lesson's signature criterion applies unchanged, only the set that produces
the signature changes: chunk roots instead of entry points. A module's signature is the
set of roots that can reach it statically. A module whose signature contains the entry
point downloads at first load; a module whose signature does not contain it downloads
when one of the roots in its signature is requested.

```js
// split.mjs — chunk graph, cost, and merging from static and dynamic edges.
import { readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRY = "source/entry-browser.js";
const STATIC_RE = /^\s*(?:import|export)[^"']*from\s*["']([^"']+)["']/gm;
const DYNAMIC_RE = /\bimport\(\s*["']([^"']+)["']\s*\)/g;

const graph = new Map();                        // path -> { size, staticImports[], dynamicImports[] }
const queue = [ENTRY];
while (queue.length > 0) {
  const filePath = queue.shift();
  if (graph.has(filePath)) continue;
  const source = readFileSync(filePath, "utf8");
  const resolve = (re) => [...source.matchAll(re)].map(([, b]) => relative(".", join(dirname(filePath), b)));
  const staticImports = resolve(STATIC_RE), dynamicImports = resolve(DYNAMIC_RE);
  graph.set(filePath, { size: statSync(filePath).size, staticImports, dynamicImports });
  queue.push(...staticImports, ...dynamicImports);
}

// Chunk root: the entry point and every dynamic import target.
const roots = [ENTRY, ...[...graph.values()].flatMap((d) => d.dynamicImports)];
const closures = new Map(roots.map((root) => {
  const visited = new Set(), stack = [root];
  while (stack.length > 0) {
    const filePath = stack.pop();
    if (visited.has(filePath)) continue;
    visited.add(filePath);
    stack.push(...graph.get(filePath).staticImports);
  }
  return [root, visited];
}));

// Signature: the set of roots that statically request the module. Same signature = same chunk.
const groups = new Map();
for (const filePath of graph.keys()) {
  const key = roots.filter((r) => closures.get(r).has(filePath)).join("|");
  if (!groups.has(key)) groups.set(key, []);
  groups.get(key).push(filePath);
}

const name = (p) => p.replace("source/", "").replace(".js", "");
const bytes = (paths) => paths.reduce((t, p) => t + graph.get(p).size, 0);
let chunks = [...groups].map(([key, modules], i) => ({
  name: "P" + (i + 1),
  roots: key.split("|"),
  modules,
  size: bytes(modules),
  initial: key.split("|").includes(ENTRY),
}));

const RTT = 60, BANDWIDTH = 192;                // ms; B/ms (1.5 Mbit/s)
const cost = (list) => list.length * RTT + list.reduce((t, c) => t + c.size, 0) / BANDWIDTH;
const asyncChunksFor = (list, root) => list.filter((c) => c.roots.includes(root) && !c.initial);

function report(title, list) {
  console.log(title);
  for (const c of list.sort((a, b) => a.name.localeCompare(b.name))) {
    console.log("  " + c.name + "  " + String(c.size).padStart(4) + " B  " +
      (c.initial ? "initial    " : "async      ") + c.modules.map(name).sort().join(", "));
  }
  const initialList = list.filter((c) => c.initial);
  console.log("  first load       " + initialList.length + " request  " +
    String(initialList.reduce((t, c) => t + c.size, 0)).padStart(4) + " B  " +
    cost(initialList).toFixed(1).padStart(6) + " ms");
  for (const root of roots.slice(1)) {
    const asyncList = asyncChunksFor(list, root);
    if (asyncList.length === 0) continue;
    console.log("  " + (name(root) + " boundary").padEnd(17) + asyncList.length + " request  " +
      String(asyncList.reduce((t, c) => t + c.size, 0)).padStart(4) + " B  " +
      cost(asyncList).toFixed(1).padStart(6) + " ms");
  }
}

console.log("-- graph --");
for (const [filePath, d] of [...graph].sort()) {
  console.log("  " + name(filePath).padEnd(16) + String(d.size).padStart(4) + " B  static: " +
    (d.staticImports.map(name).join(", ") || "-") + "  dynamic: " + (d.dynamicImports.map(name).join(", ") || "-"));
}
console.log("  chunk root: " + roots.map(name).join(", "));

report("-- chunks by signature (RTT " + RTT + " ms, bandwidth 1.5 Mbit/s) --", chunks);

// 1. Chunks that download together with the entry are not kept separate: they all merge into one initial chunk.
const initialChunks = chunks.filter((c) => c.initial);
if (initialChunks.length > 1) {
  const merged = {
    name: "I1", roots: [...new Set(initialChunks.flatMap((c) => c.roots))],
    modules: initialChunks.flatMap((c) => c.modules), size: bytes(initialChunks.flatMap((c) => c.modules)),
    initial: true,
  };
  chunks = [merged, ...chunks.filter((c) => !c.initial)];
  console.log("-- merge: " + initialChunks.map((c) => c.name).join(" + ") + " -> I1 (all of these download at first load)");
}

// 2. An async chunk under the threshold and requested from only one place joins its requester's chunk.
const THRESHOLD = 300;
for (const c of [...chunks]) {
  if (c.initial || c.size >= THRESHOLD || c.roots.length !== 1) continue;
  const requesters = [...graph].filter(([, d]) => d.dynamicImports.includes(c.roots[0])).map(([filePath]) => filePath);
  const targets = [...new Set(requesters.map((filePath) => chunks.find((chunk) => chunk.modules.includes(filePath))))];
  if (targets.length !== 1 || targets[0].initial) continue;
  const target = targets[0];
  target.modules.push(...c.modules);
  target.size += c.size;
  chunks = chunks.filter((q) => q !== c);
  console.log("-- merge: " + c.name + " (" + c.size + " B < " + THRESHOLD +
    " B, single requester) -> " + target.name);
}

report("-- after merging --", chunks);
```

```
$ node split.mjs
-- graph --
  alert            185 B  static: units  dynamic: -
  chart            439 B  static: units  dynamic: -
  csv-export       428 B  static: format, date  dynamic: -
  date             222 B  static: -  dynamic: -
  entry-browser    335 B  static: template  dynamic: panel
  format           417 B  static: date, units  dynamic: -
  panel            536 B  static: chart, csv-export, template  dynamic: alert
  template         244 B  static: format  dynamic: -
  units            174 B  static: -  dynamic: -
  chunk root: entry-browser, panel, alert
-- chunks by signature (RTT 60 ms, bandwidth 1.5 Mbit/s) --
  P1   335 B  initial    entry-browser
  P2   883 B  initial    date, format, template
  P3  1403 B  async      chart, csv-export, panel
  P4   185 B  async      alert
  P5   174 B  initial    units
  first load       3 request  1392 B   187.3 ms
  panel boundary   1 request  1403 B    67.3 ms
  alert boundary   1 request   185 B    61.0 ms
-- merge: P1 + P2 + P5 -> I1 (all of these download at first load)
-- merge: P4 (185 B < 300 B, single requester) -> P3
-- after merging --
  I1  1392 B  initial    date, entry-browser, format, template, units
  P3  1588 B  async      alert, chart, csv-export, panel
  first load       1 request  1392 B    67.3 ms
  panel boundary   1 request  1588 B    68.3 ms
```

#### Reading the Output

The first table gives the raw signature grouping. The panel itself, the chart, and the
export module are gathered into a single async chunk: only the panel root requests all
three. The template, format, and date modules are at first load because both the entry
point and the panel can reach them — there is no reason to make the panel wait for them,
they have already downloaded.

Splitting's gain is in the first line: of the total 2980 bytes, 1392 download at first
load. The panel's 1588 bytes never download at all if that panel is never opened. The
ratio is for show in a small example; in a real application, the weight of modules like
chart rendering and export can be several times the first load.

`units.js` falling into its own chunk is a direct result of the signature criterion: all
three roots request it, so its signature differs from the others'. The criterion makes
this distinction correctly, but the result is not always useful; the next section
corrects it.

#### The Cost of Over-Splitting

Every chunk is a request. Per-request latency can be larger than the transferred byte's
own transfer time; the cost lines sum these two items separately. At first load, three
chunks take 187.3 milliseconds, while the same bytes download in 67.3 milliseconds as a
single file. The 120.0 milliseconds in between are two extra requests that buy nothing.

This yields two merge rules.

**Chunks that download together are not kept separate.** Every chunk whose signature
contains the entry point downloads at first load; the only effect of keeping these in
separate files is increasing the request count. Three chunks merge into one initial
chunk, and first load drops from 187.3 milliseconds to 67.3 milliseconds. This rule has
one exception, and it concerns caching: a chunk shared by two different entry points —
like the browser-and-server overlap in the previous lesson — downloads once for each
target when kept separate. The same reasoning holds across releases as well, and will be
measured in the fifth lesson.

**A chunk under the threshold is not split off.** The threshold-alert module is 185
bytes and is requested from a single place. Kept as a separate file, it costs itself a
61.0-millisecond request; joined to the panel's chunk, it raises the panel's download
from 67.3 milliseconds to 68.3 milliseconds. The difference runs one way: splitting takes
more than it gives back.

The nested dynamic boundary has one more hidden cost. The threshold alert is requested
after the panel has loaded; the two requests are sequential. When the user opens the
panel, the wait is 67.3 + 61.0 = 128.3 milliseconds; after merging, 68.3 milliseconds.
The serial request chain is the same phenomenon measured in the rendering models topic
as the cost of client-side rendering; it works the same way at the chunk level.

#### Where the Boundary Goes

The criterion for placing a split boundary is four questions, and all four are
measurable.

**Is it needed on the first screen?** If so, it is not split off; splitting only adds
latency. The answer is no for the panel, yes for the measurement list.

**Does the byte count avoided cover the added request?** If not, it is not split off.
The threshold in the example is 300 bytes and is for demonstration; a real threshold is
computed from measured request latency and bandwidth.

**Does the user action have a natural wait?** A short wait after a button press is
acceptable; waiting while the page first opens is not. Split boundaries are placed at
action boundaries.

**How often is the code requested?** Delaying the code for a screen most visitors open
gives back, during navigation, more than the first load gained. The Code Splitting and
Lazy Loading lesson in the Application Architecture course took up this trade-off at
route boundaries and set prefetching as the resolution; the criterion is the same, the
source of the boundary differs — the route definition there, the location of the
`import()` call here.

#### The Obligations Splitting Brings

A split output brings three obligations a single-file output does not carry.

**Chunk names must be resolved at runtime.** The entry chunk must know which file to
request when the `import()` call executes. This information is written into the output
as a mapping; the fifth lesson will build this mapping together with content-hashed
names.

**A missing chunk is a runtime error.** If a new release is published while the user has
the page open, the chunk names the old page requests may no longer exist on the server.
This is directly the subject of the rollback lesson in the deployment topic, and the fix
is keeping the old release's files live for a while longer.

**Load order must be preserved.** An async chunk cannot run until the initial chunk it
depends on has been evaluated. The loader in the output tracks this dependency; as the
chunk count grows, so does the number of dependencies to track.

#### Summary

- The graph has two kinds of edge separated by time: a static import declares same-time
  necessity, `import()` declares a dependency with a time boundary.
- The set of chunk roots is the union of entry points and dynamic import targets; a
  module whose signature contains the entry point downloads at first load, a module
  whose signature does not contain it downloads when its root is requested.
- In the example, of the total 2980 bytes, 1392 download at first load; the panel's 1588
  bytes download only if that panel opens.
- Every chunk is a request: keeping chunks that download together separate raises first
  load from 67.3 milliseconds to 187.3 milliseconds, and splitting off a chunk under the
  threshold takes more than it gives back.
- Nested dynamic boundaries serialize requests; merging two boundaries into one chunk
  drops the wait from 128.3 milliseconds to 68.3 milliseconds.
- A split boundary is placed by four criteria: necessity on the first screen, the
  avoided byte covering the request cost, the action's natural wait, and how often the
  code is requested.

#### Next Step

Chunks are now split correctly both in number and in time. But their content is still
determined at module level: the graph says a module is needed, it does not say which
name inside it is used. The `units` module in the shared chunk exports four names, and
not all of them are used; the format module's second function is called only along the
export path. The next lesson brings the graph down from module level to name level:
eliminating unused exports, the conditions under which that elimination cannot happen,
and how the package declares this to make it possible.

### Tree Shaking

Source: https://academia.sh/en/courses/rendering-strategies/tree-shaking

Bringing the graph down from module level to name level; propagation of live exports, the module with side effects that cannot be eliminated, the three settings of the package manifest's side-effect declaration, and the source syntaxes that block elimination.

The previous lesson determined which modules the chunks carry. What it did not determine
is what gets carried *inside* those modules. The dependency graph says a module is
needed; it does not say which name inside it is used. If the North Slope Measurement
Station application's units module exports six names and two of them are never called
from anywhere, the graph cannot see that.

**Tree shaking** is the work of bringing the graph down from module level to name level
and dropping unreachable exports from the output. The Bundler Concept lesson in the
Modules, Tooling and the Ecosystem course listed this transform's three conditions. This
lesson tests those conditions by running them: it builds a source tree where one is
violated, measures where elimination stops, and shows how the package makes this
possible through a declaration.

#### Liveness at Name Level

In this graph, the nodes are not modules but `module#name` pairs. A pair that is **live**
stays in the output; one that is not, drops.

Propagation starts from the entry point. The entry point's exports are unconditionally
live; whatever requests them is outside the build. Every name that appears in a live
export's body is live as well: if the body touches an imported name, that name's
counterpart in the source module comes alive. Re-exports extend the chain — an
`export { a } from "./x.js"` line in an aggregator file brings `a` inside `x.js` alive
when `a` is requested, and touches nothing else.

Dynamic import creates uncertainty somewhere in this chain. The result of an `import()`
call is a namespace object, and which names get used cannot always be read off the call
site. The conservative behavior is to count all of the dynamic target's exports as live.

#### The Source Tree's Real Surface

The source tree used so far carried only the names actually used. A real set of utility
modules does not look like that: it offers a surface not all of its consumers need,
names are handed out from a single aggregator file, and one of them does work at the top
level.

The block below continues the `source/` tree set up in the Module Bundling and Code
Splitting lessons; the entry point comes from there, and those lessons' setup blocks
must run first.

```bash
mkdir -p source

cat > source/units.js <<'EOF'
export const TEMP_MIN = -60;
export const TEMP_MAX = 60;
export const isWithinRange = (c) => c >= TEMP_MIN && c <= TEMP_MAX;
export const fahrenheit = (c) => c * 9 / 5 + 32;
export const mmHg = (hpa) => hpa * 0.75006157584566;
export const windChill = (c, speed) =>
  13.12 + 0.6215 * c - 11.37 * speed ** 0.16 + 0.3965 * c * speed ** 0.16;
EOF

cat > source/date.js <<'EOF'
const two = (s) => String(s).padStart(2, "0");
export const hourMinute = (d) => two(new Date(d).getUTCHours()) + ":" + two(new Date(d).getUTCMinutes());
export const dayKey = (d) => new Date(d).toISOString().slice(0, 10);
export const weekKey = (d) => {
  const t = new Date(d);
  const day = Math.floor((t - Date.UTC(t.getUTCFullYear(), 0, 1)) / 86400000);
  return t.getUTCFullYear() + "-W" + two(Math.ceil((day + 1) / 7));
};
EOF

cat > source/forecast.js <<'EOF'
import { TEMP_MIN, TEMP_MAX } from "./units.js";

const isValid = (m) => m.temperature > TEMP_MIN && m.temperature < TEMP_MAX;

export const trend = (measurements) => {
  const list = measurements.filter(isValid);
  if (list.length < 2) return 0;
  return (list.at(-1).temperature - list[0].temperature) / (list.length - 1);
};
EOF

cat > source/registry.js <<'EOF'
export const formatters = new Map();
export const register = (name, fn) => formatters.set(name, fn);
EOF

cat > source/scale-registry.js <<'EOF'
import { register } from "./registry.js";
import { fahrenheit } from "./units.js";

register("fahrenheit", fahrenheit);
EOF

cat > source/toolkit.js <<'EOF'
import "./scale-registry.js";
export { isWithinRange, fahrenheit, mmHg, windChill } from "./units.js";
export { hourMinute, dayKey, weekKey } from "./date.js";
export { trend } from "./forecast.js";
export { formatters } from "./registry.js";
EOF

cat > source/format.js <<'EOF'
import { hourMinute, isWithinRange, fahrenheit, formatters } from "./toolkit.js";

export function measurementLine(m) {
  const flag = isWithinRange(m.temperature) ? "" : " (out of range)";
  const secondary = formatters.has("fahrenheit")
    ? " / " + formatters.get("fahrenheit")(m.temperature).toFixed(1) + " F" : "";
  return hourMinute(m.timestamp) + "  " + m.temperature.toFixed(1) + " C" + secondary + "  %" + m.humidity + flag;
}
export const dualUnit = (m) => m.temperature.toFixed(1) + " C / " + fahrenheit(m.temperature).toFixed(1) + " F";
EOF

cat > package.json <<'EOF'
{
  "name": "north-slope",
  "type": "module",
  "sideEffects": ["./source/scale-registry.js"]
}
EOF
```

Three structures were added. The units and date modules carry names that go unused. The
aggregator file hands names out from a single place and also holds a bare import inside
it. The scale-registry module exports nothing; it only writes to a registry at the top
level, and the format module changes its output based on what that registry holds.

#### The Resolver

The script below parses modules line by line, propagates liveness, and computes output
size under three separate side-effect policies.

```js
// shake.mjs — name-level liveness analysis and three sideEffects policies.
import { readFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRY = "source/entry-browser.js";
const STATEMENT_START = /^(import\b|export\b|const\b|let\b|function\b|class\b)/;

function parse(filePath) {
  const lines = readFileSync(filePath, "utf8").split("\n");
  const resolve = (b) => relative(".", join(dirname(filePath), b));
  const imports = new Map();             // local name -> { module, exportedAs }
  const bareImports = [];                // modules imported only for their side effect
  const pieces = [];                     // { name, module, exportedAs, body, exported, size }
  for (let i = 0; i < lines.length; i++) {
    const s = lines[i];
    let m;
    if ((m = s.match(/^import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/))) {
      for (const p of m[1].split(",").map((x) => x.trim()).filter(Boolean)) {
        const [exportedAs, local = exportedAs] = p.split(/\s+as\s+/);
        imports.set(local, { module: resolve(m[2]), exportedAs });
      }
    } else if ((m = s.match(/^import\s*["']([^"']+)["']/))) {
      bareImports.push(resolve(m[1]));
    } else if ((m = s.match(/^export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/))) {
      for (const p of m[1].split(",").map((x) => x.trim()).filter(Boolean)) {
        pieces.push({ name: p, module: resolve(m[2]), exportedAs: p, body: "", exported: true,
          size: Buffer.byteLength(p) + 2 });
      }
    } else if ((m = s.match(/^(?:export\s+)?(?:const|let|function|async function|class)\s+(\w+)/))) {
      const body = [s];
      while (i + 1 < lines.length && !STATEMENT_START.test(lines[i + 1])) body.push(lines[++i]);
      const text = body.join("\n").replace(/\s+$/, "");
      pieces.push({ name: m[1], body: text, exported: s.startsWith("export"),
        size: Buffer.byteLength(text) + 1 });
    } else if (s.trim() && !s.trim().startsWith("//")) {
      pieces.push({ name: null, body: s, exported: false, size: Buffer.byteLength(s) + 1 });
    }
  }
  return { imports, bareImports, pieces, size: Buffer.byteLength(readFileSync(filePath)) };
}

const modules = new Map();
const queue = [ENTRY];
while (queue.length > 0) {
  const filePath = queue.shift();
  if (modules.has(filePath)) continue;
  const m = parse(filePath);
  modules.set(filePath, m);
  const dynamicImports = [...readFileSync(filePath, "utf8").matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g)]
    .map(([, b]) => relative(".", join(dirname(filePath), b)));
  queue.push(...[...m.imports.values()].map((i) => i.module), ...m.bareImports, ...dynamicImports,
    ...m.pieces.filter((p) => p.module).map((p) => p.module));
  m.dynamicImports = dynamicImports;
}

function resolvePolicy(policy) {              // policy: "missing" | "false" | "list"
  const LIST = JSON.parse(readFileSync("package.json", "utf8")).sideEffects;
  const live = new Set();                      // "module#name"
  const retained = new Set();
  const pending = [];

  function scanBody(filePath, body) {
    const m = modules.get(filePath);
    for (const [local, source] of m.imports)
      if (new RegExp("\\b" + local + "\\b").test(body)) pending.push([source.module, source.exportedAs]);
    for (const p of m.pieces)
      if (p.name && new RegExp("\\b" + p.name + "\\b").test(body) && !body.startsWith("export"))
        pending.push([filePath, p.name]);
  }

  function retain(filePath) {
    if (retained.has(filePath)) return;
    retained.add(filePath);
    const m = modules.get(filePath);
    for (const p of m.pieces) if (!p.exported) scanBody(filePath, p.body);
    for (const b of m.bareImports) {
      const keep = policy === "missing" || (policy === "list" && LIST.includes("./" + b));
      if (keep) retain(b);
    }
    for (const d of m.dynamicImports) {
      retain(d);
      for (const p of modules.get(d).pieces) if (p.exported) pending.push([d, p.name]);
    }
    if (policy === "missing") {
      for (const i of m.imports.values()) retain(i.module);
      for (const p of m.pieces) if (p.module) retain(p.module);
    }
  }

  function markLive(filePath, name) {
    const key = filePath + "#" + name;
    if (live.has(key)) return;
    live.add(key);
    retain(filePath);
    const p = modules.get(filePath).pieces.find((x) => x.name === name && x.exported);
    if (!p) return;
    if (p.module) pending.push([p.module, p.exportedAs]);
    else scanBody(filePath, p.body);
  }

  retain(ENTRY);
  for (const p of modules.get(ENTRY).pieces) if (p.exported) pending.push([ENTRY, p.name]);
  while (pending.length > 0) markLive(...pending.pop());

  let total = 0;
  const dropped = [], deadNames = [];
  for (const [filePath, m] of modules) {
    if (!retained.has(filePath)) { dropped.push(filePath); continue; }
    let size = m.size;
    for (const p of m.pieces)
      if (p.exported && !live.has(filePath + "#" + p.name)) {
        size -= p.size;
        deadNames.push(filePath.replace("source/", "") + "#" + p.name);
      }
    total += size;
  }
  return { total, dropped, deadNames };
}

const raw = [...modules.values()].reduce((t, m) => t + m.size, 0);
console.log("modules in graph:", modules.size, " raw size:", raw, "B");
for (const policy of ["missing", "false", "list"]) {
  const r = resolvePolicy(policy);
  console.log("-- sideEffects: " + policy + " --");
  console.log("  output size : " + r.total + " B  (gain " + (raw - r.total) + " B)");
  console.log("  dropped module : " + (r.dropped.map((p) => p.replace("source/", "")).join(", ") || "-"));
  console.log("  dead name      : " + (r.deadNames.sort().join(", ") || "-"));
}
```

```
$ node shake.mjs
modules in graph: 13  raw size: 4280 B
-- sideEffects: missing --
  output size : 3665 B  (gain 615 B)
  dropped module : -
  dead name      : date.js#weekKey, forecast.js#trend, toolkit.js#dayKey, toolkit.js#mmHg, toolkit.js#trend, toolkit.js#weekKey, toolkit.js#windChill, units.js#mmHg, units.js#windChill
-- sideEffects: false --
  output size : 3353 B  (gain 927 B)
  dropped module : scale-registry.js, forecast.js
  dead name      : date.js#weekKey, registry.js#register, toolkit.js#dayKey, toolkit.js#mmHg, toolkit.js#trend, toolkit.js#weekKey, toolkit.js#windChill, units.js#mmHg, units.js#windChill
-- sideEffects: list --
  output size : 3537 B  (gain 743 B)
  dropped module : forecast.js
  dead name      : date.js#weekKey, toolkit.js#dayKey, toolkit.js#mmHg, toolkit.js#trend, toolkit.js#weekKey, toolkit.js#windChill, units.js#mmHg, units.js#windChill
```

The three policies produce three separate outputs from the same source tree. The
dead-name list carries a common core across all of them: millimeters of mercury, wind
chill, the week key, and the trend calculation appear in no live body. The re-export
lines in the aggregator file drop along with them — the line that carries a name is
meaningless once the name itself is dead.

#### The Side-Effect Barrier

The single file that makes the difference between policies is the scale registry. This
module has no exports; it enters the graph only through a bare import and calls a
function at its top level. Name-level resolution jams here: there is no name to bring
alive, yet the module's running changes another module's behavior.

The proof is done by running it. The block below produces a copy with elimination
applied and runs the same call against both outputs. The `sed -i` option behaves
differently between the BSD and GNU versions; giving it a backup extension gets the same
result on both.

```bash
#!/usr/bin/env bash
# Produces a copy with the side-effect module pruned, runs the same call against both outputs.
rm -rf pruned && mkdir pruned && cp source/*.js pruned/
sed -i.bak -e '/^import ".\/scale-registry.js";$/d' -e '/from ".\/forecast.js"/d' pruned/toolkit.js
rm -f pruned/*.bak pruned/scale-registry.js pruned/forecast.js

MEASUREMENT='{ timestamp: "2026-02-11T06:00:00Z", temperature: -4.2, humidity: 72 }'
node --input-type=module -e "
import { measurementLine } from './source/format.js';
console.log('preserved:', measurementLine(${MEASUREMENT}));
"
node --input-type=module -e "
import { measurementLine } from './pruned/format.js';
console.log('pruned   :', measurementLine(${MEASUREMENT}));
"
```

```
preserved: 06:00  -4.2 C / 24.4 F  %72
pruned   : 06:00  -4.2 C  %72
```

The second line has no Fahrenheit column. The elimination produced no error, threw no
exception; it silently changed the output. This is tree shaking's most dangerous failure
mode: the build looks successful, and a behavior the tests do not cover disappears.

This is why the tool does not drop a module with undeclared side-effect status on its
own. The information comes from the `sideEffects` field in the package's manifest, and it
has three settings.

**If the field is absent**, the tool behaves conservatively: it drops no module in its
entirety, only deletes dead export declarations. Output is 3665 bytes and correct, but
the forecast module stays in the output hollowed out.

**If the field is `false`**, the author has promised that no module in the package does
work at the top level. The tool drops the scale registry and the forecast module, and
the `register` name in the registry module dies along with it. Output shrinks to 3353
bytes — and produces the second line's behavior above. For this package, `false` is a
lie.

**If the field is a list**, the author states which files are side-effecting, one by
one. The scale registry is preserved, the forecast module drops. Output is 3537 bytes:
the smallest output obtainable without breaking correctness.

The field is therefore not a setting but a commitment. Filled in incorrectly, the tool
eliminates wrongly, and the failure shows up not where the elimination happened but
where the lost behavior gets used.

#### Other Syntaxes That Stop Elimination

Side effects are not the only barrier. All of the syntaxes below break name-level
tracking and force the tool to keep the module in its entirety.

**Passing the namespace object around whole.** If the object is handed to a function
after `import * as toolkit from "./toolkit.js"`, which names get read cannot be known
statically. This uncertainty does not arise when names are imported one at a time.

**Computed access.** A read in the form `toolkit[selectedName]` corresponds to a name
determined at runtime. A tool that sees such an access has to count every name in that
module as live.

**Call-based module format.** In CommonJS, the exports object can be mutated at runtime;
which names exist is not certain until evaluation finishes. Because the list obtained by
static parsing can be incomplete, elimination is not applied.

**Declarations that do work at evaluation time.** If a top-level call's result is bound
to a constant, whether that call has a side effect is not known. Tools get past this
uncertainty with purity annotations placed in the source; the annotation is a commitment
just like the declaration field.

**Aggregator files.** The aggregator itself is harmless — the example's aggregator had
its dead re-exports drop. What is harmful is the aggregator pulling a side-effecting
module into the graph through a bare import: that single line carries that module to
every consumer that imports the aggregator.

This list leaves the library author four concrete responsibilities: not doing work at
the top level, exporting names directly, listing side-effecting files in the
declaration, and not putting a bare import in the aggregator.

#### Summary

- Tree shaking brings the graph down to `module#name` level; liveness propagates from
  the entry point's exports through names that appear in bodies.
- Re-exports carry liveness by name; a dynamic import target's every export is
  conservatively counted live.
- A module that has no exports and does work at the top level cannot be eliminated by
  name-level resolution; if it is eliminated, the failure is silent — in the example,
  the Fahrenheit column disappears without an error.
- The side-effect field in the package manifest produces three outcomes: absent gives
  3665 bytes and no module drops, `false` gives 3353 bytes and breaks behavior, a
  correct list gives 3537 bytes and preserves behavior.
- Passing the namespace object around whole, computed access, call-based module format,
  and declarations that do work at evaluation time all stop elimination.

#### Next Step

The graph has, up to this point, been built only from script files. But an interface's
output does not consist only of code: the measurement list's style file, the station
icon, the interface's font, and a handful of static files to be copied to the browser
as-is also go into the output. Some of these are imported from code and become nodes in
the graph; some are never imported but must still be published. The next lesson takes up
this second flow: running assets through a pipeline, embedding small ones in the body,
writing large ones as separate files, and linking names in the source to names in the
output.

### Asset Processing

Source: https://academia.sh/en/courses/rendering-strategies/asset-processing

Images, fonts, and static files entering the build output; extracting asset links into the graph as nodes, the embed-threshold decision, rewriting references, the asset manifest, and the directly copied directory.

For three lessons, the graph was built only from script files. The North Slope
Measurement Station interface's output is not limited to that: the measurement list's
style file, the icon in front of every row in the list, the drawing of the station's
elevation curve, the interface's font, and a handful of fixed-name files search engines
read are also published.

These files enter the output through two separate routes. Some are linked from code or
a style file; the link is an edge in the graph, and the asset becomes a node. Some are
linked from nowhere but must still be published. This lesson separates the two routes
and runs the pipeline linked assets pass through.

#### An Asset Is a Graph Node

For an asset to enter the graph, its link must be statically readable. Two standard
syntaxes provide this.

A `url()` declaration in a style file gives a relative path as its address. The path in
the source is resolved against the directory the style file sits in.

A `new URL("./x.svg", import.meta.url)` expression in code does the same job on the
script side. The second argument is the module's own address; the expression produces
an address resolved against the module's location. This syntax is part of the standard
and is not tied to any tool; it works with no tool present, and can also be caught at
build time when a tool is present.

A syntax that cannot be caught is one where the address is assembled at runtime. A
concatenation in the form `"/asset/" + name + ".svg"` does not say which file it points
to during the build. Such a link does not enter the pipeline: the file is not copied,
its name is not rewritten, and a request goes to an address absent from the output. The
rule is clear — asset addresses computed at runtime are served from the directly copied
directory.

#### Two Flows

The **processed flow** covers linked assets. Depending on their size, these assets are
either written to the output as a separate file or embedded into the body of the file
that links them; either way, the link in the source is translated into an address in
the output.

The **directly copied flow** is for files that must be published with their name and
location unchanged: the file carrying crawler rules, domain verification files, data
files downloaded from a fixed address. These files are not scanned, their names are
preserved, and their content does not change. The distinguishing criterion is a single
question: is the build free to determine the address, or is it pinned from outside?

#### The Asset Tree

The block below produces assets deterministically. A filler file of the same size
stands in for a font body; because the pipeline's decisions depend on a file's size, not
its content, this does not change the result.

The block below adds asset links to the `source/` tree set up in the previous lessons;
those lessons' setup blocks must run in this directory first.

```bash
#!/usr/bin/env bash
mkdir -p asset public style

cat > asset/icon.svg <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#243">
<path d="M2 13 L6 5 L9 10 L11 7 L14 13 Z"/><circle cx="12" cy="3" r="2"/>
</svg>
EOF

node --input-type=module -e '
import { writeFileSync } from "node:fs";
// Station map: a 60-point elevation curve, deterministic generation.
const point = Array.from({ length: 60 }, (_, i) =>
  `${(i * 8).toFixed(0)} ${(120 - 40 * Math.sin(i / 6) - i / 3).toFixed(2)}`);
writeFileSync("asset/station.svg",
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160">\n` +
  `<path fill="none" stroke="#1b3a2f" stroke-width="2" d="M${point.join(" L")}"/>\n` +
  point.filter((_, i) => i % 6 === 0).map((n) => `<circle cx="${n.split(" ")[0]}" cy="${n.split(" ")[1]}" r="3"/>`).join("\n") +
  `\n</svg>\n`);
// A deterministic binary file of the same size stands in for a font body.
writeFileSync("asset/measurement.woff2", Buffer.alloc(4096, 0x77));
'

cat > style/station.css <<'EOF'
@font-face {
  font-family: "Measurement";
  src: url("../asset/measurement.woff2") format("woff2");
  font-display: swap;
}
.measurement li::before {
  content: "";
  background-image: url("../asset/icon.svg");
  width: 16px;
  height: 16px;
}
.station-map {
  background-image: url("../asset/station.svg");
  min-height: 160px;
}
EOF

cat > source/asset-links.js <<'EOF'
export const iconUrl = new URL("../asset/icon.svg", import.meta.url);
export const mapUrl = new URL("../asset/station.svg", import.meta.url);
EOF

printf 'User-agent: *\nDisallow: /panel/\n' > public/robots.txt
printf '{ "station": "north-slope", "elevation_m": 1840 }\n' > public/station.json
```

#### The Pipeline

The pipeline is four steps: scan for links, decide embedding for each asset, replace
the reference with its output address, and write the mapping between source and output
into a manifest.

```js
// asset.mjs — asset pipeline: scanning, embed decision, rewriting, manifest generation.
import { readFileSync, writeFileSync, mkdirSync, cpSync, statSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";

const EMBED_THRESHOLD = 1024;                    // bytes
const OUTPUT = "output";
const TYPE = { ".svg": ["image/svg+xml", "text"], ".css": ["text/css", "text"],
              ".woff2": ["font/woff2", "binary"], ".png": ["image/png", "binary"] };
const extension = (p) => p.slice(p.lastIndexOf("."));

const SOURCES = ["style/station.css", "source/asset-links.js"];
const LINK = [
  /url\(\s*["']?([^"')]+)["']?\s*\)/g,                          // link inside a style file
  /new URL\(\s*["']([^"']+)["']\s*,\s*import\.meta\.url\s*\)/g, // link inside code
];

function dataUrl(filePath) {                     // percent-encoding for text assets, base64 for binary
  const [mime, kind] = TYPE[extension(filePath)], body = readFileSync(filePath);
  return kind === "text"
    ? "data:" + mime + "," + encodeURIComponent(body.toString("utf8")).replace(/%20/g, " ")
    : "data:" + mime + ";base64," + body.toString("base64");
}

rmSync(OUTPUT, { recursive: true, force: true });
mkdirSync(join(OUTPUT, "asset"), { recursive: true });

const assets = new Map();                        // path -> { size, refs, embedded, target, outputSize }
const manifest = {};

for (const sourcePath of SOURCES) {
  let text = readFileSync(sourcePath, "utf8");
  for (const pattern of LINK) {
    text = text.replace(pattern, (full, link) => {
      const filePath = relative(".", join(dirname(sourcePath), link));
      if (!assets.has(filePath)) {
        const size = statSync(filePath).size;
        const embedded = size <= EMBED_THRESHOLD;
        const target = embedded ? dataUrl(filePath) : "/asset/" + basename(filePath);
        if (!embedded) cpSync(filePath, join(OUTPUT, target));
        assets.set(filePath, { size, refs: 0, embedded, target,
          outputSize: embedded ? Buffer.byteLength(target) : size });
      }
      const a = assets.get(filePath);
      a.refs++;
      return full.startsWith("url") ? 'url("' + a.target + '")'
        : 'new URL("' + a.target + '", import.meta.url)';
    });
  }
  const outputPath = join(OUTPUT, basename(sourcePath));
  writeFileSync(outputPath, text);
  manifest["/" + basename(sourcePath)] = { source: sourcePath, size: statSync(outputPath).size };
}

for (const [filePath, a] of assets)
  if (!a.embedded) manifest[a.target] = { source: filePath, size: a.size };
cpSync("public", OUTPUT, { recursive: true });   // directly copied directory
writeFileSync(join(OUTPUT, "asset-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");

const base64Length = (filePath) => Math.ceil(statSync(filePath).size / 3) * 4;
console.log("asset".padEnd(20) + "size".padStart(6) + "  ref  decision    output  if embedded");
for (const [filePath, a] of assets)
  console.log(basename(filePath).padEnd(20) + String(a.size).padStart(6) + "   " + a.refs +
    "   " + (a.embedded ? "embedded  " : "separate  ") + String(a.outputSize).padStart(7) +
    String(base64Length(filePath)).padStart(12));

console.log("-- rewritten style file (long values truncated) --");
for (const s of readFileSync(join(OUTPUT, "station.css"), "utf8").trimEnd().split("\n"))
  console.log("  " + (s.length > 64 ? s.slice(0, 64) + "[...]" : s));

console.log("-- manifest keys --");
for (const [url, k] of Object.entries(manifest))
  console.log("  " + url.padEnd(24) + String(k.size).padStart(5) + " B  <- " + k.source);

const separate = [...assets.values()].filter((a) => !a.embedded);
console.log("-- total --");
console.log("  style file      : " + manifest["/station.css"].size + " B  (source was " +
  statSync("style/station.css").size + " B)");
console.log("  separate asset  : " + separate.length + " file, " +
  separate.reduce((t, a) => t + a.size, 0) + " B");
console.log("  direct copy     : robots.txt, station.json (name and content unchanged)");
```

```
$ node asset.mjs
asset                 size  ref  decision    output  if embedded
measurement.woff2     4096   1   separate     4096        5464
icon.svg               168   2   embedded      275         224
station.svg           1162   2   separate     1162        1552
-- rewritten style file (long values truncated) --
  @font-face {
    font-family: "Measurement";
    src: url("/asset/measurement.woff2") format("woff2");
    font-display: swap;
  }
  .measurement li::before {
    content: "";
    background-image: url("data:image/svg+xml,%3Csvg xmlns%3D%22ht[...]
    width: 16px;
    height: 16px;
  }
  .station-map {
    background-image: url("/asset/station.svg");
    min-height: 160px;
  }
-- manifest keys --
  /station.css              586 B  <- style/station.css
  /asset-links.js           398 B  <- source/asset-links.js
  /asset/measurement.woff2 4096 B  <- asset/measurement.woff2
  /asset/station.svg       1162 B  <- asset/station.svg
-- total --
  style file      : 586 B  (source was 332 B)
  separate asset  : 2 file, 5258 B
  direct copy     : robots.txt, station.json (name and content unchanged)
```

In the rewritten style file, all three links have changed: two into a root-relative
address, one into the body itself. The `../asset/` relative path in the source never
appears in the output — output layout is independent of source layout.

The ref column exposes a detail: the icon and the map are linked from both the style
file and the code. The map, written as a separate file, is copied once, and both
references point at the same address. The embedded icon, on the other hand, is
rewritten at every reference; the 168-byte file totals 550 bytes across two outputs.

#### The Embed Threshold

The embed decision is made with a threshold, and the threshold balances two opposing
costs.

**Embedding's gain is one request.** Every asset written as a separate file brings a
request's latency alongside the byte it carries. For small assets, this latency is more
expensive than the file itself.

**Embedding's cost has three items.** First is encoding expansion: binary data grows by
roughly a third when converted to base64 — a 4096-byte font body becomes 5464
characters. Text assets can use percent-encoding, but it is not always cheaper: the
example's icon is 275 bytes with percent-encoding and 224 with base64, because the
proportion of characters that need escaping is high. Second is duplication: an embedded
asset sits separately in every output that links it. Third is caching — an embedded
asset has no separate address, so it cannot be cached separately; it downloads again
with the file that links it every time that file changes.

This third item explains why the threshold is kept small. Embedding a rarely changing
image inside a frequently changing style file makes that image download again with
every release. The next lesson will make this connection measurable.

#### Decisions Specific to Asset Classes

The threshold alone is not a sufficient policy; each asset class has its own
constraints.

**Fonts** are written as separate files regardless of size. A font body is shared
across multiple pages and cached long-lived; embedding it destroys that sharing. The two
settings established in the Font Loading lesson of the Visual Presentation with CSS
course apply here as well: display behavior is declared with `font-display`, and the
body can be requested with an early loading hint inside the document. A third thing the
build side can add is subsetting — keeping only the used character ranges from the body
shrinks the file noticeably, and the decision depends on the targeted language set.

**Images** carry two extra pieces of metadata. First are dimensions: when width and
height are read during the build and written into the markup, the content shift defined
in the Web Fundamentals and HTML course is prevented. Second are derivatives: the
candidate set in the Layout Systems and Responsive Design course's Responsive Images
lesson asks for several widths of the same image; the pipeline is what produces those
versions. Format choice is made by class, not by product name — drawings that need
lossless compression and photographs suited to lossy compression are handled
separately.

**Style files** are both asset and source: the links inside them are scanned, and they
themselves are written to the output. This is why they appear twice in the pipeline.

#### The Asset Manifest

The pipeline's final output is a mapping: which source file corresponds to which
address in the output. This file is called the **asset manifest**, and it is the
contract between the build and everything outside it.

At least three parties read the manifest. The document template takes the address of
the style file and the entry chunk from it. A runtime rendering on the server writes
the same addresses into the markup it produces. A service worker running offline
generates the list of files it will precache from it — the Caching Strategies lesson in
the Browser and the Web Platform course showed how fragile writing that list by hand is;
the manifest lets the build produce the list instead.

Name similarity invites confusing three separate files. The package manifest carries
dependencies and package metadata; the application manifest carries installability
information; the asset manifest is only the source-to-output mapping. The three are
read at different times, by different parties.

#### Summary

- Assets enter the graph through two standard links: a `url()` declaration in a style
  file and a `new URL(...)` expression in code; addresses assembled at runtime cannot
  enter the pipeline.
- Linked assets pass through the processed flow; files with a fixed name pass through
  the directly copied flow — the criterion is whether the build is free to determine
  the address.
- The pipeline is four steps: scan for links, decide embedding, replace the reference
  with the output address, write the mapping into the manifest.
- Embedding buys one request; in exchange it brings encoding expansion, duplication at
  every reference, and the loss of separate caching — a 4096-byte body becomes 5464
  characters in base64.
- Fonts are written as separate files regardless of size; images are produced together
  with dimension metadata and width derivatives.
- The asset manifest is the shared contract for the document template, the server
  runtime, and the service worker.

#### Next Step

The files in the output still carry their source names for now. This leaves both of two
questions unresolved. If a published file is cached long-lived, the user keeps getting
the old version once its content changes; if it is cached short-lived, revalidation
happens on every visit and the cache's value is lost. The way to avoid both extremes is
binding the name to the content: when a file's name carries a digest of its content, the
name does not change unless the content changes, and the cache stays valid as long as
the name does not change. The next lesson produces these names, measures how names
change in a chain across interdependent chunks, and builds the mechanism that breaks
that chain.

### Cache Busting

Source: https://academia.sh/en/courses/rendering-strategies/cache-busting

Binding the file name to its content; hash generation and determinism, the chained change of the hash across dependent chunks, breaking the chain with an import map, and cache policy by name.

The files in the previous lesson's output directory carried their source names. This
leaves an unresolved dilemma on the caching side. If the North Slope Measurement
Station site's style file is served from the address `/station.css` and a long-lived
cache policy is applied to that address, the user keeps getting the old file once its
content changes. If the policy is kept short, revalidation happens on every visit and
the cache's gain goes to revalidation round trips.

The dilemma's source is that the address is independent of content. If the address is
bound to the content, the dilemma disappears: the address does not change unless the
content changes, and that address can be kept in the cache for close to forever; if the
content changes, the address changes too, and since the new address was never in the
cache, it downloads unavoidably. **Cache busting** is the naming method that
establishes this bond.

#### Binding a Name to Content

The bond is established with a digest function. The file's bytes are run through a
cryptographic digest function, and a portion of the result is appended to the name. The
result provides two properties at once.

**Determinism:** the same bytes give the same name. When the same source is built
twice, the names come out identical; this is one of the conditions required for the
build to be reproducible, and it will be used again in the preview deployments lesson.

**No collisions:** the odds of different bytes giving the same name are controlled by
the digest length taken. Eight hexadecimal digits give close to four billion distinct
values; the file count in a release stays small next to that, which makes it sufficient
in practice. The truncation is a name-length decision, not a security decision —
integrity checking is done with a separate mechanism, subresource integrity.

The hash must be computed over the **final** content. A hash computed before
transforms does not represent the output's bytes; the name stays the same in some cases
where the content changes, and the cache keeps serving the old file.

#### Chained Change

This is the part of hashed naming that looks easy. The hard part is that outputs are
bound to each other.

A chunk carries, in its body, the name of another chunk it loads dynamically. When that
other chunk's name changes, this chunk's **content** changes; when its content changes,
its hash changes; when its hash changes, its name changes. The change propagates
through the graph from leaf to root. In a three-layer chain, a one-line change in a leaf
module changes three files' names and makes a returning visitor download all three
again.

What breaks the chain is the name not being embedded in the body. If module specifiers
are resolved through an **import map**, the chunk's body does not carry its child's
hashed name; it carries only an unchanging specifier. The map is a small mapping
written into the document and not cached.

#### Producing a Release

The script below runs two naming modes side by side and counts the bytes each of two
change scenarios makes get re-downloaded. The style file and two assets are read from
the previous lesson's output directory.

This lesson's script reads the `output/` tree produced by the Asset Processing lesson;
that lesson's blocks must run in this directory first.

```js
// hash.mjs — content-hashed file names, chained changes, and an import map.
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";

const hash = (body) => createHash("sha256").update(body).digest("hex").slice(0, 8);
const hashedName = (name, h) => name.replace(/\.([^.]+)$/, "-" + h + ".$1");

// The output set. Entries whose body is null are read from the fourth lesson's output directory.
// The {{...}} placeholder is replaced with the child output's name in the release.
const BODY = {
  "asset/station.svg": null,
  "asset/measurement.woff2": null,
  "station.css": '@font-face { font-family: "Measurement";\n' +
    '  src: url("{{asset/measurement.woff2}}") format("woff2"); font-display: swap; }\n' +
    '.station-map { background-image: url("{{asset/station.svg}}"); }\n',
  "chunk/chart.js": 'export const path = (m) => m.map((x) => x.temperature).join(" ");\n',
  "chunk/panel.js": 'import { path } from "{{chunk/chart.js}}";\n' +
    'export const draw = (m) => "<svg>" + path(m) + "</svg>";\n',
  "entry.js": 'export const open = async () => (await import("{{chunk/panel.js}}")).draw;\n',
};
const ORDER = ["asset/station.svg", "asset/measurement.woff2", "station.css",
              "chunk/chart.js", "chunk/panel.js", "entry.js"];
const SPECIFIER = { "chunk/chart.js": "north:chart", "chunk/panel.js": "north:panel" };

function publish(mode, changes = {}) {
  const name = {}, body = {}, size = {};
  for (const filePath of ORDER) {
    let b = changes[filePath] ?? BODY[filePath] ?? readFileSync(join("output", filePath));
    if (typeof b === "string") {
      // embedded mode: the child's hashed name is written into the body.
      // map mode: only module specifiers are left bare; the url() link in the style
      // file cannot be resolved by an import map, so its name keeps being embedded.
      b = Buffer.from(b.replace(/\{\{([^}]+)\}\}/g, (_, child) =>
        mode === "map" && SPECIFIER[child] ? SPECIFIER[child] : "/" + name[child]));
    }
    name[filePath] = hashedName(filePath, hash(b));
    body[filePath] = b;
    size[filePath] = b.length;
  }
  const importMap = { imports: Object.fromEntries(
    Object.entries(SPECIFIER).map(([filePath, specifier]) => [specifier, "/" + name[filePath]])) };
  return { name, body, size, importMap: Buffer.from(JSON.stringify(importMap) + "\n") };
}

const first = publish("embedded");
console.log("-- first release --");
for (const filePath of ORDER) console.log("  " + filePath.padEnd(25) + first.name[filePath]);
console.log("  same source compiled twice, names identical: " +
  ORDER.every((p) => first.name[p] === publish("embedded").name[p]));

rmSync("release", { recursive: true, force: true });
for (const filePath of ORDER) {
  const target = join("release", first.name[filePath]);
  mkdirSync(dirname(target), { recursive: true });
  writeFileSync(target, first.body[filePath]);
}

const SCENARIO = {
  "leaf module changed": {
    "chunk/chart.js": BODY["chunk/chart.js"].replace('join(" ")', 'join(", ")'),
  },
  "image asset changed": {
    "asset/station.svg": Buffer.from(readFileSync("output/asset/station.svg", "utf8")
      .replace('stroke-width="2"', 'stroke-width="3"')),
  },
};

for (const [scenarioTitle, changes] of Object.entries(SCENARIO)) {
  console.log("-- " + scenarioTitle + " --");
  for (const mode of ["embedded", "map"]) {
    const before = publish(mode), after = publish(mode, changes);
    const changedNames = ORDER.filter((p) => before.name[p] !== after.name[p]);
    const mapChanged = !before.importMap.equals(after.importMap);
    const redownloaded = changedNames.reduce((t, p) => t + after.size[p], 0) +
      (mapChanged ? after.importMap.length : 0);
    console.log("  " + mode.padEnd(8) + " changed names: " + changedNames.join(", "));
    console.log("           redownloaded: " + redownloaded + " B / total " +
      ORDER.reduce((t, p) => t + after.size[p], 0) + " B" +
      (mapChanged ? "  (map also renewed)" : ""));
  }
}

console.log("-- produced in map mode --");
process.stdout.write("  entry.js  : " + publish("map").body["entry.js"]);
process.stdout.write("  panel.js  : " + publish("map").body["chunk/panel.js"]
  .toString().split("\n")[0] + "\n");
process.stdout.write("  map       : " + publish("map").importMap);
```

```
$ node hash.mjs
-- first release --
  asset/station.svg        asset/station-77c2bd82.svg
  asset/measurement.woff2  asset/measurement-7b962f03.woff2
  station.css              station-f6353c3e.css
  chunk/chart.js           chunk/chart-f903b59b.js
  chunk/panel.js           chunk/panel-78d33707.js
  entry.js                 entry-0aa70f11.js
  same source compiled twice, names identical: true
-- leaf module changed --
  embedded changed names: chunk/chart.js, chunk/panel.js, entry.js
           redownloaded: 350 B / total 5711 B  (map also renewed)
  map      changed names: chunk/chart.js
           redownloaded: 163 B / total 5685 B  (map also renewed)
-- image asset changed --
  embedded changed names: asset/station.svg, station.css
           redownloaded: 1361 B / total 5710 B
  map      changed names: asset/station.svg, station.css
           redownloaded: 1361 B / total 5684 B
```

```
-- produced in map mode --
  entry.js  : export const open = async () => (await import("north:panel")).draw;
  panel.js  : import { path } from "north:chart";
  map       : {"imports":{"north:chart":"/chunk/chart-f903b59b.js","north:panel":"/chunk/panel-3ef97d1e.js"}}
```

#### What the Output Says

The verification line in the first section shows the naming is deterministic: names
produced twice from the same source are identical. This becomes an identity for the
release — whether two builds give the same result can be determined by comparing the
name list, without opening the output.

The first scenario gives the chain's cost. In embedded naming, the one-line change in
the leaf chunk changes three files' names and makes a returning visitor download 350
bytes. In import-map mode, only the changed file's name changes, and the amount
re-downloaded drops to 163 bytes — less than half. The ratio grows with the chain's
depth: in a four-layer chunk graph, embedded naming would change four files, map mode
still just one.

The second scenario shows the method's limit. A change to an image asset changes the
style file's name in both modes, because the `url()` link inside a style file cannot be
resolved by an import map; the address has to be written there. Chain-breaking applies
only to module specifiers. This adds a new criterion to the fourth lesson's embed
decision: embedding a frequently changing asset in the style file changes the style
file too, on every release.

#### Cache Policy by Name

Hashed naming alone produces no gain; the gain is produced by cache headers. The
headers defined in the Caching Strategies lesson of the Browser and the Web Platform
course are applied here by file class.

**Hashed files** are served with the longest lifetime and an immutability declaration:
a one-year freshness window and the immutable flag. What guarantees this declaration is
correct is the name itself — if the content changes, the file is no longer requested
under that name. The immutable flag also removes revalidation requests made before the
freshness window expires.

**The document** is never served long-lived. The document carries the hashed names; if
the document is cached, the user keeps requesting the old names and never sees the new
release at all. The correct policy for the document is to mark it to revalidate on
every use and to give it a validator; if the content has not changed, the response
comes back without a body.

**The import map** sits inside the document and is subject to the same policy as the
document. If it is kept in a separate file, it must be marked the same way the document
is; served long-lived, everything done to break the chain goes to waste.

**Directly copied files** keep their names, so they cannot get the hashed policy.
These are given a medium-length freshness window and a validator.

Query-string versioning — appending a version parameter to the end of the address —
appears to serve the same purpose but has two weaknesses: some intermediate caches do
not cache addresses carrying a query string, and two copies of the same file can end up
existing under two different parameters. Versioning through the name creates neither
problem.

#### The Lifespan of Old Names

Hashed naming brings one obligation: **old names must keep living for a while.** If a
new release is published while a user has the page open, the open page's document
keeps carrying the old names. A dynamic chunk request triggered from that page goes to
the old name. If the new release deleted the old files, the request is not found, and
the runtime error defined in the Code Splitting lesson results.

This is why a release is made **additive** instead of changing files in place: new
names are written, old names are not deleted. Deletion is done as a separate cleanup
task, with a delay that exceeds open sessions' lifetime. The same rule is required for
rollback as well, and it will turn into a release policy in the staged rollout lesson
in the deployment topic.

#### Summary

- A content hash binds the file name to its content: the same bytes give the same
  name, changed bytes produce a new name; the hash must be computed over the final
  content.
- The naming is deterministic; two builds from the same source produce an identical
  name list, and that list can be used as the release's identity.
- When a hashed name is embedded in the body, the change chains from leaf to root: in
  the example, a one-line change changes three files' names and makes 350 bytes get
  re-downloaded.
- An import map pulls module specifiers out of the body and breaks the chain; the same
  change drops to 163 bytes. Asset links in a style file cannot be resolved this way.
- Policy is given by file class: hashed files are long-lived and immutable, the
  document and the import map revalidate on every use.
- Old names are not deleted right away; the chunks open pages will request stay live
  for a while longer.

#### Next Step

Everything produced up to this point is for release: chunks are merged, dead code is
dropped, assets are processed, names are bound to content. All of these steps take
time, and during development, none of them pay off. For someone who wants to change one
line and see the result, re-bundling the entire graph, recomputing hashes, and
reloading the page from scratch means an unnecessary wait and a lost screen state. The
next lesson builds the compile's second mode: a development server that serves the
source as-is, re-evaluates only the module that changed, and propagates the change
upward through the graph.

### Development Server

Source: https://academia.sh/en/courses/rendering-strategies/development-server

The compile's second mode; transforming and serving modules on request, reuse via a validator, modeling hot module replacement as invalidation propagation, and the correctness conditions of the accept boundary.

The previous five lessons built a single mode: compiling for release. In this mode the
graph is scanned in full, chunks are merged, dead code is dropped, assets are processed,
and names are bound to content. Every one of these steps pays off — but only in release.

For someone changing a formatting line in the North Slope Measurement Station
interface, none of these steps do any work. Re-scanning the entire graph, recomputing
hashes, and reloading the page from scratch costs two things: seconds, and the screen's
state. The panel being open, the filter being set, the scroll position — a full load
erases all of it.

This lesson builds the compile's second mode. Its question is: when a file changes,
what is the least that can be done?

#### The Difference Between the Modes

Development mode drops most of release mode's steps.

**Bundling drops.** The browser can load standard modules on its own; traversing the
graph is left to the browser's module loader. The server's job is reduced to handing
over a single module when it is requested.

**Tree shaking, minification, and content hashing drop.** All three shrink output
size; size is not decisive on a local connection, and all three require seeing the
whole graph.

**What has to stay is three things:** specifier resolution — the browser cannot resolve
bare specifiers — translating syntax the target does not understand, and source map
generation. The Source Maps lesson in the Modules, Tooling and the Ecosystem course
showed why this third one is indispensable: transformed code cannot be debugged.

This dropping makes the cost independent of graph size. In release mode, a change
concerns the entire graph; in development mode, only the file that changed.

#### Serving on Request

The server below performs a single transform and serves a single file.

This lesson's server serves the `source/` tree set up in the previous lessons; those
lessons' setup blocks must run in this directory first.

```js
// dev-server.mjs — serves modules on request, transformed, with a validator.
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { readFileSync, existsSync } from "node:fs";

// A single transform: relative specifiers are rewritten to absolute paths on the server.
// No bundling; each module stays its own file.
function transform(urlPath, source) {
  return "// [dev] " + urlPath + "\n" + source
    .replace(/from\s+"\.\/([^"]+)"/g, 'from "/source/$1"')
    .replace(/^import\s+"\.\/([^"]+)"/gm, 'import "/source/$1"')
    .replace(/import\(\s*"\.\/([^"]+)"\s*\)/g, 'import("/source/$1")');
}

createServer((request, response) => {
  response.sendDate = false;                     // keeps the output deterministic
  const urlPath = request.url.split("?")[0];
  const filePath = urlPath.replace(/^\//, "");
  if (!urlPath.startsWith("/source/") || !existsSync(filePath)) {
    response.writeHead(404).end();
    return;
  }
  const body = transform(urlPath, readFileSync(filePath, "utf8"));
  const tag = '"' + createHash("sha256").update(body).digest("hex").slice(0, 12) + '"';
  if (request.headers["if-none-match"] === tag) {
    response.writeHead(304, { ETag: tag }).end();
    return;
  }
  response.writeHead(200, {
    "Content-Type": "text/javascript; charset=utf-8",
    "Content-Length": Buffer.byteLength(body),
    "Cache-Control": "no-cache",
    ETag: tag,
  }).end(body);
}).listen(8172, "127.0.0.1", () => console.log("listening: 127.0.0.1:8172"));
```

The party that traverses the graph is the browser. The client below is a small
stand-in for the browser's module loading loop: it fetches a module, reads the static
specifiers inside it, and requests them. It does not track dynamic import — because the
browser does not either, it waits until the call executes.

```js
// client.mjs — a small stand-in for the browser's module loading loop.
// Only static specifiers are tracked; import() is not requested until it executes.
const ROOT = "http://127.0.0.1:8172";
const STATIC = [/from\s*"([^"]+)"/g, /^import\s*"([^"]+)"/gm];

async function load(roots) {
  const fetched = new Map(), queue = [...roots];
  while (queue.length > 0) {
    const urlPath = queue.shift();
    if (fetched.has(urlPath)) continue;
    const body = await (await fetch(ROOT + urlPath)).text();
    fetched.set(urlPath, body.length);
    for (const pattern of STATIC)
      for (const [, b] of body.matchAll(pattern)) if (b.startsWith("/source/")) queue.push(b);
  }
  return fetched;
}

const short = (m) => [...m].map(([p]) => p.replace("/source/", "")).sort().join(", ");
const first = await load(["/source/entry-browser.js"]);
console.log("received at first load : " + first.size + " module  " + short(first));
const after = await load([...first.keys(), "/source/panel.js"]);
const extra = new Map([...after].filter(([p]) => !first.has(p)));
console.log("extra once panel opens : " + extra.size + " module  " + short(extra));
console.log("alert.js not yet requested: " + !after.has("/source/alert.js"));
```

The long-lived server is started with its own script. The port chosen, 8172, is
arbitrary and must be free.

```bash
#!/usr/bin/env bash
# Starts the server, requests a single module, tests the validator, runs the module loop.
cp source/template.js template.orig
node dev-server.mjs > /dev/null &
server=$!
sleep 1

echo "--- how a single module is served ---"
curl -sS http://127.0.0.1:8172/source/template.js

TAG=$(curl -sS -o /dev/null -D - http://127.0.0.1:8172/source/template.js \
  | awk 'tolower($1) == "etag:" { print $2 }' | tr -d '\r')
echo "--- validator $TAG ---"
curl -sS -o /dev/null -w "unchanged, second request : %{http_code}\n" \
  -H "If-None-Match: $TAG" http://127.0.0.1:8172/source/template.js
printf '\nexport const footnote = () => "measurements UTC";\n' >> source/template.js
curl -sS -o /dev/null -w "after the file changed     : %{http_code}\n" \
  -H "If-None-Match: $TAG" http://127.0.0.1:8172/source/template.js

echo "--- loading on request ---"
node client.mjs

kill "$server"
mv template.orig source/template.js
```

```
--- how a single module is served ---
// [dev] /source/template.js
import { measurementLine } from "/source/format.js";

export const list = (measurements) =>
  "<ul>" + measurements.map((m) => "<li>" + measurementLine(m) + "</li>").join("") + "</ul>";
export const heading = (station) => "<h1>" + station + "</h1>";
--- validator "6b87f32456fc" ---
unchanged, second request : 304
after the file changed     : 200
--- loading on request ---
received at first load : 9 module  date.js, entry-browser.js, forecast.js, format.js, registry.js, scale-registry.js, template.js, toolkit.js, units.js
extra once panel opens : 3 module  chart.js, csv-export.js, panel.js
alert.js not yet requested: true
```

The validator's value is produced from the served module's bytes: the same byte
sequence always gives the same value, a one-character change gives a different one. The
value here depends on this directory's content at that moment — because earlier
lessons' setup blocks rewrite the same file, a different validator can be seen depending
on which blocks were run. What matters is not the value itself but that it stays the
**same** for an unchanged module.

The output's last three lines show development mode's gain. At first load, nine of
thirteen modules are requested; three more come once the panel opens; the
threshold-alert module is never requested, because the dynamic call that requests it has
not yet executed. At no stage is the entire graph scanned.

The validator lines give the second gain. A module whose content has not changed is
dispatched with a bodyless response on the second request; when the page reloads, only
the changed files download again.

#### Invalidation Propagation

Updating without reloading the page requires more than this. When a module's new
version is fetched, the values held by the modules that imported it are stale: imported
bindings must be reread, and those modules must be re-evaluated too. The change
propagates through the graph against the direction of the edges — from the requested
toward the requester.

For the propagation not to go on forever, a stop is needed. The **accept boundary** is
a module that declares it can absorb its own update: it is replaced with its new
version, and its importers are not notified. If no boundary is found and the
propagation reaches the entry point, the only thing left to do is a full load.

```js
// hot.mjs — invalidation propagation in the module graph and the accept boundary.
import { readFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRY = "source/entry-browser.js";
const LINK = [/from\s+"(\.[^"]+)"/g, /^import\s+"(\.[^"]+)"/gm, /import\(\s*"(\.[^"]+)"\s*\)/g];

// Accept boundary: a module that absorbs its own update without propagating to its importers.
const ACCEPT = new Set(["source/template.js", "source/panel.js", "source/chart.js"]);
// Modules holding module-level state: state resets if they are re-evaluated.
const STATE = new Set(["source/registry.js"]);

const graph = new Map(), queue = [ENTRY];
while (queue.length > 0) {
  const filePath = queue.shift();
  if (graph.has(filePath)) continue;
  const source = readFileSync(filePath, "utf8");
  const neighbors = LINK.flatMap((k) => [...source.matchAll(k)]
    .map(([, b]) => relative(".", join(dirname(filePath), b))));
  graph.set(filePath, neighbors);
  queue.push(...neighbors);
}

const importers = new Map([...graph.keys()].map((p) => [p, []]));
for (const [filePath, neighbors] of graph) for (const n of neighbors) importers.get(n).push(filePath);

function propagate(changed) {
  const invalidated = [], visited = new Set(), queue = [changed];
  let fullReload = false;
  while (queue.length > 0) {
    const filePath = queue.shift();
    if (visited.has(filePath)) continue;
    visited.add(filePath);
    invalidated.push(filePath);
    if (ACCEPT.has(filePath)) continue;             // boundary found, does not propagate upward
    if (filePath === ENTRY) { fullReload = true; continue; }
    queue.push(...importers.get(filePath));
  }
  return { invalidated, fullReload, state: invalidated.filter((p) => STATE.has(p)) };
}

const name = (p) => p.replace("source/", "");
console.log("graph: " + graph.size + " module, accept boundary: " + [...ACCEPT].map(name).join(", "));
console.log("changed".padEnd(18) + "result".padEnd(14) + "re-evaluated set");
for (const changed of ["source/chart.js", "source/panel.js", "source/format.js",
                       "source/units.js", "source/registry.js", "source/entry-browser.js"]) {
  const r = propagate(changed);
  console.log(name(changed).padEnd(18) +
    (r.fullReload ? "full reload" : "hot update").padEnd(14) +
    r.invalidated.length + "/" + graph.size + "  " + r.invalidated.map(name).join(", ") +
    (r.state.length ? "   [state resets: " + r.state.map(name).join(", ") + "]" : ""));
}
```

```
$ node hot.mjs
graph: 13 module, accept boundary: template.js, panel.js, chart.js
changed           result        re-evaluated set
chart.js          hot update    1/13  chart.js
panel.js          hot update    1/13  panel.js
format.js         hot update    4/13  format.js, template.js, csv-export.js, panel.js
units.js          hot update    10/13  units.js, chart.js, alert.js, toolkit.js, forecast.js, scale-registry.js, panel.js, format.js, template.js, csv-export.js
registry.js       hot update    7/13  registry.js, toolkit.js, scale-registry.js, format.js, template.js, csv-export.js, panel.js   [state resets: registry.js]
entry-browser.js  full reload   1/13  entry-browser.js
```

The table says three things.

**A change below the boundary is cheap.** Because the chart and panel modules accept
their own updates, only they are re-evaluated; twelve of the thirteen modules stay
untouched.

**A change in a leaf module is expensive.** The units module sits low in the graph,
and nine modules request it directly or indirectly; all of them are re-evaluated. Small
edits to shared utility modules look slow because of this.

**The accept boundary's placement determines the decision.** Boundaries are placed at
view boundaries, because a view can reproduce its own output. Utility modules cannot be
a boundary: they cannot update the bindings held by whatever requests their values.

#### The Correctness Condition of Hot Replacement

Hot replacement is not an improvement, it is a risk; applied incorrectly, the state
the developer sees diverges from the state a cold load would produce.

**The first danger is state resetting.** When a module holding module-level state is
re-evaluated, that state is lost. The table's last column flags this: when the registry
module updates, the formatter registry empties, and if the side-effecting module that
fills it does not run again, the interface behaves incompletely. The same silent
behavior loss from the tree shaking lesson shows up here, this time during development.

**The second danger is stale closures.** An event listener or timer registered in a
module's old version keeps running even after the module has been replaced. The accept
boundary is obligated to clean up what the old version left behind before switching to
the new one.

**The third danger is accumulated drift.** The state after dozens of hot replacements
can differ from the state a cold load of the same source would produce. The rule is to
reload the page by hand at every point of doubt and do verification on a cold load.

These three dangers explain why falling back to a full load, in situations where hot
replacement cannot be applied, is not a flaw but a safeguard.

#### The Gap Between Development and Release

Every step development mode drops creates a possibility of a behavior difference
between the two modes. If tree shaking is off, a module that should not have been
eliminated shows up eliminated only in release. If modules are served as separate
files, the evaluation-order difference bundling produces shows up only in release. If
minification is off, a name-dependent behavior breaks only in release.

All of these differences share one consequence: **working in development mode is not
proof of working in release mode.** This is why running and trying the release output
locally is part of the development flow, and the Preview Deployments lesson in the
deployment topic will extend this trial to every change.

#### Summary

- Development mode drops bundling, tree shaking, minification, and content hashing;
  specifier resolution, syntax translation, and source map generation stay.
- The graph is traversed by the browser's module loader; the server transforms and
  hands over a single module, making cost independent of graph size.
- In the example, first load requests nine of thirteen modules, the panel adds three
  more, the module behind the dynamic boundary is never requested; the validator
  dispatches unchanged modules with a bodyless response.
- Hot replacement is invalidation propagating toward the requesters; the accept
  boundary stops the propagation, and a full load happens if no boundary is found.
- A change below the boundary re-evaluates one module, a change in a shared utility
  module re-evaluates ten; boundaries are placed at view boundaries.
- Hot replacement carries the risks of state resetting, stale closures, and
  accumulated drift; a cold load is the measure of verification when in doubt.

#### Next Step

Development and release modes differ not only in speed but in the environment they run
in. Locally, station data comes from a test endpoint; in release, from the real
measurement feed; log detail is on locally and off in release; authentication endpoints
sit at different addresses. Embedding these differences in the source is not
acceptable, because it produces two separate outputs from the same source. What carries
the difference is build-time variables. The next lesson shows how these variables enter
the output and establishes the one critical rule: a variable that reaches the client is
public to everyone.

### Environment Configuration

Source: https://academia.sh/en/courses/rendering-strategies/environment-configuration

How build-time variables enter the output; the constants substitution produces, every value that reaches the client bundle being public, turning the passable-versus-not-passable distinction into an auditable rule, and the trade-off of runtime configuration.

The previous lesson separated the compile's two modes, but both modes shared one
assumption: the source is the same. The North Slope Measurement Station interface reads
data from a test endpoint locally and from the real measurement feed in release; log
detail is on locally and off in release; archive writes go to a temporary directory
locally and to permanent storage in release. Writing these differences into the source
would mean producing two separate outputs from the same source, and at that point there
is no longer a single source tree to speak of.

What carries the difference is **build-time variables**. This lesson first shows how
these variables enter the output, then establishes the one critical rule: a value that
enters the client bundle is public to everyone.

#### Two Moments of Configuration

A program running on the server reads its configuration at runtime. As the process
starts, it asks the environment for a name, gets the value, and uses it. When the value
changes, the process is restarted; the program is not recompiled. Loading environment
variables from a file — a `.env` file that does not go into version control — changes
this read's source, not its timing.

Code running in the browser has no such source. The page does not open inside a process
environment; the only thing it can read is the bytes it was given. Two routes remain for
configuration to enter client code, and the two work at separate moments.

**Build-time substitution.** A placeholder in the source is replaced with the value
itself during the build. There is no variable in the output; there is a constant. When
the value changes, a rebuild is required.

**Runtime read.** The value is placed not in the output but in the document or a
separate endpoint; the client gets it on its first request. When the value changes, a
rebuild is not required, but one more read is.

This lesson's main subject is the first, because the second has a precondition: which
value will never be given to the client at all must be decided first.

#### What Substitution Does

Substitution is a text operation. The transformer below replaces `ENV.<NAME>`
placeholders in the source with the environment table's values written as code, and
produces a single bundle.

```js
// substitute.mjs — build-time environment substitution and searching for the value in the output.
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";

// Source tree: three modules, each reading a placeholder in the form ENV.<NAME>.
const SOURCE = {
  "measurement-client.js":
    'const ENDPOINT = ENV.NORTH_MEASUREMENT_ENDPOINT;\n' +
    'export const fetchMeasurements = (station) =>\n' +
    '  fetch(ENDPOINT + "/station/" + station).then((r) => r.json());\n',
  "log.js":
    'const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };\n' +
    'const LEVEL = ENV.NORTH_LOG_LEVEL;\n' +
    'export const write = (level, message) =>\n' +
    '  ORDER[level] >= ORDER[LEVEL] ? console.log(level + ": " + message) : undefined;\n',
  "archive-writer.js":
    'const KEY = ENV.NORTH_WRITE_KEY;\n' +
    'export const archive = (record) =>\n' +
    '  fetch("/archive", { method: "POST", headers: { "X-Key": KEY },\n' +
    '                       body: JSON.stringify(record) });\n',
};

// The .env file's content: name=value lines, not committed to version control.
const ENV_FILE = [
  "NORTH_MEASUREMENT_ENDPOINT=https://measurement.north-slope.example/v1",
  "NORTH_LOG_LEVEL=warn",
  "NORTH_WRITE_KEY=write-9f21c4a7e08b",
].join("\n") + "\n";

const parseEnv = (text) => Object.fromEntries(text.split("\n")
  .filter((s) => s.includes("="))
  .map((s) => [s.slice(0, s.indexOf("=")).trim(), s.slice(s.indexOf("=") + 1).trim()]));

const env = parseEnv(ENV_FILE);

// Substitution: ENV.<NAME> is replaced with the value written as a source-code constant.
const substitute = (source) => source.replace(/ENV\.([A-Z0-9_]+)/g,
  (full, name) => (name in env ? JSON.stringify(env[name]) : full));

rmSync("output", { recursive: true, force: true });
mkdirSync("output", { recursive: true });
mkdirSync("source", { recursive: true });
writeFileSync(".env.release", ENV_FILE);

let bundle = "";
const changed = [];
for (const [name, body] of Object.entries(SOURCE)) {
  writeFileSync("source/" + name, body);
  const out = substitute(body);
  bundle += "// " + name + "\n" + out + "\n";
  body.split("\n").forEach((line, i) => {
    if (line.includes("ENV.")) changed.push([name, out.split("\n")[i]]);
  });
}
writeFileSync("output/bundle.js", bundle);

console.log("-- substituted lines --");
for (const [name, line] of changed) console.log("  " + name.padEnd(23) + line);

const body = readFileSync("output/bundle.js", "utf8");
console.log("-- value search in the output bundle --");
for (const [name, value] of Object.entries(env))
  console.log("  " + name.padEnd(29) + (body.includes(value) ? "VISIBLE" : "absent"));
```

The independent search below runs outside the script's own search, against the
produced file.

```bash
#!/usr/bin/env bash
# Runs the substitution, then scans the produced bundle directly.
node substitute.mjs
echo "=== independent search ==="
grep -c 'write-9f21c4a7e08b' output/bundle.js
grep -n 'X-Key' output/bundle.js
```

```
-- substituted lines --
  measurement-client.js  const ENDPOINT = "https://measurement.north-slope.example/v1";
  log.js                 const LEVEL = "warn";
  archive-writer.js      const KEY = "write-9f21c4a7e08b";
-- value search in the output bundle --
  NORTH_MEASUREMENT_ENDPOINT   VISIBLE
  NORTH_LOG_LEVEL              VISIBLE
  NORTH_WRITE_KEY              VISIBLE
=== independent search ===
1
15:  fetch("/archive", { method: "POST", headers: { "X-Key": KEY },
```

What the output says is technically ordinary, not in its consequences: all three
values sit in the bundle's bytes as plain text. The write key was behind a name in the
source; in the output it is a string constant.

This result is not a flaw, it is the definition of substitution. Because no mechanism
exists in the output to resolve a variable, the value has to be written there. For the
same reason, there is no build step that hides a substituted value in the output either:

- **Minification does not change values.** Name shortening changes local names; string
  constants stay as they are. The tree shaking from the third lesson finds no reason to
  eliminate a string either.
- **Encoding is not hiding.** Writing the value in base64 or with a simple scramble
  does not remove it, since its decoder sits in the same bundle; it only adds a step.
- **A source map is a second copy.** A source map served publicly in release also
  carries the original form of the substituted lines.

This is where the lesson's rule comes from: **a value that enters the client bundle is
in the hands of everyone who opens the page.** A value that enters through substitution
and one given at runtime are in the same position.

#### The Secret-Value Boundary

The rule is not a blocklist, it is a classification. Every configuration value falls
into one of two sets, and the distinction is made with a single question: **can someone
who knows this value do something someone who does not know it cannot?**

If the answer is no, the value can pass to the client. This set is general
configuration: the addresses of public endpoints, the log level, whether a feature flag
is on or off, the release tag, a public tenant or site identifier, the locale list.
Knowing these grants no new privilege; it only states what is already observable.

If the answer is yes, the value cannot pass to the client. This set is secret values:
server-side write keys, signing secrets, data store connection information, credentials
for administrative endpoints, third-party services' secret keys. Someone who knows
these can act with the server's authority.

The boundary has two consequences, and both are frequently neglected.

**Need does not produce an exception.** If the client appears to need a secret value,
what is actually needed is not the value but the work done with it. The correct fix is
moving the work to the server: the client calls an endpoint at its own origin, the
session carries the authority, and the secret value stays on the server. This is the
correct form of the archive writer above.

**A leaked value cannot be taken back.** The output has been downloaded and has
settled into intermediate caches and browser caches; the fifth lesson also said old
names keep living for a while. Rolling back the release does not bring the value back.
The only response is revoking the value and replacing it with a new one. This is why
the boundary is not something to fix after a leak, but a check that stops the build.

The same reasoning extends to where the build runs. Secret values are given to the
environment in a continuous integration pipeline; every line written to that pipeline's
log is visible to everyone who can see that pipeline's output. The tool doing the
substitution printing which names it put into the output is the quietest form of leak.

#### Auditing the Boundary

The classification is not enforced as long as it stays in a document. For it to be
auditable, three things are required: the scope of names declared in one place, the
declaration reflected in naming, and the output tested against the declaration.

The auditor below does this with five rules. `R1` reports a name that is not declared,
`R2` a server-scoped name read in client source, `R3` a client name that does not carry
the public prefix, `R4` a secret value found in the output's bytes, `R5` a placeholder
that stayed in the output because it has no counterpart in the environment.

```js
// auditor.mjs — an auditor separating a variable open to the client from one that stays on the server.
// Usage: node auditor.mjs initial | node auditor.mjs fixed
const PREFIX = "NORTH_PUBLIC_";

// Declaration: every name's scope is written in one place. The audit reads this table.
const DECLARATION = {
  NORTH_PUBLIC_MEASUREMENT_ENDPOINT: "client",
  NORTH_PUBLIC_LOG_LEVEL: "client",
  NORTH_PUBLIC_RELEASE_TAG: "client",
  NORTH_MEASUREMENT_ENDPOINT: "client",
  NORTH_LOG_LEVEL: "client",
  NORTH_WRITE_KEY: "server",
  NORTH_SIGNING_SECRET: "server",
};

const WRITE = 'const KEY = ENV.NORTH_WRITE_KEY;\n' +
  'export const archive = (record) => request("/archive", KEY, record);\n';

const VERSION = {
  initial: {
    client: {
      "measurement-client.js": 'const ENDPOINT = ENV.NORTH_MEASUREMENT_ENDPOINT;\n',
      "log.js": 'const LEVEL = ENV.NORTH_LOG_LEVEL;\n',
      "footnote.js": 'export const footnote = () => "release " + ENV.NORTH_PUBLIC_RELEASE_TAG;\n',
      "archive-writer.js": WRITE,
    },
    server: {},
    env: {
      NORTH_MEASUREMENT_ENDPOINT: "https://measurement.north-slope.example/v1",
      NORTH_LOG_LEVEL: "warn",
      NORTH_WRITE_KEY: "write-9f21c4a7e08b",
      NORTH_SIGNING_SECRET: "sign-4d70b1cc52ae",
    },
  },
  fixed: {
    client: {
      "measurement-client.js": 'const ENDPOINT = ENV.NORTH_PUBLIC_MEASUREMENT_ENDPOINT;\n',
      "log.js": 'const LEVEL = ENV.NORTH_PUBLIC_LOG_LEVEL;\n',
      "footnote.js": 'export const footnote = () => "release " + ENV.NORTH_PUBLIC_RELEASE_TAG;\n',
      // Archive writing moved to a same-origin endpoint; the key no longer appears in client source.
      "archive-sender.js": 'export const archive = (record) => request("/endpoint/archive", record);\n',
    },
    server: { "archive-writer.js": WRITE },
    env: {
      NORTH_PUBLIC_MEASUREMENT_ENDPOINT: "https://measurement.north-slope.example/v1",
      NORTH_PUBLIC_LOG_LEVEL: "warn",
      NORTH_PUBLIC_RELEASE_TAG: "2026.03",
      NORTH_WRITE_KEY: "write-9f21c4a7e08b",
      NORTH_SIGNING_SECRET: "sign-4d70b1cc52ae",
    },
  },
};

const namesRead = (sources) => [...new Set(Object.values(sources)
  .flatMap((g) => [...g.matchAll(/ENV\.([A-Z0-9_]+)/g)].map(([, name]) => name)))];

const buildBundle = (sources, env) => Object.values(sources).join("")
  .replace(/ENV\.([A-Z0-9_]+)/g, (full, name) => (name in env ? JSON.stringify(env[name]) : full));

function audit(version) {
  const { client, server, env } = VERSION[version];
  const bundle = buildBundle(client, env);
  const findings = [];
  const add = (rule, name, message) => findings.push([rule, name, message]);

  for (const name of namesRead(client)) {
    if (!(name in DECLARATION)) add("R1", name, "not in the declaration");
    else if (DECLARATION[name] === "server") add("R2", name, "server-scoped name read on the client");
    else if (!name.startsWith(PREFIX)) add("R3", name, "client-scoped name does not carry the " + PREFIX + " prefix");
    if (!(name in env)) add("R5", name, "not defined in the environment, placeholder stayed in the output");
  }
  for (const [name, scope] of Object.entries(DECLARATION))
    if (scope === "server" && name in env && bundle.includes(env[name]))
      add("R4", name, "its value was found in the client bundle's bytes");

  console.log("version: " + version);
  console.log("  client bundle: " + Object.keys(client).length + " module, names read: " +
    namesRead(client).join(", "));
  console.log("  server bundle: " + Object.keys(server).length + " module, names read: " +
    (namesRead(server).join(", ") || "-"));
  for (const [rule, name, message] of findings)
    console.log("  " + rule + "  " + name.padEnd(30) + message);
  console.log("  violations: " + findings.length);
  return findings.length;
}

process.exitCode = audit(process.argv[2]) > 0 ? 1 : 0;
```

```bash
#!/usr/bin/env bash
# Audits both versions and prints their exit codes.
node auditor.mjs initial;  echo "exit code: $?"
node auditor.mjs fixed;    echo "exit code: $?"
```

```
version: initial
  client bundle: 4 module, names read: NORTH_MEASUREMENT_ENDPOINT, NORTH_LOG_LEVEL, NORTH_PUBLIC_RELEASE_TAG, NORTH_WRITE_KEY
  server bundle: 0 module, names read: -
  R3  NORTH_MEASUREMENT_ENDPOINT    client-scoped name does not carry the NORTH_PUBLIC_ prefix
  R3  NORTH_LOG_LEVEL               client-scoped name does not carry the NORTH_PUBLIC_ prefix
  R5  NORTH_PUBLIC_RELEASE_TAG      not defined in the environment, placeholder stayed in the output
  R2  NORTH_WRITE_KEY               server-scoped name read on the client
  R4  NORTH_WRITE_KEY               its value was found in the client bundle's bytes
  violations: 5
exit code: 1
version: fixed
  client bundle: 4 module, names read: NORTH_PUBLIC_MEASUREMENT_ENDPOINT, NORTH_PUBLIC_LOG_LEVEL, NORTH_PUBLIC_RELEASE_TAG
  server bundle: 1 module, names read: NORTH_WRITE_KEY
  violations: 0
exit code: 0
```

The initial version's five findings show three separate classes of defect.

**A naming defect.** The two `R3` findings are not a leak; they are the condition that
makes a leak invisible. A public prefix tells a developer reading a name, through the
name itself, that this value will enter the output. Without the prefix, the difference
between `R2` and `R3` comes down to a matter of attention.

**Missing configuration.** `R5` reports that a name with no counterpart in the
environment stayed in the output unsubstituted. In this case, an undefined reference
remains in the output, and the error surfaces at runtime, when that line executes. A
defect that could have been caught at build time surfacing on the user's side is the
audit's reason for existing.

**A boundary violation.** `R2` and `R4` are the same problem seen from two different
places: one looks at the source, the other at the output's bytes. Both are needed. The
source check cannot catch code that writes the value by hand without touching the name;
the byte check cannot say where the name came from.

In the fixed version, the archive writer has been taken out of the client set and moved
into the server set. The module that stays on the client calls a same-origin endpoint;
the key never enters client source at any stage.

#### Configuration That Does Not Require a Rebuild

Build-time substitution has a cost, and that cost is the deployment topic's question:
every substituted value binds the output to that environment. If there are five
environments, there are five separate build outputs, and the bytes tested in preview
are not the bytes that go to release.

**Runtime configuration** dissolves this bond. Values that vary by environment do not
enter the bundle; they are placed in the document as a small section or at a separate
endpoint, and the client reads and uses them. The same build output runs in every
environment.

The trade-off reads on three measures. **Build count** drops from one per environment
to one. In exchange, **the number of sources that must be read before first render**
grows; if the value comes from a separate endpoint, that is one more network round
trip. If the value is embedded in the document, no round trip is added, but **the
document's cacheability** drops — under the fifth lesson's policy the document was
already revalidating on every use, so these two decisions are consistent with each
other.

The selection criterion is this: if a value varies across environments and the same
output is meant to run in more than one environment, it is given at runtime; if the
value is the same across every environment, or its value needs to affect build
decisions — for instance, so dead-code elimination can remove a feature entirely — it
is substituted at build time.

The one thing the two routes do not change is the boundary. A value given at runtime
is public to everyone the moment it reaches the client too; writing it into the
document produces no different security posture than writing it into the bundle.

#### Summary

- Code running in the browser has no process environment; configuration reaches it
  either through build-time substitution or a runtime read.
- Substitution replaces the placeholder with the value's constant; in the example, all
  three values sit in the output bundle's bytes as plain text.
- Minification, encoding, and name shortening do not hide values; a source map carries
  a second copy. A value that enters the client bundle is in everyone's hands who opens
  the page.
- The distinction is made with a single question: if someone who knows the value can
  do something someone who does not know it cannot, the value is secret and cannot
  pass to the client; the work moves to the server.
- The boundary must be auditable: a scope declaration, a public prefix, and a check
  that looks at both the source and the output bytes stop the build on a violation.
- Runtime configuration drops the build count to one but adds a read; a leaked secret
  value can only be met by revoking it.

#### Next Step

The build output is now fully determined: a directory with its chunks, its assets, its
hashed names, and its environment-bound constants. This directory is useless to anyone
as long as it sits on a single machine's disk. The next question is by what route those
bytes reach the user's browser. The simplest route is copying the output as-is and
serving it from nodes that sit close to users: no server process runs, every request
corresponds to a file. The next lesson builds this route, measures hits against misses
in the edge cache, and computes how the fifth lesson's name-based cache policy
translates into a hit ratio.

## Deployment

Getting build output to the user; static hosting over a content delivery network, deployment forms that require a server process, per-change preview environments, gradual rollout with automated rollback, and packaging for non-web targets.

### Static Hosting

Source: https://academia.sh/en/courses/rendering-strategies/static-hosting

Publishing build output without a server process; measuring the edge cache's hit, miss, validated, and refreshed states, converting a name-based cache policy into request and byte hit ratios, invalidation, and path resolution.

The build output is now settled: a directory with its chunks, processed assets,
content-hashed names, and environment-bound constants. As long as that directory sits on
the disk of the machine that produced it, it is useless to anyone.

This lesson sets up the plainest way for that output to reach the user: copy the
directory as is and serve it from nodes close to users. Its question is this — with no
server process running, where do speed and freshness come from?

#### Publishing Without a Server Process

In static hosting, publishing is nothing more than a copy of files. Every incoming
request looks up a path, finds the matching file, and sends its bytes. No program runs
per request.

This has three consequences.

**Per-request cost is close to zero.** The per-request cost line item from the
Server-Side Rendering lesson has no place here; scaling comes not from a more powerful
machine but from copying the file to more places.

**No secret sits on the served side.** The previous lesson's boundary holds here on its
own: what is served is the client bundle's bytes, and those bytes are already public.

**The response cannot vary by user.** Two requests to the same path get the same bytes.
Personalized content, authorization-dependent content, and decisions based on the
request itself cannot be produced in this model.

The first gives the model its power, the other two give it its limit. Using that power
depends on the copies sitting close to the user; the distance table from the Edge
Rendering lesson measured this. The layer that keeps the copy close is the **content
delivery network**; in static hosting it runs no code, only stores bytes.

#### The Four States of the Edge Cache

The copy an edge node holds can be in one of four relationships with the file at the
origin. The two servers below make these four states distinguishable. On the origin
server, geographic distance is modeled as a fixed delay added before the response.

```js
// origin-server.mjs -- origin that serves the release directory. Geographic distance is a fixed delay.
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const DISTANCE = 80; // ms, the cost of a round trip to the origin
const wait = (ms) => new Promise((c) => setTimeout(c, ms));

const IMMUTABLE = "public, max-age=31536000, immutable";
const DOCUMENT_POLICY = "public, no-cache";

// Release directory: hashed-name assets (the naming from the Cache Busting lesson) and a single document.
const ASSETS = new Map([
  ["/assets/station-7e1d6934.css",
   [".measurement { font-variant-numeric: tabular-nums; }\n", "text/css; charset=utf-8"]],
  ["/assets/entry-5f45daac.js",
   ['export const open = () => import("/assets/panel-c3924741.js");\n',
    "text/javascript; charset=utf-8"]],
]);

let releaseNo = 1; // a new release increments this number, the document's content changes
const document = () =>
  '<!doctype html><html lang="en"><head>' +
  '<link rel="stylesheet" href="/assets/station-7e1d6934.css">' +
  '</head><body><h1>North Slope Measurement Station</h1>' +
  "<p>release " + releaseNo + "</p></body></html>\n";

const tag = (body) =>
  '"' + createHash("sha256").update(body).digest("hex").slice(0, 12) + '"';

function resolve(path) {
  if (ASSETS.has(path)) {
    const [body, type] = ASSETS.get(path);
    return { body, type, policy: IMMUTABLE };
  }
  // A hashed name that is not found is a real 404; no fallback document is given.
  if (path.startsWith("/assets/")) return null;
  // Every other path resolves to the document: path resolution happens on the client.
  return { body: document(), type: "text/html; charset=utf-8", policy: DOCUMENT_POLICY };
}

createServer(async (request, response) => {
  response.sendDate = false;
  await wait(DISTANCE);
  if (request.method === "POST" && request.url === "/admin/release") {
    releaseNo += 1;
    response.writeHead(200, { "Content-Type": "text/plain" }).end("release " + releaseNo + "\n");
    return;
  }
  const record = resolve(request.url);
  if (record === null) {
    response.writeHead(404, { "Cache-Control": "public, max-age=60" }).end();
    return;
  }
  const etag = tag(record.body);
  const common = { ETag: etag, "Cache-Control": record.policy };
  if (request.headers["if-none-match"] === etag) {
    response.writeHead(304, common).end();
    return;
  }
  response.writeHead(200, {
    ...common,
    "Content-Type": record.type,
    "Content-Length": Buffer.byteLength(record.body),
  }).end(record.body);
}).listen(8180, "127.0.0.1", () => console.log("origin: 127.0.0.1:8180"));
```

```js
// edge-cache.mjs -- edge node that sits close to the user and stores only bytes.
// It runs no code; it applies whatever Cache-Control and ETag say.
import { createServer } from "node:http";

const ORIGIN = "http://127.0.0.1:8180";
const cache = new Map(); // path -> { body, type, etag, policy, freshUntil }
const seconds = () => Date.now() / 1000;

// The freshness duration is read only from Cache-Control; no-cache means zero freshness.
function freshness(policy) {
  if (!policy || /no-store|no-cache/.test(policy)) return 0;
  const m = /max-age=(\d+)/.exec(policy);
  return m ? Number(m[1]) : 0;
}

const send = (response, record, status) => {
  response.writeHead(200, {
    "Content-Type": record.type,
    "Content-Length": Buffer.byteLength(record.body),
    "Cache-Control": record.policy,
    ETag: record.etag,
    "X-Cache": status,
  }).end(record.body);
};

createServer(async (request, response) => {
  response.sendDate = false;

  // Invalidation: the edge copy is deleted by hand, the next request goes to the origin.
  if (request.method === "POST" && request.url.startsWith("/admin/invalidate")) {
    const target = new URL(request.url, "http://edge").searchParams.get("path");
    const existed = cache.delete(target);
    response.writeHead(200, { "Content-Type": "text/plain" })
      .end((existed ? "deleted: " : "absent : ") + target + "\n");
    return;
  }

  const record = cache.get(request.url);
  if (record && record.freshUntil > seconds()) {
    send(response, record, "hit");
    return;
  }

  const headers = record ? { "If-None-Match": record.etag } : {};
  const reply = await fetch(ORIGIN + request.url, { headers });

  if (reply.status === 304) {
    record.policy = reply.headers.get("cache-control") ?? record.policy;
    record.freshUntil = seconds() + freshness(record.policy);
    send(response, record, "validated");
    return;
  }
  if (reply.status !== 200) {
    cache.delete(request.url);
    response.writeHead(reply.status, { "X-Cache": "origin" }).end();
    return;
  }

  const fresh = {
    body: await reply.text(),
    type: reply.headers.get("content-type"),
    etag: reply.headers.get("etag"),
    policy: reply.headers.get("cache-control"),
  };
  fresh.freshUntil = seconds() + freshness(fresh.policy);
  cache.set(request.url, fresh);
  send(response, fresh, record ? "refreshed" : "miss");
}).listen(8181, "127.0.0.1", () => console.log("edge: 127.0.0.1:8181"));
```

The script below runs in the same directory as these two files. Ports 8180 and 8181 are
arbitrary and must be free; if they are in use, change them in all three files.

```bash
#!/usr/bin/env bash
# Starts the two servers, measures the edge cache's four states, then stops them.
node origin-server.mjs > /dev/null &
origin=$!
node edge-cache.mjs > /dev/null &
edge=$!
sleep 1

K=http://127.0.0.1:8181
MEASURE() { printf '%-38s' "$1"; shift;
  curl -sS -o /dev/null -w 'first byte %{time_starttransfer} s  cache %header{x-cache}\n' "$@"; }

MEASURE "1) immutable asset, first request"    "$K/assets/station-7e1d6934.css"
MEASURE "2) immutable asset, second request"   "$K/assets/station-7e1d6934.css"
MEASURE "3) document, first request"           "$K/"
MEASURE "4) document, content unchanged"       "$K/"

curl -sS -o /dev/null -X POST http://127.0.0.1:8180/admin/release
MEASURE "5) document, after a new release"     "$K/"

printf '%-38s' "6) edge copy is deleted"
curl -sS -X POST "$K/admin/invalidate?path=/assets/station-7e1d6934.css"
MEASURE "7) immutable asset, after deletion"   "$K/assets/station-7e1d6934.css"

echo "--- path resolution ---"
curl -sS -o /dev/null -w 'unknown page path    : %{http_code} %{content_type}\n' \
  "$K/measurements/north-slope/2026-03"
curl -sS -o /dev/null -w 'missing hashed name  : %{http_code}\n' \
  "$K/assets/station-00000000.css"
echo "--- final state of the document ---"
curl -sS "$K/"

kill "$origin" "$edge"
```

```
1) immutable asset, first request     first byte 0.100191 s  cache miss
2) immutable asset, second request    first byte 0.000624 s  cache hit
3) document, first request            first byte 0.083963 s  cache miss
4) document, content unchanged        first byte 0.084585 s  cache validated
5) document, after a new release      first byte 0.085991 s  cache refreshed
6) edge copy is deleted               deleted: /assets/station-7e1d6934.css
7) immutable asset, after deletion    first byte 0.084387 s  cache miss
--- path resolution ---
unknown page path    : 200 text/html; charset=utf-8
missing hashed name  : 404
--- final state of the document ---
<!doctype html><html lang="en"><head><link rel="stylesheet" href="/assets/station-7e1d6934.css"></head><body><h1>North Slope Measurement Station</h1><p>release 2</p></body></html>
```

The duration fields depend on the machine and the load; the ratios carry the meaning.
The gap between the first two rows is two orders of magnitude — a miss goes to the
origin, a hit does not. Every remaining row is in the same order of magnitude as a miss,
because each of them makes one round trip to the origin.

This is the lesson's first measured result: **validation is a round trip too.** The only
difference between the third and fourth rows is that the fourth downloads no body; the
duration is nearly the same. In small files, duration is set not by body size but by the
number of round trips.

The four states split as follows. A **miss** is the copy not existing. A **hit** is the
copy being fresh; the origin is not contacted. **Validated** is freshness ending without
a content change; the origin returns a bodyless response and the edge copy refreshes
again. **Refreshed** is freshness ending along with a content change; a new body comes
down and takes the copy's place.

#### Converting Policy into Hit Ratio

What determines how often each of the four states above occurs is the policy given by
file class in the Cache Busting lesson. The calculation below compares three policies
under the same traffic.

```js
// hit-ratio.mjs -- conversion of a name-based cache policy into edge hit ratio.
// The model is an upper bound: at most one origin request is counted per freshness window.
const PAGE_VIEWS = 240_000; // daily page views
const ASSETS = 12;          // asset requests per page
const NODES = 40;           // number of edge nodes
const DEPLOYS = 6;          // daily deploys
const CHANGED = 3;          // assets whose content changes on every deploy
const DAY = 86_400;         // s
const DOCUMENT_BYTES = 14_000;
const ASSET_BYTES = 26_000;

const perNode = PAGE_VIEWS / NODES;             // document requests per node
const windowCap = (ttl) => Math.min(perNode, Math.floor(DAY / ttl));

// Each policy yields [origin requests, responses with a body] for two file classes.
const POLICY = {
  "unhashed name, 600 s for every class": {
    document: [windowCap(600), 1 + DEPLOYS],
    asset: [ASSETS * windowCap(600), ASSETS + CHANGED * DEPLOYS],
    staleness: "at most 600 s",
  },
  "immutable asset + validated document": {
    document: [perNode, 1 + DEPLOYS],
    asset: [ASSETS + CHANGED * DEPLOYS, ASSETS + CHANGED * DEPLOYS],
    staleness: "none",
  },
  "immutable asset + 60 s windowed document": {
    document: [windowCap(60) + DEPLOYS, 1 + DEPLOYS],
    asset: [ASSETS + CHANGED * DEPLOYS, ASSETS + CHANGED * DEPLOYS],
    staleness: "at most 60 s",
  },
};

const totalRequests = PAGE_VIEWS * (1 + ASSETS);
const totalBytes = PAGE_VIEWS * (DOCUMENT_BYTES + ASSETS * ASSET_BYTES);
const percent = (x) => (100 * x).toFixed(2) + "%";

console.log("daily page views " + PAGE_VIEWS.toLocaleString("en-US") +
  ", " + (1 + ASSETS) + " requests per page, " + NODES + " edge nodes, " +
  DEPLOYS + " deploys");
console.log("total requests " + totalRequests.toLocaleString("en-US") +
  ", total sent " + (totalBytes / 1e9).toFixed(1) + " GB\n");

console.log("policy".padEnd(42) + "origin requests".padStart(16) +
  "request hits".padStart(15) + "byte hits".padStart(13) +
  "round trips per view".padStart(23) + "  staleness");

for (const [name, p] of Object.entries(POLICY)) {
  const requests = NODES * (p.document[0] + p.asset[0]);
  const bytes = NODES * (p.document[1] * DOCUMENT_BYTES + p.asset[1] * ASSET_BYTES);
  console.log(name.padEnd(42) + requests.toLocaleString("en-US").padStart(16) +
    percent(1 - requests / totalRequests).padStart(15) +
    percent(1 - bytes / totalBytes).padStart(13) +
    (requests / PAGE_VIEWS).toFixed(3).padStart(23) + "  " + p.staleness);
}

// Every origin request pays the round trip from the Edge Rendering lesson's distance calculation.
const TRIP = 90; // ms, round trip for 9000 km
console.log("\norigin time paid per view (" + TRIP + " ms round trip):");
for (const [name, p] of Object.entries(POLICY)) {
  const requests = NODES * (p.document[0] + p.asset[0]);
  console.log("  " + name.padEnd(42) + ((requests / PAGE_VIEWS) * TRIP).toFixed(1) + " ms");
}
```

```
$ node hit-ratio.mjs
daily page views 240,000, 13 requests per page, 40 edge nodes, 6 deploys
total requests 3,120,000, total sent 78.2 GB

policy                                    origin requests   request hits    byte hits   round trips per view  staleness
unhashed name, 600 s for every class                74,880         97.60%       99.96%                  0.312  at most 600 s
immutable asset + validated document               241,200         92.27%       99.96%                  1.005  none
immutable asset + 60 s windowed document            59,040         98.11%       99.96%                  0.246  at most 60 s

origin time paid per view (90 ms round trip):
  unhashed name, 600 s for every class      28.1 ms
  immutable asset + validated document      90.4 ms
  immutable asset + 60 s windowed document  22.1 ms
```

The most instructive column in the table is the one that comes out the same across every
policy: **the byte hit ratio is identical across all three.** How much body comes down
from the origin is set not by the policy but by how many times the content actually
changes. If an asset changes six times a day, every edge node downloads it at most six
times.

What the policy changes is **the number of requests.** In the second row, because the
document is validated on every use, a full round trip to the origin is paid per view —
about 90 milliseconds for nine thousand kilometers, without a single byte coming down.
This is why the third column misleads as a success metric: a release can hold a 99.96%
byte hit ratio while still charging users an intercontinental round trip per page.

The third row ties the trade-off to a measure. Giving the document even a short
freshness window cuts the round trip paid per view to a quarter; the price is that the
document served may be up to sixty seconds stale after a release. This is the same as
the window calculation from the Incremental Regeneration lesson; there, freshness is
traded against origin load, here freshness is traded against latency.

The first row shows the gain from hashed naming: with unhashed names, even unchanged
assets are revalidated once the window closes (a hundred forty-four round trips per
asset per day); with the immutable marker, these round trips disappear entirely.

#### Invalidation and the Moment of Deploy

When a release happens, the copies on the edge nodes are still stale. There are two
paths, and they split by file class.

**Nothing is done for immutably named assets.** The new release's assets carry new
names; since those names exist on no edge yet, the first request gets a miss. The
additive-deploy rule from the Cache Busting lesson holds for edge copies too.

**The document requires invalidation.** Because the document's address does not change,
the edge copy must be deleted by hand or forced into a validation. The measurement's
sixth and seventh rows show this: a deleted copy gets a miss on the next request.

Two properties of invalidation affect the decision. First, it is **not instant**: it
takes time to propagate across nodes, and different users see different versions
meanwhile. Second, it is **not sufficient on its own**: the copy in the user's own
browser cache cannot be reached from the edge. This is why the document is not given a
long lifetime.

#### Path Resolution and the Fallback Document

In static hosting, a path's counterpart is a file. If addresses are known at build time,
a file is produced for each and the mapping is direct. If an address carries a station
identity or a date range not known at build time, no file can be produced.

The solution used in that case is the **fallback document**: any path not mapped to a
known file resolves to the application's document, and the client-side router interprets
the path. The measurement's path-resolution section shows this: an unknown page path
gets the document and a `200` status code.

The fallback document has two rules.

**Asset paths must not fall back.** A hashed name not found must get a `404`. If it falls
back, the browser gets HTML where it expected a script; the error shows up as a parsing
failure, far from its real cause. The measurement's last row confirms this distinction.

**A page that genuinely does not exist must return `404`.** Resolving every path to the
document with `200` reports a nonexistent resource as existing, and indexers accept that
response as correct. If the list of known page paths can be produced at build time, paths
outside it get a real error response.

#### The Limit of This Model

The power of static hosting comes from the response being independent of the request;
its limit comes from the same place.

Personalized content cannot be produced: authorization-dependent sections,
session-dependent views, and decisions based on the request's headers have no place in
this model. The `Vary` header can vary the response along one dimension of the request,
but every variant is a separate cache copy; as variants grow, the hit ratio drops and the
model's gain erodes. Work that requires a secret is also excluded, since every byte
served is public.

These two limits require code that runs at request time.

#### Summary

- In static hosting, publishing is a file copy; per-request cost is close to zero,
  scaling is done by increasing the number of copies, and the response is independent of
  the request.
- The edge cache's four states are measurable: miss, hit, validated, and refreshed. All
  but hit make one round trip to the origin; a bodyless validation is a full round trip
  too.
- The byte hit ratio is set by how often the content changes, not by the policy; three
  different policies yield the same byte ratio. What the policy sets is the number of
  requests.
- A document validated on every use charges a full round trip per view; giving the
  document a sixty-second window cuts that round trip to a quarter, at the price of
  bounded staleness.
- Immutably named assets require no invalidation; the document does, and invalidation
  neither propagates instantly nor reaches the user's own cache.
- Unknown paths resolve to the fallback document, but asset paths must return `404` and
  nonexistent pages must not be reported with `200`.

#### Next Step

Every piece of work static hosting leaves out — work that looks at the session, uses a
secret, or decides based on the request itself — needs code that runs at request time.
On the North Slope site, this is the archive-write endpoint, session renewal, and
authorization-dependent editing views. The next lesson asks where that code runs: in a
process that stays up continuously, in an instance started per request, or in a narrow
edge runtime. The three options split along the axes of cold start, the ability to hold
state, concurrency, and shutdown behavior, and every one of these axes is measurable.

### Server-Requiring Deployment

Source: https://academia.sh/en/courses/rendering-strategies/server-requiring-deployment

The hosting forms for code that runs at request time; measuring the long-lived process, per-request instance, and edge runtime along the axes of cold start, latency, reserved capacity, and state, plus the readiness endpoint and graceful shutdown sequence.

Every piece of work static hosting leaves out needs code that runs at request time. On
the North Slope site these are the archive-write endpoint, session renewal, and
authorization-dependent editing views; what they share is using a secret and producing
the response based on the request.

This lesson asks where that code runs. There are three options, and the difference
between them is not a matter of preference but a measurable difference in behavior.

#### Three Hosting Forms

**Long-lived process.** A fixed number of instances stays up continuously and shares
incoming requests among them. Each instance can run many requests concurrently; total
capacity is the number of instances times the per-instance concurrency limit. This
capacity is reserved regardless of whether requests arrive. Because the process stays
up, it can hold state in memory and keep a connection pool open to the data store.

**Per-request instance.** When a request arrives, an instance spins up, handles the
request, then stays idle for a while before entering shutdown. Concurrency per instance
is usually one; two concurrent requests mean two instances. If no warm instance is
available, a **cold start** is paid — the line item called startup time in the Edge
Rendering lesson. Reserved capacity tracks demand, but with a delay equal to the idle
timeout.

**Edge runtime.** The same instance model, spread across many nodes. Cold start is much
shorter, because the code that runs has a tight budget. In exchange, the data source is
remote: per the rule from the Edge Rendering lesson, every request pays that distance to
the extent the data cannot move to the edge.

#### Running the Same Load in Three Forms

All three forms can be measured on the same load trace. The simulation below uses a
virtual clock; since it never reads real time, the output is the same on every run.

```js
// runtime.mjs -- runs the same load in three runtime forms.
// A virtual clock is used; Date.now() is never called, so the result is the same on every run.

// Deterministic generator (splitmix32). The seed is fixed, so the arrival trace never changes.
function generator(seed) {
  let s = seed >>> 0;
  return () => {
    s = (s + 0x9e3779b9) >>> 0;
    let z = s;
    z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) >>> 0;
    z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0;
    return ((z ^ (z >>> 15)) >>> 0) / 4294967296;
  };
}

const DURATION = 600_000; // ms
const SERVICE = 120;      // ms, the work itself: reading data + templating
const WINDOWS = [
  ["sparse", 0, 200_000, 0.5],
  ["steady", 200_000, 400_000, 60],
  ["burst", 400_000, 410_000, 600],
  ["recovery", 410_000, 600_000, 60],
];

const rateAt = (t) => WINDOWS.find(([, a, b]) => t >= a && t < b)[3];

// Arrival trace: exponential intervals, rate varying by window.
function arrivals() {
  const r = generator(20_260_327);
  const list = [];
  let t = 0;
  while (t < DURATION) {
    t += (-Math.log(1 - r()) / rateAt(t)) * 1000;
    if (t < DURATION) list.push(t);
  }
  return list;
}

// 1) Long-lived process: a fixed number of slots is always reserved, there is no cold
//    start, and once the slots are full a request waits in queue.
function longLived(arrivals, { slots, service }) {
  const free = new Array(slots).fill(0);
  const records = [];
  for (const t of arrivals) {
    let k = 0;
    for (let i = 1; i < slots; i++) if (free[i] < free[k]) k = i;
    const start = Math.max(t, free[k]);
    records.push({ t, latency: start - t + service, cold: false });
    free[k] = start + service;
  }
  return { records, intervals: new Array(slots).fill([0, DURATION]) };
}

// 2) Per-request instance: one instance handles a single request, stays warm for
//    idleLife after finishing, then shuts down. A cold start is paid when no warm
//    instance is available.
function perRequest(arrivals, { service, cold, idleLife }) {
  const records = [], intervals = [];
  let live = [];
  for (const t of arrivals) {
    live = live.filter((o) => {
      if (o.lifeEnd > t) return true;
      intervals.push([o.spawnedAt, o.lifeEnd]);
      return false;
    });
    const warm = live.find((o) => o.busyEnd <= t);
    if (warm) {
      records.push({ t, latency: service, cold: false });
      warm.busyEnd = t + service;
      warm.lifeEnd = warm.busyEnd + idleLife;
    } else {
      records.push({ t, latency: cold + service, cold: true });
      const busyEnd = t + cold + service;
      live.push({ busyEnd, lifeEnd: busyEnd + idleLife, spawnedAt: t });
    }
  }
  for (const o of live) intervals.push([o.spawnedAt, Math.min(o.lifeEnd, DURATION)]);
  return { records, intervals };
}

// 3) Edge runtime: the same instance model, load split across nodes, data is remote.
function edgeRuntime(arrivals, { nodes, ...opts }) {
  const partitions = Array.from({ length: nodes }, () => []);
  arrivals.forEach((t, i) => partitions[i % nodes].push(t));
  const records = [], intervals = [];
  for (const partition of partitions) {
    const s = perRequest(partition, opts);
    records.push(...s.records);
    intervals.push(...s.intervals);
  }
  return { records, intervals };
}

const percentile = (list, p) => {
  const s = [...list].sort((a, b) => a - b);
  return s.length === 0 ? 0 : s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
};
const overlap = (interval, a, b) =>
  Math.max(0, Math.min(interval[1], b) - Math.max(interval[0], a));

function summary({ records, intervals }, a, b) {
  const slice = records.filter((k) => k.t >= a && k.t < b);
  const reserved = intervals.reduce((s, iv) => s + overlap(iv, a, b), 0) / 1000;
  const used = (slice.length * SERVICE) / 1000;
  return {
    requests: slice.length,
    p50: percentile(slice.map((k) => k.latency), 50),
    p95: percentile(slice.map((k) => k.latency), 95),
    p99: percentile(slice.map((k) => k.latency), 99),
    cold: (100 * slice.filter((k) => k.cold).length) / slice.length,
    reserved,
    utilization: (100 * used) / reserved,
  };
}

const arrival = arrivals();
console.log("arrival trace: " + arrival.length.toLocaleString("en-US") + " requests, " +
  DURATION / 1000 + " s, the work itself " + SERVICE + " ms");
for (const [name, a, b, rate] of WINDOWS)
  console.log("  " + name.padEnd(12) + (a / 1000) + "-" + (b / 1000) + " s   " + rate + " req/s");

const MODEL = [
  ["long-lived process (75 slots)", longLived(arrival, { slots: 75, service: SERVICE })],
  ["per-request instance", perRequest(arrival, { service: SERVICE, cold: 350, idleLife: 60_000 })],
  ["edge runtime (40 nodes)",
   edgeRuntime(arrival, { nodes: 40, service: SERVICE + 60, cold: 40, idleLife: 5_000 })],
];

console.log("\n" + "window".padEnd(12) + "model".padEnd(33) + "req".padStart(7) +
  "p50".padStart(6) + "p95".padStart(6) + "p99".padStart(6) + "cold".padStart(8) +
  "reserved inst-s".padStart(18) + "util".padStart(10));
for (const [name, a, b] of WINDOWS) {
  for (const [mname, s] of MODEL) {
    const o = summary(s, a, b);
    console.log(name.padEnd(12) + mname.padEnd(33) + String(o.requests).padStart(7) +
      o.p50.toFixed(0).padStart(6) + o.p95.toFixed(0).padStart(6) +
      o.p99.toFixed(0).padStart(6) + o.cold.toFixed(1).padStart(7) + "%" +
      o.reserved.toFixed(0).padStart(18) + o.utilization.toFixed(1).padStart(9) + "%");
  }
}
```

```
$ node runtime.mjs
arrival trace: 29,655 requests, 600 s, the work itself 120 ms
  sparse      0-200 s   0.5 req/s
  steady      200-400 s   60 req/s
  burst       400-410 s   600 req/s
  recovery    410-600 s   60 req/s

window      model                                req   p50   p95   p99    cold   reserved inst-s      util
sparse      long-lived process (75 slots)         87   120   120   120    0.0%             15000      0.1%
sparse      per-request instance                  87   120   120   470    3.4%               354      2.9%
sparse      edge runtime (40 nodes)               87   220   220   220  100.0%               446      2.3%
steady      long-lived process (75 slots)      12010   120   120   120    0.0%             15000      9.6%
steady      per-request instance               12010   120   120   120    0.2%              4133     34.9%
steady      edge runtime (40 nodes)            12010   180   180   180    0.3%              7968     18.1%
burst       long-lived process (75 slots)       5947   123   208   235    0.0%               750     95.2%
burst       per-request instance                5947   120   120   470    3.4%              2159     33.0%
burst       edge runtime (40 nodes)             5947   180   180   220    2.0%              1488     48.0%
recovery    long-lived process (75 slots)      11611   120   120   120    0.0%             14250      9.8%
recovery    per-request instance               11611   120   120   120    0.0%             14601      9.5%
recovery    edge runtime (40 nodes)            11611   180   180   180    0.0%              8197     17.0%
```

The table shows that no form dominates in every window.

**A sparse window penalizes reserved capacity.** For eighty-seven requests, the
long-lived process reserves fifteen thousand instance-seconds and uses a thousandth of
it. Per-request instance covers the same load with three hundred fifty-four
instance-seconds — forty times less. Edge runtime cold-starts every request in this
window, but the line item it pays is forty milliseconds.

**A burst window justifies reserved capacity.** The long-lived process's utilization
climbs to 95%; capacity is fully used. But the price of running at the limit is
queueing: p50 stays at 123 milliseconds while p95 rises to 208 and p99 to 235. That rise
comes not from the work taking longer but from a request waiting its turn. Per-request
instance never disturbs p50 in the same burst — there is no queue, the instance count
tracks demand.

**Cold start is invisible at the median.** In the burst window, the per-request
instance model's cold-start rate is 3.4%; p50 and p95 show none of it, p99 shows 470
milliseconds. Someone reading the measure from the average never notices the cold start
at all. Queueing and cold start are only distinguishable at the upper percentiles.

**The recovery window shows the price of the idle timeout.** After the burst ends, the
per-request instance model's reserved capacity climbs to fourteen thousand six hundred
instance-seconds and its utilization drops to 9.5% — below the long-lived process's
9.8%. The reason is that the hundreds of instances spun up during the burst stay alive
for their idle timeout. Capacity that tracks demand tracks it with a delay.

**Edge runtime's price is paid on every row.** p50 is 180 milliseconds in the loaded
windows and 220 in the sparse one; the first figure comes from paying the
sixty-millisecond distance to the data store on every request, and the
forty-millisecond gap in the second comes from every request falling into a cold start
under sparse load. The edge's gain from proximity is not in this table; the table
measures only server-side behavior. Taken together, these line items repeat the rule
from the Edge Rendering lesson: the edge pays off to the extent its data can move there
too.

#### Where State Lives

State held in memory is meaningful only in the long-lived process, and even there only
conditionally. If the instance count is greater than one, two requests can land on two
different instances; a session, counter, or cache held in one instance is absent from
the other.

This has three consequences. **Session state is held in a shared store** or lives in a
signed value the client carries; the token-storage decision from the Application
Architecture course meets the deployment decision at this point. **Rate limits and
queue counters move to the shared store**; a limit counted per instance is a limit
multiplied by the instance count. **The connection pool is per instance**; the data
store's connection limit is set by the instance count times the pool size. In the
per-request instance model, this product grows fast during a burst, and it is usually
the first line item to hit the store's limit.

Always routing the same user to the same instance — sticky assignment — looks like a
way to keep holding state in memory. It has a price: when the instance shuts down, that
user's state is lost and load distribution breaks. Moving state to the shared store
causes neither problem.

#### Readiness Endpoint and Graceful Shutdown

Server-requiring deployment has a problem with no counterpart in static hosting: a
release requires stopping a running process. If the requests it is holding are cut off
mid-flight when the process stops, the user sees the release as an error.

The correct shutdown sequence has four steps: the readiness endpoint turns negative, a
grace period is waited for the router to notice, the listener closes, and open requests
are waited out.

```js
// graceful-shutdown.mjs -- shutdown sequence of the long-lived process.
// 1) the readiness endpoint turns negative, 2) a grace period is waited for the router
// to notice, 3) the listener closes, 4) once open requests finish, the process exits.
import { createServer } from "node:http";

const WORK_DURATION = 1500;   // ms, how long the archive write takes
const READINESS_GRACE = 600;  // ms, gap between the readiness endpoint and the listener closing

let ready = true;
let openRequests = 0;

const server = createServer(async (request, response) => {
  response.sendDate = false;
  if (request.url === "/ready") {
    response.writeHead(ready ? 200 : 503, { "Content-Type": "text/plain" })
      .end(ready ? "ready\n" : "shutting down\n");
    return;
  }
  openRequests += 1;
  await new Promise((c) => setTimeout(c, WORK_DURATION));
  openRequests -= 1;
  response.writeHead(200, { "Content-Type": "text/plain" }).end("archived\n");
});

server.listen(8182, "127.0.0.1", () => console.log("server  : listening"));

process.on("SIGTERM", () => {
  ready = false;
  console.log("server  : SIGTERM received, readiness turned negative, open requests " + openRequests);
  setTimeout(() => {
    server.close(() => {
      console.log("server  : open requests " + openRequests + ", exiting process");
      process.exit(0);
    });
    console.log("server  : listener closed, no new connections accepted");
  }, READINESS_GRACE);
});
```

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

```bash
#!/usr/bin/env bash
# Measures the shutdown sequence: the open request finishes, no new connection is accepted.
node graceful-shutdown.mjs &
server=$!
sleep 0.5

curl -sS -o /dev/null \
  -w 'client  : in-flight request status %{http_code}, duration %{time_total} s\n' \
  http://127.0.0.1:8182/archive &
slow=$!
sleep 0.3

curl -sS -o /dev/null -w 'client  : readiness endpoint %{http_code} (before SIGTERM)\n' \
  http://127.0.0.1:8182/ready
kill -TERM "$server"
sleep 0.3
curl -sS -o /dev/null -w 'client  : readiness endpoint %{http_code} (after SIGTERM)\n' \
  http://127.0.0.1:8182/ready

sleep 0.6
if curl -sS -o /dev/null http://127.0.0.1:8182/archive 2> /dev/null; then
  echo "client  : new request accepted"
else
  echo "client  : new request could not connect, curl exit code $?"
fi

wait "$slow"
wait "$server"
```

```
server  : listening
client  : readiness endpoint 200 (before SIGTERM)
server  : SIGTERM received, readiness turned negative, open requests 1
client  : readiness endpoint 503 (after SIGTERM)
server  : listener closed, no new connections accepted
client  : new request could not connect, curl exit code 7
client  : in-flight request status 200, duration 1.504674 s
server  : open requests 0, exiting process
```

All the durations depend on the environment; what matters is the sequence. When the
shutdown signal arrives, there is one open request; it completes with `200` in its full
duration. After the signal, the readiness endpoint returns `503` but the port is still
open — this grace period is left for the router to notice and drop this instance from
its list. Once the grace period ends, the listener closes and no new connection can be
made; exiting the process waits for the open request to finish.

Skipping the grace period leads to one of two failures. If the listener closes
immediately, the router — not yet aware — keeps sending requests to this instance, and
those requests get a connection error. If the readiness endpoint is never used, the same
result happens from the first millisecond of shutdown. In the per-request instance and
edge forms, this sequence is the platform's responsibility, but the work's duration must
still be shorter than what the platform allows, so the open request is not cut off
mid-flight.

#### Selection Rule

The decision reduces to three measures: how regular the load is, how long the work
takes, and where the data lives.

If the load is regular and has no idle window, the long-lived process is chosen;
reserved capacity's utilization is high, there is no cold start, and a connection pool
and an in-memory cache are used. If the load is sparse or bursty, per-request instance
is chosen; reserved capacity tracks demand, at the price of cold start visible at the
upper percentiles. If the work is short, has little dependency on data, and geographic
proximity is decisive, edge runtime is chosen.

This rule does not mean a single choice is made once. On the North Slope site, static
hosting can handle the pages, a long-lived process the archive-write endpoint, and edge
runtime the routing and header-adding work. The split is made according to the work
itself.

#### Summary

- The three hosting forms split along the axes of reserved capacity, cold start,
  concurrency, and state; none dominates in every load window.
- Under sparse load, the long-lived process uses a thousandth of the capacity it
  reserves; per-request instance covers the same load with forty times less capacity.
- In a burst, the long-lived process fully uses its capacity but queueing appears: p50
  stays flat while p99 rises. Per-request instance produces no queue, only cold start.
- Cold start and queueing are invisible at the median; the measure must be read from
  the upper percentiles.
- State in memory is safe only on a single instance; session, counter, and cache move
  to the shared store, while the connection pool is multiplied by the instance count.
- Graceful shutdown has four steps: the readiness endpoint turns negative, a grace
  period is waited, the listener closes, open requests finish. Skip the grace period and
  the router keeps sending requests that then fail.

#### Next Step

Up to this point, two deployment forms and one shutdown sequence have been set up; all of them looked at a
single target, production. But a change needs to be seen working before it reaches production, and the
Development Server lesson gave the reason: working in development mode is not proof that it works in
production mode. The next lesson sets up a separate environment for every change: a deployment with its
own address, built in production mode, that does not touch production data and is not indexed. These
environments' identity is derived from the deterministic naming in the Cache Busting lesson.

### Preview Deployments

Source: https://academia.sh/en/courses/rendering-strategies/preview-deployments

Producing a separate environment for every change; a deterministic deployment id derived from content, a content-addressed store that shares blobs, previews served side by side with the release, data and side-effect isolation, and how long deployments live.

The previous two lessons looked at production: how the output is served and where the code that runs at
request time lives. Seeing that a change works before it reaches production is a separate problem, and the
Development Server lesson gave the reason — working in development mode is not proof that it works in
production mode.

This lesson sets up a separate environment for every change: a deployment built in
production mode, with its own address, that does not touch production and is not
indexed.

#### What a Preview Deployment Must Satisfy

A preview deployment works only when it satisfies six conditions at once.

**Same pipeline as the release.** Same build steps, same mode, same checks. A preview
produced by a different pipeline does not prove what it set out to prove.

**Stable, derived address.** The deployment's id is derived from its content; the same
change always yields the same id. The determinism condition from the Cache Busting
lesson turns into an id here.

**Able to live alongside the release.** A preview's existence does not change the
release's behavior in any way; both can be served from the same server at the same time.

**Data and side-effect isolation.** A preview does not write to production data and does
not trigger outbound side effects.

**Not indexed, access controlled.** Preview addresses must not enter search indexes and
must not be public.

**Finite lifetime.** The deployment is removed once the change closes.

#### Deployment Id and the Shared Store

The script below builds three branches — release and two changes — each with its own
environment, writes the files to a content-addressed store, and produces an id for each
deployment.

```js
// preview.mjs -- per-change deployment: content-addressed id and shared store.
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";

const hash = (b) => createHash("sha256").update(b).digest("hex");

// Source tree in production. {{NAME}} placeholders are filled by the build-time
// substitution from the Environment Configuration lesson.
const BASE = {
  "document.html":
    '<!doctype html><html lang="en"><body>' +
    "<h1>North Slope Measurement Station</h1>" +
    "<p>endpoint: {{MEASUREMENT_ENDPOINT}}</p><p>branch: {{BRANCH}}</p></body></html>\n",
  "assets/station.css": ".measurement { font-variant-numeric: tabular-nums; }\n",
  "assets/entry.js": 'import "./panel.js";\n',
  "assets/panel.js": 'export const draw = (o) => o.map((x) => x.temperature).join(" ");\n',
  "assets/chart.js": "export const axis = (a, b) => [a, b];\n",
};

// Three branches: release and two separate changes. Each branch builds with its own env.
const BRANCH = {
  release: { change: {}, env: { MEASUREMENT_ENDPOINT: "https://measurements.north-slope.example/v1" } },
  "threshold-warning": {
    change: {
      "assets/panel.js":
        'export const draw = (o) =>\n' +
        '  o.map((x) => (x.temperature < -20 ? "!" : "") + x.temperature).join(" ");\n',
    },
    env: { MEASUREMENT_ENDPOINT: "https://staging.measurements.north-slope.example/v1" },
  },
  "date-format": {
    change: {
      "assets/chart.js": "export const axis = (a, b) => [a, b, \"UTC\"];\n",
      "assets/station.css":
        ".measurement { font-variant-numeric: tabular-nums; font-feature-settings: \"ss01\"; }\n",
    },
    env: { MEASUREMENT_ENDPOINT: "https://staging.measurements.north-slope.example/v1" },
  },
};

const STORE = "store";
const counter = { written: 0, reused: 0 };

function build(branchName) {
  const { change, env } = BRANCH[branchName];
  const vars = { ...env, BRANCH: branchName };
  const manifest = {};
  for (const path of Object.keys(BASE).sort()) {
    const source = change[path] ?? BASE[path];
    const body = source.replace(/\{\{([A-Z_]+)\}\}/g, (t, name) => vars[name] ?? t);
    const h = hash(body);
    const target = join(STORE, h);
    if (existsSync(target)) counter.reused += 1;
    else { writeFileSync(target, body); counter.written += 1; }
    manifest[path] = h;
  }
  // Deployment id: hash of the sorted list of path-hash pairs.
  const id = hash(Object.entries(manifest).map(([path, h]) => path + " " + h).join("\n"))
    .slice(0, 12);
  mkdirSync(join("deployments", id), { recursive: true });
  writeFileSync(join("deployments", id, "manifest.json"),
    JSON.stringify({ branch: branchName, files: manifest }, null, 2) + "\n");
  return { id, manifest };
}

rmSync(STORE, { recursive: true, force: true });
rmSync("deployments", { recursive: true, force: true });
mkdirSync(STORE, { recursive: true });

const result = {};
for (const branch of Object.keys(BRANCH)) result[branch] = build(branch);

console.log("branch".padEnd(18) + "deployment id".padEnd(18) + "files");
for (const [branch, s] of Object.entries(result))
  console.log(branch.padEnd(18) + s.id.padEnd(18) + Object.keys(s.manifest).length);

console.log("\nstore: " + counter.written + " separate blobs written, " +
  counter.reused + " blobs reused (total " +
  (counter.written + counter.reused) + " file entries)");

// A second build from the same source must give the same id.
const rebuild = Object.fromEntries(Object.keys(BRANCH).map((b) => [b, build(b).id]));
console.log("second build gave the same ids: " +
  Object.entries(result).every(([b, s]) => s.id === rebuild[b]));

// Mark the release deployment and write the ids to the file the server will read.
writeFileSync("current.txt", result.release.id + "\n");
writeFileSync("ids.txt",
  Object.entries(result).map(([b, s]) => b + " " + s.id).join("\n") + "\n");

console.log("\ndifference between deployments (compared with release):");
for (const branch of ["threshold-warning", "date-format"]) {
  const diff = Object.keys(BASE).sort()
    .filter((path) => result[branch].manifest[path] !== result.release.manifest[path]);
  console.log("  " + branch.padEnd(18) + diff.length + "/" + Object.keys(BASE).length +
    " files different: " + diff.join(", "));
}
```

#### Serving Side by Side

All three deployments are served from the same server. The release sits at the root,
previews sit under their ids.

```js
// preview-server.mjs -- serves deployments side by side by their id.
// The release deployment sits at the root, preview deployments sit under /p/<id>/.
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { join, extname } from "node:path";

const RELEASE = readFileSync("current.txt", "utf8").trim();
const TYPE = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
};

createServer((request, response) => {
  response.sendDate = false;
  const part = request.url.split("?")[0].split("/").filter(Boolean);
  const preview = part[0] === "p";
  const id = preview ? part[1] : RELEASE;
  const path = (preview ? part.slice(2) : part).join("/") || "document.html";

  const manifestPath = join("deployments", id, "manifest.json");
  if (!existsSync(manifestPath)) {
    response.writeHead(404, { "Content-Type": "text/plain" }).end("no such deployment\n");
    return;
  }
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
  const blob = manifest.files[path];
  if (!blob) {
    response.writeHead(404, { "Content-Type": "text/plain" }).end("no such file\n");
    return;
  }
  const body = readFileSync(join("store", blob));
  const headers = {
    "Content-Type": TYPE[extname(path)] ?? "application/octet-stream",
    "Content-Length": body.length,
    "X-Deployment": id,
    "X-Branch": manifest.branch,
  };
  // Preview deployments must not be indexed and must not be confused with the release address.
  if (preview) headers["X-Robots-Tag"] = "noindex, nofollow";
  response.writeHead(200, headers).end(body);
}).listen(8183, "127.0.0.1", () => console.log("server: 127.0.0.1:8183"));
```

The script below runs in the same directory as these two files. Port 8183 is arbitrary
and must be free; if it is in use, change it in both files.

```bash
#!/usr/bin/env bash
# Produces three deployments, serves all of them side by side from the same server.
node preview.mjs
WARN=$(awk '$1 == "threshold-warning" { print $2 }' ids.txt)
DATE=$(awk '$1 == "date-format" { print $2 }' ids.txt)

node preview-server.mjs > /dev/null &
server=$!
sleep 1

echo "--- release (root) ---"
curl -sS -D - -o /dev/null http://127.0.0.1:8183/ | grep -iE '^(x-deployment|x-branch|x-robots-tag)'
curl -sS http://127.0.0.1:8183/assets/panel.js

echo "--- preview: threshold-warning ---"
curl -sS -D - -o /dev/null "http://127.0.0.1:8183/p/$WARN/" | grep -iE '^(x-deployment|x-branch|x-robots-tag)'
curl -sS "http://127.0.0.1:8183/p/$WARN/assets/panel.js"
curl -sS "http://127.0.0.1:8183/p/$WARN/"

echo "--- preview: date-format ---"
curl -sS "http://127.0.0.1:8183/p/$DATE/assets/chart.js"
curl -sS -o /dev/null -w 'unknown deployment id : %{http_code}\n' \
  http://127.0.0.1:8183/p/000000000000/

kill "$server"
```

```
branch            deployment id     files
release           e4fb083337b5      5
threshold-warning c4b66ffc753e      5
date-format       775994305775      5

store: 10 separate blobs written, 5 blobs reused (total 15 file entries)
second build gave the same ids: true

difference between deployments (compared with release):
  threshold-warning 2/5 files different: assets/panel.js, document.html
  date-format       3/5 files different: assets/chart.js, assets/station.css, document.html
--- release (root) ---
X-Deployment: e4fb083337b5
X-Branch: release
export const draw = (o) => o.map((x) => x.temperature).join(" ");
--- preview: threshold-warning ---
X-Deployment: c4b66ffc753e
X-Branch: threshold-warning
X-Robots-Tag: noindex, nofollow
export const draw = (o) =>
  o.map((x) => (x.temperature < -20 ? "!" : "") + x.temperature).join(" ");
<!doctype html><html lang="en"><body><h1>North Slope Measurement Station</h1><p>endpoint: https://staging.measurements.north-slope.example/v1</p><p>branch: threshold-warning</p></body></html>
--- preview: date-format ---
export const axis = (a, b) => [a, b, "UTC"];
unknown deployment id : 404
```

The output makes four points.

**The id comes from content.** Ids produced a second time from the same source are
identical. This makes a deployment's source verifiable through its address, and it is
the basis for the rollback operation in the next lesson: the version to return to
corresponds to content, not a name.

**The environment enters the id.** In the threshold-warning branch, only one file was
changed, yet two files differ from the release; the second is the document, and the
reason for the difference is that the measurement endpoint changed. This measures the
result stated in the Environment Configuration lesson about build-time substitution:
when the environment changes, the output bytes change too, so the bytes tested in a
preview are not the bytes that reach production. The only way to zero out this
difference is to move environment-bound values into runtime configuration.

**The store shares blobs.** Fifteen file entries came down to ten separate blobs. The
ratio improves as the file count grows: a deployment's cost is not its whole output but
only its changed files. In a release with hundreds of files, a one-line change writes
only a handful of new blobs.

**A preview is an id separate from the release.** The response headers say which
deployment is served, previews are marked closed to indexing, and an undefined id gets
`404`. The release at the root is unaffected by the previews' existence.

#### Data and Side-Effect Isolation

The easy part of a preview is the bytes; the hard part is what those bytes touch when
they run.

The rule is one sentence: **a preview cannot write to production data.** Enforcing it is
a configuration decision, not a code decision. The scope declaration from the
Environment Configuration lesson does its second job here: the data endpoint's address,
which account the write key belongs to, and where outbound calls go are all set by
environment variables. The preview environment binds these variables to staging
counterparts.

It is wrong for this split to turn into an `if (preview)` branch in code. That branch
would also exist in the production build, and isolation is lost every time its condition
is evaluated wrong. If the environment draws the boundary instead, no path to the
staging endpoint exists in the production build at all.

Three classes of side effect are handled separately. **Writes** go to a separate data
copy; the copy is a small, seeded set, not a mirror of production data — if it were a
mirror, personal data would be visible from a preview address. **Outbound
notifications** — email, a payment call, third-party triggers — are either disabled in a
preview or redirected to a collector. **Schema changes** are applied to the preview's own
copy; a migration applied to a shared schema puts a change that has not yet shipped into
production.

Access is part of isolation too. The `X-Robots-Tag` header is aimed only at indexers; it
does not block someone who knows the address. An unguessable id is not access control —
the address ends up in browser history, link previews, and referrer headers. Access
control is a separate layer.

#### Lifetime, Cost, and Cleanup

An environment per change multiplies the deployment count by the change count. Cost has
two line items, and both can be bounded.

**Storage** is bounded by the shared blob store. Deleting a deployment means deleting its
manifest; the blobs themselves are deleted only after confirming that no other manifest
references them. A cleanup run without reference counting can delete the release
deployment's files too.

**Running code**, if it exists, has a cost set directly by which of the three forms from
the Server-Requiring Deployment lesson is chosen. Previews are used sparsely and
irregularly; this is the window in that lesson's table where per-request instance looks
best.

The lifetime rule is simple: a preview is removed once its change closes. It has one
exception — **every deployment that reaches production is kept.** Kept deployments form
a version history, and the next lesson's rollback operation returns to exactly this
history.

#### Summary

- A preview deployment must satisfy six conditions at once: same pipeline as the
  release, a derived address, the ability to live alongside the release, data
  isolation, no indexing, and a finite lifetime.
- The deployment id is a hash of path-hash pairs; the same source yields the same id,
  and the id becomes a verifiable name for the content.
- Environment variables enter the output's bytes, so preview and release bytes
  diverge; the way to zero out that gap is moving values into runtime configuration.
- A content-addressed store shares blobs: in the example, fifteen file entries came
  down to ten blobs, and a deployment's cost is only its changed files.
- Isolation is established through environment configuration, not a code branch;
  writes, outbound notifications, and schema changes are each handled separately.
- The no-indexing header is not access control; deleting a deployment must check blob
  reference counts, and deployments that reach production must be kept.

#### Next Step

A change tested in preview reaches every user at once when it goes to production. That
means every flaw preview could not catch reaches everyone at once too: load behaviors
that show up only under real traffic, edge cases found only in real data, and failures
seen only on certain devices. The next lesson takes production release out of being a
single switch flip; it routes a small share of traffic to the new version, watches the
error rate against a threshold, and reverts once the threshold is crossed. What it
reverts to is the deployments kept in this lesson, and the time that takes is a
measurable quantity.

### Gradual Rollout and Rollback

Source: https://academia.sh/en/courses/rendering-strategies/gradual-rollout-and-rollback

Bounding release risk with a share of traffic; sticky assignment, an observation window, and a noise-resistant threshold for automatic rollback, atomic version switching through a pointer change, and handling non-revertible changes with an expand-contract sequence.

A change tested in preview reaches every user at once when it goes to production. So
does every flaw preview could not catch: load behaviors that show up only under real
traffic, edge cases found only in real data, failures seen only on certain devices.

This lesson takes release out of being a single switch flip. A small share of traffic is
routed to the new version, the error rate is watched against a threshold, and the
previous deployment is returned to once the threshold is crossed. Its question is this:
by what measures are the share, the threshold, and the rollback time chosen?

#### The Four Parts of a Staged Rollout

**Rollout plan.** The share of traffic routed to the new version increases in steps,
with observation time left between them. Each step is a decision point: continue, or
revert.

**Sticky assignment.** Which version a user sees is set by a bucket derived from their
identity, and it does not change for the rest of the rollout. If assignment were done
per request, the same user would flip between the two versions; that has three concrete
consequences — the hashed names the document carries start requesting the other
version's chunks, flows left mid-session break, and measurement gets noisy. The bucket
count sets the share's resolution: a hundred buckets means one-percent steps.

**Observation window and threshold.** The decision looks at the error rate of requests
that land on the new version in a recent window. The window must reach at least one
sample-size floor; a decision made before that is noise.

**Rollback.** Zeroing the share once the threshold is crossed. Its precondition is that
the deployment to return to is still live in production — the protected-deployments
rule from the Preview Deployments lesson does its work here.

#### Running the Same Flaw Through Four Rollout Forms

```js
// staged-rollout.mjs -- percentage-share rollout, sticky assignment, threshold-based rollback.
// A virtual clock is used; Date.now() is never called, so the result is the same on every run.
import { createHash } from "node:crypto";

// Deterministic generator (splitmix32). The seed is fixed, so the sequence is the same on every run.
function generator(seed) {
  let s = seed >>> 0;
  return () => {
    s = (s + 0x9e3779b9) >>> 0;
    let z = s;
    z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) >>> 0;
    z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0;
    return ((z ^ (z >>> 15)) >>> 0) / 4294967296;
  };
}

const DURATION = 3600;     // s
const RATE = 40;           // req/s
const USERS = 5000;
const BASE_ERROR = 0.004;  // old version's baseline error rate
const PLAN = [[0, 1], [300, 5], [600, 25], [1200, 50], [1800, 100]];
const WINDOW = 120;        // s, observation window
const MIN_SAMPLE = 200;    // minimum new-version requests required in the window
const THRESHOLD = 0.02;    // window error-rate threshold
const STEP = 30;           // s, evaluation interval

// The threshold must be cleared past sampling noise: the lower bound of the rate is checked.
const lowerBound = (failed, n) => {
  const p = failed / n;
  return p - 2 * Math.sqrt((p * (1 - p)) / n);
};

// Sticky assignment: a user identity is pinned to a bucket between 0 and 99.
// The same user stays on the same side throughout the rollout.
const bucket = (user) =>
  createHash("sha256").update("user-" + user).digest().readUInt32BE(0) % 100;

const BUCKET = Array.from({ length: USERS }, (_, i) => bucket(i));
const shareAt = (t) => PLAN.filter(([at]) => at <= t).at(-1)[1];

function rollout({ newError, allAtOnce, manual }) {
  // Two separate streams: user selection and the error draw must not affect each other.
  const rUser = generator(20_260_412);
  const rError = generator(78_345_921);
  const events = [];   // { t, isNew, hasError, user }
  let rollback = null, nextEvaluation = STEP;
  const log = [];
  let lastShare = null;

  for (let n = 0; n < DURATION * RATE; n++) {
    const t = n / RATE;
    if (manual !== undefined && rollback === null && t >= manual) {
      rollback = manual;
      log.push([rollback, "detected manually and rolled back"]);
    }
    while (t >= nextEvaluation) {
      if (rollback === null && manual === undefined) {
        const windowEvents = events.filter((o) => o.isNew && o.t > nextEvaluation - WINDOW);
        const failed = windowEvents.filter((o) => o.hasError).length;
        const n = windowEvents.length;
        if (n >= MIN_SAMPLE && lowerBound(failed, n) > THRESHOLD) {
          rollback = nextEvaluation;
          log.push([rollback, "rolled back, window error rate " +
            ((100 * failed) / n).toFixed(1) + "% (" + failed + "/" + n +
            "), lower bound " + (100 * lowerBound(failed, n)).toFixed(1) + "%"]);
        }
      }
      nextEvaluation += STEP;
    }
    const share = rollback !== null ? 0 : allAtOnce ? 100 : shareAt(t);
    if (share !== lastShare && rollback === null) {
      log.push([t, "share " + share + "%"]);
      lastShare = share;
    }
    const user = Math.floor(rUser() * USERS);
    const isNew = BUCKET[user] < share;
    const hasError = rError() < (isNew ? newError : BASE_ERROR);
    events.push({ t, isNew, hasError, user });
  }

  const newEvents = events.filter((o) => o.isNew);
  const failed = newEvents.filter((o) => o.hasError);
  return {
    rollback,
    newRequests: newEvents.length,
    errors: failed.length,
    users: new Set(failed.map((o) => o.user)).size,
    log,
  };
}

console.log("load: " + (DURATION * RATE).toLocaleString("en-US") + " requests / " + DURATION +
  " s, " + USERS.toLocaleString("en-US") + " users, sticky assignment");
console.log("plan: " + PLAN.map(([t, p]) => t + " s " + p + "%").join(", "));
console.log("observation: " + WINDOW + " s window, at least " + MIN_SAMPLE +
  " samples, threshold " + (100 * THRESHOLD).toFixed(1) + "%, evaluated every " + STEP + " s");
console.log("old version's baseline error rate: " + (100 * BASE_ERROR).toFixed(1) + "%");

for (const [name, opts] of [
  ["healthy new version (error rate 0.5%)", { newError: 0.005 }],
  ["flawed new version (error rate 6.0%)", { newError: 0.06 }],
]) {
  const s = rollout(opts);
  console.log("\nscenario: " + name);
  for (const [t, message] of s.log)
    console.log("  " + (t.toFixed(0) + " s").padStart(8) + "  " + message);
  console.log("  result: " + (s.rollback === null ? "rollout completed" : "rolled back") +
    ", " + s.newRequests.toLocaleString("en-US") + " requests to the new version, " +
    s.errors + " errors, " + s.users + " distinct users affected");
}

console.log("\nsame flaw (6.0%), four rollout and detection forms:");
console.log("  " + "form".padEnd(34) + "detected".padStart(9) + "errors".padStart(8) +
  "affected users".padStart(21));
for (const [name, opts] of [
  ["staged + automatic monitoring", { newError: 0.06 }],
  ["all-at-once + automatic monitoring", { newError: 0.06, allAtOnce: true }],
  ["staged + manual detection (600 s)", { newError: 0.06, manual: 600 }],
  ["all-at-once + manual detection (600 s)", { newError: 0.06, allAtOnce: true, manual: 600 }],
]) {
  const s = rollout(opts);
  console.log("  " + name.padEnd(34) + (s.rollback + " s").padStart(9) +
    String(s.errors).padStart(8) +
    (s.users + " (" + ((100 * s.users) / USERS).toFixed(1) + "%)").padStart(21));
}
```

```
$ node staged-rollout.mjs
load: 144,000 requests / 3600 s, 5,000 users, sticky assignment
plan: 0 s 1%, 300 s 5%, 600 s 25%, 1200 s 50%, 1800 s 100%
observation: 120 s window, at least 200 samples, threshold 2.0%, evaluated every 30 s
old version's baseline error rate: 0.4%

scenario: healthy new version (error rate 0.5%)
       0 s  share 1%
     300 s  share 5%
     600 s  share 25%
    1200 s  share 50%
    1800 s  share 100%
  result: rollout completed, 91,166 requests to the new version, 471 errors, 444 distinct users affected

scenario: flawed new version (error rate 6.0%)
       0 s  share 1%
     300 s  share 5%
     390 s  rolled back, window error rate 7.4% (15/202), lower bound 3.7%
  result: rolled back, 365 requests to the new version, 22 errors, 21 distinct users affected

same flaw (6.0%), four rollout and detection forms:
  form                               detected  errors       affected users
  staged + automatic monitoring         390 s      22            21 (0.4%)
  all-at-once + automatic monitoring     30 s      67            67 (1.3%)
  staged + manual detection (600 s)     600 s      44            42 (0.8%)
  all-at-once + manual detection (600 s)    600 s    1490         1275 (25.5%)
```

The output makes four points.

**A small share cannot decide.** The flawed version was not detected while its share
was one percent. The reason is arithmetic: one percent of forty requests per second is
0.4 requests per second; in a hundred-twenty-second window that does not even reach
fifty requests, short of the two-hundred minimum-sample floor. Detection happened right
after the window filled once the share reached five percent. The first step's share is
set not by risk appetite but by the sample size a decision needs.

**The threshold is defined together with noise.** The decision looks at the error
rate's lower bound, not its point value: in the flawed version, the rate is 7.4 percent,
its lower bound 3.7 percent, and the 2 percent threshold is exceeded by that lower
bound. A rule that looked at the point value would also find windows in the healthy
version that cross the threshold now and then — seeing five errors in two hundred
samples is not unusual for a version whose true rate is 0.5 percent. The lower-bound
condition filters out these false rollbacks.

**Staged rollout slows detection but shrinks impact.** In an all-at-once rollout, the
flaw is detected in thirty seconds; staged, in three hundred ninety. The number of
affected users, by contrast, differs by about three times under automatic monitoring
and about thirty times under manual detection. What staged rollout sells is not speed,
it is margin for error.

**Without automatic monitoring, staged rollout alone is not enough.** The last two rows
apply the same ten-minute detection delay to both rollout forms: forty-two users are
affected in the staged case, one thousand two hundred seventy-five — a quarter of all
users — in the all-at-once case. The rollout plan and monitoring work together; without
one, the other falls short.

#### The Condition for Being Revertible

Rollback is not rebuilding and republishing the old source; that would be a new release
and would take as long as a build. For rollback to be short, two conditions are
required: **the deployment to return to is still live in production**, and **which one
is live is determined by a single pointer.**

The pointer's change must be atomic: it is written to a temporary file, then moved onto
the old one with `rename(2)`. This call either happens entirely or not at all; a reader
sees either the old value or the new one, never half of it.

```js
// switch.mjs -- atomic switch between version-labeled deployments.
// A temporary pointer file is written first, then moved onto it with rename(2).
import { writeFileSync, renameSync, readFileSync } from "node:fs";

const target = process.argv[2];
const t0 = process.hrtime.bigint();
writeFileSync("release/current.new", target + "\n");
renameSync("release/current.new", "release/current");
const t1 = process.hrtime.bigint();

console.log("switch -> " + target + "   write + rename " +
  (Number(t1 - t0) / 1e6).toFixed(3) + " ms   pointer " +
  readFileSync("release/current", "utf8").trim());
```

```js
// reader.mjs -- reads continuously while the switch is happening; counts broken or mixed reads.
import { readFileSync } from "node:fs";

const count = new Map();
let broken = 0, inconsistent = 0;
for (let i = 0; i < 120_000; i++) {
  try {
    const version = readFileSync("release/current", "utf8").trim();
    const body = readFileSync("release/version/" + version + "/document.txt", "utf8").trim();
    if (body !== version) inconsistent += 1;
    count.set(version, (count.get(version) ?? 0) + 1);
  } catch {
    broken += 1;
  }
}
console.log("read   : " + [...count].sort().map(([k, v]) => k + "=" + v).join("  ") +
  "   broken=" + broken + "   inconsistent=" + inconsistent);
```

```bash
#!/usr/bin/env bash
# Two version-labeled deployments, atomic switch, and rollback.
rm -rf release
mkdir -p release/version/2026.03 release/version/2026.04
echo "2026.03" > release/version/2026.03/document.txt
echo "2026.04" > release/version/2026.04/document.txt
echo "2026.03" > release/current
echo "start: $(cat release/current)"

node reader.mjs &
reader=$!
sleep 0.3
node switch.mjs 2026.04
wait "$reader"

node reader.mjs &
reader=$!
sleep 0.3
node switch.mjs 2026.03
wait "$reader"
```

```
start: 2026.03
switch -> 2026.04   write + rename 0.250 ms   pointer 2026.04
read   : 2026.03=18088  2026.04=101912   broken=0   inconsistent=0
switch -> 2026.03   write + rename 0.292 ms   pointer 2026.03
read   : 2026.03=101743  2026.04=18257   broken=0   inconsistent=0
```

Read counts depend on the machine and the load; they change on every run. Two fields do
not change: no read breaks, and no read comes back with content that disagrees with the
pointer. Every one of the hundred twenty thousand reads sees either the old version or
the new one.

The switch itself takes under a millisecond. From this it also follows where rollback
time comes from: it comes not from the pointer changing but from the **change
propagating**. The invalidation from the Static Hosting lesson neither propagates
instantly nor reaches the user's own cache; giving the document a short freshness window
is why it also sets the upper bound on rollback time. Thanks to hashed names, the old
version's assets are still live, so rollback requires no new asset download.

#### Non-Revertible Changes

Reverting code is not reverting what that code did. Three classes of change fall
outside this rule, and staged rollout's protection does not work for them.

**Schema changes.** If the new version removed a field, the reverted old version looks
for that field and does not find it. The solution is splitting the change into
revertible steps: **expand-contract.** First, the schema is expanded to a shape both
versions can run against — the new field is added, the old one stays. Then the code is
released and stability is waited for. Only after that, in a separate release, is the old
field contracted. Each step has its own revertibility.

**Data written in a new format.** If the old version cannot read records the new
version produces, rollback leaves the data unreadable. The rule is that read support
enters **one release ahead of** the write change: first the version that can read both
formats ships, then the version that writes the new format.

**Side effects that have already left.** A notification sent, a payment call made, or a
record created at a third party cannot be reverted. The only path for these is rolling
forward: shipping a new version that fixes the flaw. All staged rollout provides here is
having kept the number of users the side effect reached small.

Together these three give one design rule: **revertibility is a property, it does not
come for free.** The moment a change becomes non-revertible, staged rollout only
shrinks the impact; it does not make the fix easier.

#### Summary

- Staged rollout has four parts: rollout plan, sticky assignment, observation window
  and threshold, rollback. The bucket count sets the share's resolution.
- The first step's share is set not by risk appetite but by the sample size a decision
  needs; in the example, a one-percent share could not fill the window and so could not
  decide.
- The threshold is applied to the error rate's lower bound, not its point value; a rule
  that looked at the point value would also roll back healthy versions.
- Staged rollout slows detection but shrinks impact; the gap grows as detection delay
  grows — forty-two users in the example against one thousand two hundred seventy-five.
- Rollback is returning to a protected deployment through a single pointer; the switch
  made with `rename(2)` takes under a millisecond, and what sets the duration is the
  change propagating.
- Schema changes are made in an expand-contract sequence, and write-format changes ship
  with read support one release ahead; side effects that have already left are answered
  only by rolling forward.

#### Next Step

Every deployment form up to this point shared the same assumption: the output is put at
an address, the user opens that address with a browser, and the new version takes
effect on the next request. There are targets where this assumption does not hold. The
same build output can be placed inside a desktop shell, inside a web view embedded in a
native body, or in a package distributed from an app store. Addresses then have to be
relative, the update channel goes through a review process, and rollback is counted not
in the milliseconds measured in this lesson but in days. The final lesson takes up these
targets and the constraints they bring.

### Non-Web Targets

Source: https://academia.sh/en/courses/rendering-strategies/non-web-targets

Distributing the same build output through a desktop shell, a web view embedded in a native body, and an app store package; auditing the broken root assumption, narrowing the bridge surface, and the divergence between shell and content versions.

Every deployment form up to this point shared the same assumption: the output is put
at an address, the user opens that address with a browser, and the new version takes
effect on the next request.

There are targets where this assumption does not hold. The same build output can be
placed inside a desktop shell, inside a web view embedded in a native body, or in a
package distributed from an app store. This lesson takes up which assumptions break
when the target changes, and how each break is closed.

#### Four Assumptions That Change

**Load source and root.** If the document loads from the file system rather than an
HTTP root, no link starting with `/` resolves to the application's directory. Every
root-relative link breaks.

**Origin.** A document loaded from a local file has an origin that is either empty or unique. Because the
same-origin policy, storage keys, and service worker registration are all defined through origin, a
storage scheme that works on the web lands in a different key space inside the shell, or cannot be used at
all. Cookies are defined through domain rather than origin, but with an empty origin there is no domain
for them to attach to either.

**Update channel.** On the web, a new version takes effect on the next request; the
previous lesson's pointer switch was under a millisecond. On the shell side, an update
is either a download or a review process, and rollback is counted not in milliseconds
but in days.

**Network assumption.** The shell can open offline. The offline strategies from the
Browser and the Web Platform course are not an option here, they are default behavior.

There is also a quiet change in a trade-off. On the web, code splitting's gain was
speeding up the first paint; in a packaged target, every chunk already sits on local
disk. Code splitting's counterpart there is **update size**: unchanged chunks are not
downloaded again.

#### The Absolute-Path Assumption

The first break can be detected mechanically and closed largely mechanically.

```js
// path-check.mjs -- finds absolute-path assumptions in the output tree and converts them to relative.
import { posix } from "node:path";

// Output tree: path -> body. Every link is currently written root-relative.
const OUTPUT = {
  "document.html":
    '<link rel="stylesheet" href="/assets/station.css">\n' +
    '<script type="module" src="/assets/entry.js"></script>\n' +
    '<img src="/assets/logo.svg" alt="North Slope">\n',
  "archive/2026-03.html":
    '<link rel="stylesheet" href="/assets/station.css">\n' +
    '<script type="module" src="/assets/entry.js"></script>\n',
  "assets/station.css":
    '.map { background-image: url("/assets/map.svg"); }\n' +
    '@font-face { font-family: "Measurement"; src: url("/assets/measurement.woff2") format("woff2"); }\n',
  "assets/entry.js":
    'import { draw } from "/assets/panel.js";\n' +
    'export const backend = (name) => fetch("/data/" + name + ".json");\n',
};

const PATTERN = [
  [/((?:href|src)=")(\/[^"]+)(")/g, "markup link"],
  [/(url\(")(\/[^"]+)("\))/g, "style url()"],
  [/(from ")(\/[^"]+)(")/g, "module specifier"],
];
// Path concatenated at runtime: cannot be fixed mechanically.
const CONCATENATED = /"(\/[^"]*)"\s*\+/g;

function toRelative(sourcePath, absolute, isModule) {
  const rel = posix.relative(posix.dirname(sourcePath), absolute.slice(1));
  return isModule && !rel.startsWith(".") ? "./" + rel : rel;
}

const findings = [];
const newOutput = {};
for (const [path, body] of Object.entries(OUTPUT)) {
  let result = body;
  for (const [pattern, kind] of PATTERN) {
    result = result.replace(pattern, (t, before, absolute, after) => {
      const rel = toRelative(path, absolute, kind === "module specifier");
      findings.push([path, kind, absolute, rel]);
      return before + rel + after;
    });
  }
  for (const [, absolute] of body.matchAll(CONCATENATED))
    findings.push([path, "concatenated path", absolute, "BY HAND"]);
  newOutput[path] = result;
}

console.log("file".padEnd(22) + "kind".padEnd(18) + "absolute".padEnd(28) + "relative");
for (const [path, kind, absolute, rel] of findings)
  console.log(path.padEnd(22) + kind.padEnd(18) + absolute.padEnd(28) + rel);

const remaining = Object.entries(newOutput)
  .flatMap(([path, body]) => [...body.matchAll(/"(\/[^"]+)"/g)].map(([, m]) => path + " -> " + m));
console.log("\nabsolute paths remaining after conversion: " + (remaining.join(", ") || "none"));

console.log("\n-- two converted files --");
for (const path of ["archive/2026-03.html", "assets/entry.js"])
  process.stdout.write(path + ":\n" + newOutput[path]);
```

```
$ node path-check.mjs
file                  kind              absolute                    relative
document.html         markup link       /assets/station.css         assets/station.css
document.html         markup link       /assets/entry.js            assets/entry.js
document.html         markup link       /assets/logo.svg            assets/logo.svg
archive/2026-03.html  markup link       /assets/station.css         ../assets/station.css
archive/2026-03.html  markup link       /assets/entry.js            ../assets/entry.js
assets/station.css    style url()       /assets/map.svg             map.svg
assets/station.css    style url()       /assets/measurement.woff2   measurement.woff2
assets/entry.js       module specifier  /assets/panel.js            ./panel.js
assets/entry.js       concatenated path /data/                      BY HAND

absolute paths remaining after conversion: assets/entry.js -> /data/

-- two converted files --
archive/2026-03.html:
<link rel="stylesheet" href="../assets/station.css">
<script type="module" src="../assets/entry.js"></script>
assets/entry.js:
import { draw } from "./panel.js";
export const backend = (name) => fetch("/data/" + name + ".json");
```

The output says three things.

**A relative path depends on the file's own location.** The same style sheet is
`assets/station.css` from the root document and `../assets/station.css` from the
document in the subdirectory. The conversion has to be done file by file.

**A module specifier needs a separate rule.** A specifier in the form `panel.js` is a
bare specifier, and the module loader does not resolve it as a file; the `./` prefix is
required.

**A path concatenated at runtime cannot be fixed mechanically.** The `/data/` prefix
left in the last row cannot be caught by any string conversion, because the address is
concatenated with a variable. The only fix for paths of this kind is computing the base
in a single place: a module resolves its own location through `import.meta.url`, and
every runtime address is built from that base. The audit's job is to find violations of
this rule at build time.

#### The Bridge Surface

The second break is not a path problem, it is a privilege problem. The set of functions
the shell exposes to the web view — the **bridge** — is the application's native
privilege surface. The rule is one sentence: a capability opened on the bridge opens the
**entire** privilege it grants. `readFile(path)` is not the capability to read one file,
it is the capability to read every file.

```js
// bridge.mjs -- surface check for the bridge between the shell and the web view.
// Usage: node bridge.mjs wide | node bridge.mjs narrow
const WEIGHT = { none: 0, "user selection": 1, "app directory": 2, unbounded: 100 };

const SURFACE = {
  wide: {
    remoteContent: true,     // third-party content is embedded in the view
    originCheck: false,      // the bridge call's origin is not checked
    capability: [
      { name: "readFile(path)", scope: "unbounded", validation: "none" },
      { name: "runShell(command)", scope: "unbounded", validation: "none" },
      { name: "networkRequest(address)", scope: "unbounded", validation: "none" },
      { name: "showNotification(text)", scope: "none", validation: "type" },
    ],
  },
  narrow: {
    remoteContent: false,
    originCheck: true,
    capability: [
      { name: "pickMeasurementFile()", scope: "user selection", validation: "type" },
      { name: "writeArchive(record)", scope: "app directory", validation: "schema" },
      { name: "showNotification(text)", scope: "none", validation: "type" },
    ],
  },
};

function check(name) {
  const s = SURFACE[name];
  const findings = [];
  console.log("surface: " + name);
  console.log("  " + "capability".padEnd(24) + "scope".padEnd(20) + "validation".padEnd(12) + "weight");
  let total = 0;
  for (const c of s.capability) {
    total += WEIGHT[c.scope];
    console.log("  " + c.name.padEnd(24) + c.scope.padEnd(20) + c.validation.padEnd(12) +
      WEIGHT[c.scope]);
    if (c.scope === "unbounded") findings.push(["R1", c.name, "scope unbounded"]);
    if (c.validation === "none") findings.push(["R2", c.name, "parameter not validated"]);
  }
  if (s.remoteContent && !s.originCheck)
    findings.push(["R3", "(surface)", "has remote-origin content, bridge does not check origin"]);

  for (const [rule, target, message] of findings)
    console.log("  " + rule + "  " + target.padEnd(24) + message);
  console.log("  surface weight: " + total + "   violations: " + findings.length);
  return findings.length;
}

process.exitCode = check(process.argv[2]) > 0 ? 1 : 0;
```

```bash
#!/usr/bin/env bash
# Checks both bridge surfaces and prints the exit codes.
node bridge.mjs wide;    echo "exit code: $?"
node bridge.mjs narrow;  echo "exit code: $?"
```

```
surface: wide
  capability              scope               validation  weight
  readFile(path)          unbounded           none        100
  runShell(command)       unbounded           none        100
  networkRequest(address) unbounded           none        100
  showNotification(text)  none                type        0
  R1  readFile(path)          scope unbounded
  R2  readFile(path)          parameter not validated
  R1  runShell(command)       scope unbounded
  R2  runShell(command)       parameter not validated
  R1  networkRequest(address) scope unbounded
  R2  networkRequest(address) parameter not validated
  R3  (surface)               has remote-origin content, bridge does not check origin
  surface weight: 300   violations: 7
exit code: 1
surface: narrow
  capability              scope               validation  weight
  pickMeasurementFile()   user selection      type        1
  writeArchive(record)    app directory       schema      2
  showNotification(text)  none                type        0
  surface weight: 3   violations: 0
exit code: 0
```

Both surfaces do the same jobs; their weights differ a hundredfold. Three rules produce
the gap.

**Scope is embedded in the capability itself.** `pickMeasurementFile()` replaces
`readFile(path)`: in the second, the user picks the file, and nothing opens except the
one object picked. Privilege is bounded by the function's definition, not by its
parameter.

**The parameter is validated with a schema.** A value arriving at the bridge is
unvalidated input, because it came from the web side. Validation happens on the shell
side; a check on the web side is only for user experience.

**Origin is checked.** If third-party content enters the view — an embedded map, a
frame — the bridge is open to that content too. Without an origin check, the
application's native privileges have effectively passed into that content's hands. In a
view that loads remote content, the right decision is to never wire up the bridge at all
and keep that content in a separate view.

#### Update Channel and Version Divergence

The third break comes from the two components updating through separate channels. The
shell comes from the store channel, the web content from its own channel; the two do
not update at the same time.

```js
// version.mjs -- the share of content a given version can reach, by installed shell version.
// Semantic versioning: compared through the three-number tuple.
const INSTALLED = {
  "1.2.0": 0.08, "1.3.0": 0.14, "2.0.0": 0.33, "2.1.0": 0.31, "3.0.0": 0.14,
};
const CONTENT = [
  { version: "2026.02", minShell: "1.2.0" },
  { version: "2026.03", minShell: "2.0.0" },
  { version: "2026.04", minShell: "3.0.0" },
];

const parse = (s) => s.split(".").map(Number);
const meetsMin = (shell, required) => {
  const a = parse(shell), b = parse(required);
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
  return true;
};

console.log("installed shell distribution: " +
  Object.entries(INSTALLED).map(([s, p]) => s + " " + (100 * p).toFixed(0) + "%").join(", "));
console.log("\n" + "content version".padEnd(16) + "min shell".padEnd(14) +
  "reached share".padStart(14) + "stuck on old content".padStart(22));
for (const { version, minShell } of CONTENT) {
  const share = Object.entries(INSTALLED)
    .filter(([k]) => meetsMin(k, minShell))
    .reduce((t, [, p]) => t + p, 0);
  console.log(version.padEnd(16) + minShell.padEnd(14) +
    ((100 * share).toFixed(0) + "%").padStart(14) +
    ((100 * (1 - share)).toFixed(0) + "%").padStart(22));
}
```

```
$ node version.mjs
installed shell distribution: 1.2.0 8%, 1.3.0 14%, 2.0.0 33%, 2.1.0 31%, 3.0.0 14%

content version min shell      reached share  stuck on old content
2026.02         1.2.0                   100%                    0%
2026.03         2.0.0                    78%                   22%
2026.04         3.0.0                    14%                   86%
```

Raising the minimum shell requirement by one version drops the reached share from 100
percent to 78, and by one more version to 14. How fast the installed-version
distribution moves forward is the store channel's business and the user's update
behavior; it is not under the application's control. Two rules follow from this.

**Content version trails the shell version.** Content that depends on a new bridge
capability does not ship until the shell carrying that capability has spread widely
enough. During the gap, the content detects the capability and falls back to the old
path if it is absent.

**Compatibility runs both ways.** A new shell must run old content, and an old shell
must be able to run new content for a while too. Bridge function signatures therefore
change only in a backward-compatible way; changing one signature breaks every piece of
old content that lands on that shell.

Rollback diverges here too. If content comes from the network, the previous lesson's
pointer applies and rollback takes seconds. There is no rollback on the shell side: even
if a published version is pulled from the channel, installed copies stay installed. The
shell version belongs to the non-revertible-changes class from the Gradual Rollout and
Rollback lesson, and this is why the logic placed in the shell is kept to a minimum —
everything with any chance of changing is kept on the content side.

#### Summary

- Four assumptions break when the target changes: load root, origin, update channel,
  and network presence. Code splitting's gain shifts from first paint to update size.
- Root-relative links convert to relative links per file; module specifiers need a
  `./` prefix, and paths concatenated at runtime must resolve from a single base.
- Every capability opened on the bridge opens the entire privilege it grants; scope is
  embedded in the capability's definition, the parameter is validated with a schema on
  the shell side, and origin is checked.
- In the example, two bridge surfaces doing the same jobs differ a hundredfold in
  weight; a view that loads remote content is never wired to the bridge.
- The installed shell distribution is not under the application's control: raising the
  minimum shell requirement by one version drops the reached share from 100 percent to
  78.
- The shell version cannot be rolled back; logic with any chance of changing is kept on
  the content side, and bridge signatures change only in a backward-compatible way.

#### Course Wrap-Up

This course started with a single question: where and when is an interface's markup
produced? The rendering models measured six separate answers to that question — client,
server, build time, incremental regeneration, streaming, and edge — and showed that none
of them is generally superior; the choice is a decision made along the axes of content
freshness, personalization, and scale.

The build and bundling topic produced this decision's output: chunks from the
dependency graph, elimination of unused code, the asset pipeline, content-bound names,
development mode's separate economics, and the build-time variables that bind output to
its environment. That topic's last lesson established a single boundary that held
throughout this course: every value that enters the client bundle is public.

The deployment topic got the output to the user. Static hosting mapped every request to
a file and measured the conversion of cache policy into request hit ratio;
server-requiring deployment split three hosting forms along the axes of reserved
capacity and cold start; preview deployments gave every change its own environment;
staged rollout bounded risk with a share of traffic; and this lesson showed which
assumptions the same output loses in non-web targets.

One shared method ties all three topics together: every decision was tied to a measure.
Time to first byte, bytes re-downloaded, hit ratio, reserved instance-seconds, detection
time, number of affected users. A deployment decision not tied to a measure is not a
defensible decision.

What remains is measuring itself. The application is now built, bundled, and deployed;
next comes testing whether what ships is any good. The **Frontend Quality** course does
this under four headings: defining a performance budget and verifying it by
measurement, applying accessibility criteria at the standard's level, reducing
client-side security risk with layers of defense, and setting up a test regime that
catches all of this before it reaches production. The continuous integration pipeline
built in this course will be where those budgets and criteria get applied.

