---
title: 'Static Hosting'
source: 'https://academia.sh/en/courses/rendering-strategies/static-hosting'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:07+00:00'
license: 'CC BY-SA 4.0'
---

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