Lesson 04 / 16
Static Content Hosting
Separating the content path from the application path: measuring the combined and split arrangements with local processes, counting the requests and bytes a page load drops on the application process, and folding asset traffic into the introductory course's peak edge request rate and read egress calculations.
Contents
The previous two lessons kept content at the edge, but the content always came from the same place: the application itself. The twenty common-class objects — shell, style, script, and icon files — touch none of the application’s logic; they sit as fixed byte sequences and are still requested from the application processes’ address.
This lesson separates that path. The content path is what an unchanging file follows up to the request; the application path is where a request gets answered by running code. Static file serving was built in the Server-Side Fundamentals course, asset naming and versioning in Rendering Strategies and Infrastructure, and neither is repeated here. The only question: once the paths split, how many requests and bytes reach the application process, and what that changes in the introductory course’s arithmetic.
Two Arrangements
Combined arrangement means the same process carries both the /tracking path and the
/asset path. There is a single deployment unit and little configuration; in return, the
application process does twenty file reads on every page load.
Split arrangement hands the asset path to a separate process. The application process sees only requests that run code. The difference between the two is not a capability difference — both return the same bytes — it is a placement difference, and its result can be counted.
// static/server.mjs — brings up both arrangements at once: the app process on 8473 can carry // both the /tracking and /asset paths, the content process on 8474 carries only /asset. // Asset files are generated at startup. /counter returns the counters and resets them when called. import http from "node:http"; import fs from "node:fs"; const V5 = 480; // K01 V5: tracking response body const ASSET = [["shell.html", 6_000], ["style.css", 40_000], ["app.js", 120_000], ["font.woff2", 28_000], ["logo.svg", 4_000]]; for (let i = 1; i <= 15; i += 1) ASSET.push([`icon-${i}.svg`, 1_200]); // T13 fs.mkdirSync("asset", { recursive: true }); for (const [name, bytes] of ASSET) fs.writeFileSync(`asset/${name}`, "x".repeat(bytes)); const fresh = () => ({ tracking: 0, asset: 0, bytes: 0 }); const writeCounter = (s, response) => { // plain text so it can be read from the shell response.writeHead(200, { "content-type": "text/plain" }); response.end(`${s.tracking} ${s.asset} ${s.bytes}`); Object.assign(s, fresh()); // read, reset }; function startProcess(port, carriesAssets) { const s = fresh(); http.createServer((request, response) => { if (request.url === "/counter") return writeCounter(s, response); if (request.url.startsWith("/asset/")) { if (!carriesAssets) { response.writeHead(404); return response.end(); } const b = fs.readFileSync(`asset/${request.url.slice("/asset/".length)}`); s.asset += 1; s.bytes += b.length; response.writeHead(200, { "content-length": b.length }); return response.end(b); } const b = JSON.stringify({ tracking: request.url.slice("/tracking/".length), state: "in-transit" }); const body = b + " ".repeat(V5 - b.length); s.tracking += 1; s.bytes += V5; response.writeHead(200, { "content-type": "application/json", "content-length": V5 }); response.end(body); }).listen(port); return s; } startProcess(8473, true); // combined arrangement: the app process also carries assets startProcess(8474, true); // content process in the split arrangement
# static-measure.sh — one page load in two arrangements: first the app process also carries the # assets, then the asset path is handed to a separate process. The counter resets when read. node static/server.mjs & SERVER=$! until curl -sf http://127.0.0.1:8473/counter >/dev/null; do sleep 0.2; done ASSET=$(ls asset) load() { # $1 = the asset process's port curl -s -o /dev/null "http://127.0.0.1:8473/tracking/TR900011" for a in $ASSET; do curl -s -o /dev/null "http://127.0.0.1:$1/asset/$a"; done } curl -s -o /dev/null http://127.0.0.1:8473/counter; curl -s -o /dev/null http://127.0.0.1:8474/counter printf '%s assets, %s bytes total\n\n' "$(ls asset | wc -l | tr -d ' ')" "$(cat asset/* | wc -c | tr -d ' ')" printf '%-11s %-9s %8s %6s %9s\n' arrangement process tracking asset bytes load 8473 printf '%-11s %-9s %8s %6s %9s\n' combined app $(curl -s http://127.0.0.1:8473/counter) printf '%-11s %-9s %8s %6s %9s\n' combined content $(curl -s http://127.0.0.1:8474/counter) load 8474 printf '%-11s %-9s %8s %6s %9s\n' split app $(curl -s http://127.0.0.1:8473/counter) printf '%-11s %-9s %8s %6s %9s\n' split content $(curl -s http://127.0.0.1:8474/counter) kill $SERVER
20 assets, 216000 bytes total arrangement process tracking asset bytes combined app 1 20 216480 combined content 0 0 0 split app 1 0 480 split content 0 20 216000
These numbers belong to the measurement class; the asset set is this course’s assumption — T13: 20 assets per page, 216,000 bytes total — because a tracking page consists of shell, style, script, font, and icon files.
The measurement gives what a page load drops on the application process in each arrangement. In the combined arrangement, it handled 21 requests and sent 216,480 bytes. In the split arrangement, the same page load left it with 1 request and 480 bytes; the remaining 20 requests and 216,000 bytes went to the content process. Requests on the application process fell from 21 to 1, bytes from 216,480 to 480, and the only thing that produced this was splitting the path — no cache, no compression was added.
The split’s second result is the scaling unit. In the combined arrangement, the number of application processes is set by the sum of two separate needs: requests running code and requests reading files. The two needs do not grow in the same direction — more assets on a page does not change the code load. In the split arrangement, the two counts are set independently.
Two Names, Two TTLs
A direct consequence of splitting the path is that the content path gets its own name, which returns to the first lesson’s decision. There, the TTL was a single number doing two jobs at once: cutting query load and setting the failover window. The measurement showed a tradeoff — a 900-second TTL brings upstream queries down to 64 but a single zone failover spends 14.28% of the monthly downtime budget; a 30-second TTL cuts that budget share to 0.50% but raises upstream queries to 1,600.
With a single name, only one point on that tradeoff can be picked; with two names, two points can be picked, because their failover needs differ. The application name must fail over fast when a zone goes down, paying for it in a short TTL and its query load. The content name points to unchanging files held at twelve edges — when one goes down, the others carry the same bytes, so the failover window never becomes an outage, and a long TTL costs it nothing.
The result is that two separate rows of the first lesson’s table can be chosen at once: a short TTL for the name that resolves the application, a long one for the content name. Taking both ends of a tradeoff at once happens by splitting the responsibility that created the tradeoff.
Back to the Numbers
The measured twenty-one requests have no counterpart in the introductory course. The course’s peak edge requests/s, 513.89, counted only tracking queries and state events; the page itself and its assets never entered the arithmetic. Filling that gap needs one more assumption — T14: 2 page loads per user per day — because two of the six tracking queries are a fresh page open and four are refreshes of the same page.
// static/compute.mjs — the effect of splitting the content path on K01's computations. The // asset count and bytes per page are taken from the measurement in the previous block. const V1 = 2_000_000, PEAK_FACTOR = 3, DAY = 86_400; // K01 V1, V8 const PEAK_EDGE = 513.89, K01_EGRESS = 1.60; // K01 calculation const T14 = 2; // page loads per user per day const ASSETS = 20, ASSET_BYTES = 216_000; // measurement: previous block const EDGES = 12; // T10: previous lesson's edge count const pagePeak = ((V1 * T14) / DAY) * PEAK_FACTOR; const assetPeak = pagePeak * ASSETS; const mbit = (rate, bytes) => (rate * bytes * 8) / 1e6; const row = (name, requests, bytes) => console.log(`${name.padEnd(34)}${requests.toFixed(2).padStart(10)}${bytes.toFixed(2).padStart(12)}`); console.log(`daily page loads = ${V1 * T14}, peak = ${pagePeak.toFixed(2)} pages/s`); console.log(`${ASSETS} assets per page, ${ASSET_BYTES} bytes -> ` + `peak asset requests = ${assetPeak.toFixed(2)} requests/s`); console.log(); console.log(`${"arrangement".padEnd(34)}${"requests/s".padStart(10)}${"Mbit/s".padStart(12)}`); row("K01 calc. (assets not counted)", PEAK_EDGE, K01_EGRESS); row("combined path, app process", PEAK_EDGE + assetPeak, K01_EGRESS + mbit(pagePeak, ASSET_BYTES)); row("split path, app process", PEAK_EDGE, K01_EGRESS); row("split path, content process", assetPeak, mbit(pagePeak, ASSET_BYTES)); console.log(); console.log(`split path + edge: with assets held at ${EDGES} edges, the origin sees ` + `${ASSETS * EDGES} asset requests/day, ${((ASSET_BYTES * EDGES) / 1e6).toFixed(2)} MB`); console.log(`in the combined path, the app process sees ${((PEAK_EDGE + assetPeak) / PEAK_EDGE).toFixed(2)} ` + `times K01's requests, ${((K01_EGRESS + mbit(pagePeak, ASSET_BYTES)) / K01_EGRESS).toFixed(1)} times its bytes`); console.log(`requests the app sees in one page load: combined ${1 + ASSETS}, split 1`); console.log(`T14 sensitivity: at 1 page load per user, asset requests would be ` + `${(assetPeak / 2).toFixed(2)} requests/s, edge total ` + `${(PEAK_EDGE + assetPeak / 2).toFixed(2)} requests/s`);
daily page loads = 4000000, peak = 138.89 pages/s 20 assets per page, 216000 bytes -> peak asset requests = 2777.78 requests/s arrangement requests/s Mbit/s K01 calc. (assets not counted) 513.89 1.60 combined path, app process 3291.67 241.60 split path, app process 513.89 1.60 split path, content process 2777.78 240.00 split path + edge: with assets held at 12 edges, the origin sees 240 asset requests/day, 2.59 MB in the combined path, the app process sees 6.41 times K01's requests, 151.0 times its bytes requests the app sees in one page load: combined 21, split 1 T14 sensitivity: at 1 page load per user, asset requests would be 1388.89 requests/s, edge total 1902.78 requests/s
The table’s first row names the introductory course’s gap: 513.89 requests/s and 1.60 Mbit/s were calculated without ever counting the asset path. The second row shows what asset traffic adds: in the combined arrangement, the application process sees 3,291.67 requests/s — 6.41 times the course’s number — and sends 241.60 Mbit/s, 151.0 times its read egress. A design’s largest resource line item can be the one never calculated. The introductory course reached the same conclusion with its end-of-day job, which demanded 3.75 times the bandwidth of a continuously running flow; here the ratio is 151.0, and the line item never entered the arithmetic at all.
The third and fourth rows say what the split does: the total work does not shrink, it divides. The application process returns to the course’s 513.89 requests/s and 1.60 Mbit/s; 2,777.78 requests/s and 240.00 Mbit/s move to the content process. Splitting the content path does not fix the introductory course’s arithmetic — it names the arrangement under which that arithmetic holds. The course’s 513.89 is the number the application process sees only once the asset path is separated from it.
The fifth row connects to the previous two lessons. The twenty assets were the common class, requested from every edge in the previous lesson’s measurement, with a zero wasted share under push-based distribution. Pushed to twelve edges, the origin sees only 240 asset requests a day and sends 2.59 MB. That is how much of the 240.00 Mbit/s flowing to users actually reaches the origin. Three decisions combined — a split content path, an edge cache, and pushing the common class — bring a 240.00 Mbit/s line item down to 2.59 MB a day at the origin.
The last line is the assumption’s sensitivity. Had T14 been halved, asset requests would be 1,388.89/s, edge total 1,902.78/s — still more than three times the course’s number. Whether the assumption doubles or halves, the conclusion does not change: the calculation is wrong whenever the asset path is not counted.
Summary
- Separating the content path from the application path is a placement decision; both return the same bytes, and what changes is which process a request lands on.
- Measurement: on one page load, the application process saw 21 requests and 216,480 bytes in the combined arrangement, 1 request and 480 bytes in the split arrangement.
- In the split arrangement, scaling units become independent and the content path gets its own name; both ends of the first lesson’s TTL tradeoff — short for the application name, long for the content name — can be chosen at once.
- The introductory course’s 513.89 requests/s at the edge never counted the asset path; once asset traffic is added, the application process in the combined arrangement sees 3,291.67 requests/s and 241.60 Mbit/s — 6.41 times the requests, 151.0 times the bytes.
- The split does not reduce the total, it divides it: the application process returns to 513.89 requests/s and 1.60 Mbit/s, while 2,777.78 requests/s and 240.00 Mbit/s move to the content process. The course’s number is valid only under this arrangement.
- With the split path, edge cache, and pushing the common class combined, the origin sees 240 asset requests and 2.59 MB a day; even if T14 were halved, the calculation stays wrong whenever the asset path is not counted.
Next Step
This topic kept a share of requests from ever reaching the application: the edge cache satisfied nine-tenths of tracking responses at the boundary, the split content path kept twenty of a page load’s twenty-one requests away from the application, and the name layer chose which zone a request went to. What remains are the requests that genuinely must reach the application — 513.89 a second in the measured arrangement. One question stays unanswered: once a zone is chosen, with multiple replicas doing the same work, which one does a request land on, and who notices when that replica is down. The next lesson ties both questions to a single component and counts its responsibilities.
To keep your progress and take notes, Log in
My notes
Log in to take notes.