Skip to content
academia.sh

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 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close