Lesson 15 / 19
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.
Contents
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.
// 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"));
// 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.
#!/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.
// 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
404and nonexistent pages must not be reported with200.
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.