---
title: 'The Traffic Layer'
source: 'https://academia.sh/en/courses/traffic-layer'
language: en
updated: '2026-08-23T07:01:34+00:00'
license: 'CC BY-SA 4.0'
lessons: 16
outcomes:
    - 'Designs the layers a request passes through from the user to the application'
    - 'Justifies load balancing and content distribution decisions'
    - 'Places gateway responsibilities in the right layer'
---

# The Traffic Layer

Designing the stops a request passes through on its way from the user to the application: domain name routing policies and failover, content distribution with an edge cache, push- and pull-based propagation, the load balancer's layer 4 and layer 7 decisions and its distribution algorithms, the scaling cost of session stickiness, and the responsibilities the gateway takes to the edge; each stop is treated together with which of the numbers calculated in the introductory course it changes.

## Entry Points

The first stops where a request enters the system: using domain name resolution as a routing policy and failover instrument, the edge cache's effect on the number of requests reaching the application, propagating content with push and pull models, and separating the content path from the application path.

### Domain Name System Design

Source: https://academia.sh/en/courses/traffic-layer/domain-name-system-design

Designing name resolution as a routing decision: measuring the geolocation, latency-based, and weighted policies on the same request flow, showing through resolver shares why the weighted policy misses its target share, and converting the TTL's determination of the failover window into dropped requests against the introductory course's downtime budget.

The Introduction to System Design course defined every property, set the thresholds, and chose
the failover patterns. One thing was missing: the request had not yet entered the system. A
tracking query begins with a domain name resolving, may pass through an edge cache, lands on a
load balancer, and reaches the application through a gateway. There, the cache was only a
multiplier in the arithmetic, and routing was a one-line choice in the model.

This course takes up that path, starting with name resolution. The Domain Name System itself was
built in the How the Internet Works course in the Computer Networks curriculum — hierarchy,
resolution chain, record types, and TTL are covered there and not repeated here. The question here
is different: when a name has more than one zone behind it, which address gets returned is a
design decision with measurable consequences.

#### The Name Layer Is a Decision Point

The authoritative server need not give the same answer to every query: it can return a different
address depending on where the query came from and the state of the zones. This choice is a
routing policy, and it takes three forms.

The geolocation policy answers from a coarse, hand-written table keyed on the querier's location —
this client cluster goes to that zone — and the table can be wrong. The latency-based policy picks
whichever zone measures closer; because geographic and network proximity are not the same thing,
the two policies produce different answers. The weighted policy assigns each zone a target share
and distributes answers accordingly; its purpose is capacity control, not proximity.

A fourth use is not a policy but a failure response: when a zone becomes unreachable, the
authoritative server removes its address from the answers. This is the name layer's form of
failover, and the second half of the lesson measures its cost.

#### Setup

The measurement uses an in-process model with one authoritative server, forty resolvers, and three
zones — `b06` added to the `b34` and `b35` zones from the Availability Patterns lesson. The request
rate comes from the introductory course: peak edge requests/s is 513.89, rounded up to 514. The
model adds four new assumptions of its own; they are not folded into the introductory course's
V1–V13 table.

| Code | Assumption | Value | Rationale |
|---|---|---|---|
| T1 | TTL (baseline) | 300 s | the duration the name owner chooses, varied here |
| T2 | zone count | 3 | a backup zone is added to the two-zone failover |
| T3 | target share | 0.50 / 0.30 / 0.20 | the old zone carries more load, the new zone less |
| T4 | resolver count and share | 40, share ~ 1/(j+1) | most queries come from a small number of large resolvers |
| T5 | dead-zone detection time | 10 s | the health check counts a zone dead after this delay |

The distance matrix is in abstract units, not measured latency — the same kind of abstraction as
the introductory course's round. The resolvers' cache phases are spread evenly, since all of them
expiring at the same instant is a separate problem, measured in the Caching, Queues and
Asynchronous Processing course as the stampede effect.

```js
// name/model.mjs -- name layer model. Distance is an abstract unit, not measured latency;
// TTL, detection time, and outage start are model parameters.
export const ZONE = ["b34", "b35", "b06"];
export const GROUP = ["g1", "g2", "g3", "g4"];
export const DISTANCE = {                      // unit; not measured latency
  g1: { b34: 1, b35: 3, b06: 2 }, g2: { b34: 3, b35: 1, b06: 2 },
  g3: { b34: 2, b35: 4, b06: 1 }, g4: { b34: 2, b35: 3, b06: 4 },
};
export const GEOLOCATION = { g1: "b34", g2: "b35", g3: "b34", g4: "b06" };  // coarse zone table
export const FALLBACK = { b34: "b06", b35: "b34", b06: "b35" };            // the table's fallback
export const PATTERN = ["b34", "b35", "b06", "b34", "b35", "b34", "b35", "b06", "b34", "b34"];
export const WEIGHT = { b34: 0.5, b35: 0.3, b06: 0.2 };  // T3: target share, PATTERN yields this
export const RESOLVERS = 40;      // T4
export const DETECTION = 10;      // T5: health check marks a dead zone (s)
export const REQUESTS_PER_SECOND = 514;  // K01 "peak at the edge 513.89 requests/s", rounded up

// T4: resolver shares are not equal, share ~ 1/(j+1); the per-second requests are divided, remainder goes to the largest fractional parts.
const raw = Array.from({ length: RESOLVERS }, (_, j) => 1 / (j + 1));
const t0 = raw.reduce((a, b) => a + b, 0);
export const REQUESTS = raw.map((p) => Math.floor((p / t0) * REQUESTS_PER_SECOND));
for (const [, j] of raw.map((p, j) => [(p / t0) * REQUESTS_PER_SECOND % 1, j]).sort((a, b) => b[0] - a[0])
  .slice(0, REQUESTS_PER_SECOND - REQUESTS.reduce((a, b) => a + b, 0))) REQUESTS[j] += 1;

export function run({ policy, ttl, outageStart = null, duration = 3600, requests = REQUESTS }) {
  const s = { accepted: 0, dropped: 0, upstreamQueries: 0, distance: 0, window: 0 };
  const zoneRequests = Object.fromEntries(ZONE.map((b) => [b, 0]));
  const bound = new Array(RESOLVERS).fill(null);
  // warm-up: cache phases are spread evenly, counting starts at t >= 1
  const expires = Array.from({ length: RESOLVERS }, (_, j) => 1 - ttl + ((j * 13) % ttl));
  let patternIndex = 0;
  const isDown = (b, t) => outageStart !== null && b === "b34" && t >= outageStart;
  const isDetected = (b, t) => isDown(b, t) && t >= outageStart + DETECTION;

  const authoritative = (group, t) => {
    if (policy === "geolocation") {
      const b = GEOLOCATION[group];
      return isDetected(b, t) ? FALLBACK[b] : b;
    }
    if (policy === "latency-based") {
      return ZONE.filter((b) => !isDetected(b, t))
        .reduce((a, b) => (DISTANCE[group][b] < DISTANCE[group][a] ? b : a));
    }
    for (let k = 0; k < PATTERN.length; k++) {      // weighted: next live zone in the pattern
      const b = PATTERN[(patternIndex + k) % PATTERN.length];
      if (!isDetected(b, t)) { patternIndex = (patternIndex + k + 1) % PATTERN.length; return b; }
    }
  };

  for (let t = 1 - ttl; t <= duration; t++) {
    for (let j = 0; j < RESOLVERS; j++) {
      if (t >= expires[j]) {                        // TTL expired: asks the authoritative server
        bound[j] = authoritative(GROUP[j % GROUP.length], t);
        expires[j] = t + ttl;
        if (t >= 1) s.upstreamQueries += 1;
      }
      if (t < 1) continue;
      const n = requests[j], target = bound[j];
      if (isDown(target, t)) { s.dropped += n; s.window = t - outageStart + 1; continue; }
      s.accepted += n;
      s.distance += n * DISTANCE[GROUP[j % GROUP.length]][target];
      zoneRequests[target] += n;
    }
  }
  const share = Object.fromEntries(ZONE.map((b) => [b, zoneRequests[b] / s.accepted]));
  const deviation = ZONE.reduce((a, b) => a + Math.abs(share[b] - WEIGHT[b]), 0) / 2;
  return { ...s, share, deviation, unitDistance: s.distance / s.accepted };
}
```

#### Three Policies on the Same Flow

```js
// name/policy.mjs -- three routing policies on the same request flow: zone share, deviation from target share,
// distance unit per request, and how many names resolvers ask the authoritative server for
import { run, REQUESTS, REQUESTS_PER_SECOND, WEIGHT, ZONE } from "./model.mjs";

const TTL = 300, DURATION = 3600;
console.log(`${DURATION} s, ${REQUESTS_PER_SECOND} requests/s, TTL ${TTL} s, ` +
  `largest resolver's share %${((REQUESTS[0] / REQUESTS_PER_SECOND) * 100).toFixed(2)}`);
console.log(`target share: ${ZONE.map((b) => `${b}=%${WEIGHT[b] * 100}`).join(" ")}`);
console.log();
console.log("policy          |   b34   b35   b06 | deviation | unit distance | upstream query");
console.log("----------------|-------------------|-----------|---------------|---------------");
for (const policy of ["geolocation", "latency-based", "weighted"]) {
  const r = run({ policy, ttl: TTL, duration: DURATION });
  console.log(`${policy.padEnd(15)} |` +
    `${ZONE.map((b) => `%${(r.share[b] * 100).toFixed(1)}`.padStart(6)).join("")} | ` +
    `${r.deviation.toFixed(3).padStart(9)} | ${r.unitDistance.toFixed(3).padStart(13)} | ` +
    `${String(r.upstreamQueries).padStart(14)}`);
}
console.log();
for (const ttl of [30, 60, 300, 900]) {
  const r = run({ policy: "weighted", ttl, duration: DURATION });
  console.log(`weighted, ttl ${String(ttl).padStart(3)} s -> deviation ${r.deviation.toFixed(3)}, ` +
    `upstream query ${String(r.upstreamQueries).padStart(4)}`);
}

// T4 sensitivity: how much the same policy would deviate if resolver shares were equal
const EQUAL = Array.from({ length: 40 }, (_, j) => (j < 34 ? 13 : 12));   // totals 514
const e = run({ policy: "weighted", ttl: TTL, duration: DURATION, requests: EQUAL });
console.log(`\nT4 sensitivity: with equal shares (ttl ${TTL}) deviation ${e.deviation.toFixed(3)}`);
```

```
3600 s, 514 requests/s, TTL 300 s, largest resolver's share %23.35
target share: b34=%50 b35=%30 b06=%20

policy          |   b34   b35   b06 | deviation | unit distance | upstream query
----------------|-------------------|-----------|---------------|---------------
geolocation     | %57.6 %25.3 %17.1 |     0.076 |         1.710 |            480
latency-based   | %55.1 %25.3 %19.6 |     0.051 |         1.171 |            480
weighted        | %50.6 %27.8 %21.6 |     0.022 |         2.144 |            480

weighted, ttl  30 s -> deviation 0.082, upstream query 4800
weighted, ttl  60 s -> deviation 0.195, upstream query 2400
weighted, ttl 300 s -> deviation 0.022, upstream query  480
weighted, ttl 900 s -> deviation 0.037, upstream query  160

T4 sensitivity: with equal shares (ttl 300) deviation 0.000
```

These numbers belong to the measurement class: they come out of a model run on this machine. The
distance unit depends on the model's parameters; the request counts do not.

The first table shows two metrics moving in opposite directions. The latency-based policy finds
the closest placement, 1.171 distance units per request, against 1.710 for geolocation and 2.144
for weighted — 1.83 times the distance of the closest-zone policy, a ratio independent of unit
choice since both runs share the same matrix. But weighted gives the distribution closest to the
target share: deviation 0.022, against 0.076 for geolocation. Proximity and share control are not
achieved together in the same policy.

Geolocation's 1.710 comes from the hand-written table: in two of the four client clusters it
misses the nearest zone. Cluster `g3`'s entry says `b34`, but the nearest zone in the matrix is
`b06`; `g4`'s says `b06`, but the nearest is `b34`. When geographic and network proximity diverge,
the policy silently sends traffic to the wrong zone — visible only once it is measured.

The second table exposes the weighted policy's real problem: deviation does not improve as the TTL
shortens — 0.082, 0.195, 0.022, 0.037, oscillating without a pattern — while upstream queries climb
from 160 to 4,800, thirtyfold. A short TTL multiplies the price paid by thirty without bringing the
result any closer to the target share.

The last line gives the reason: when resolver shares are equalized, the same policy's deviation
drops to 0.000. The problem is not the TTL but the control unit — the policy distributes share per
resolution, while load arrives per request. The single largest resolver alone carries 23.35% of
all requests, more than zone `b06`'s entire 20% target share; whichever zone it is bound to
overshoots. The name layer is not a request-level control instrument.

#### The Failover Window

When a zone becomes unreachable, the authoritative server removes it from the answers, but the
answers already sitting in caches at that moment stay valid. The failover window therefore has two
parts: the zone being marked dead (T5) and the cached answer's TTL running out (T1).

```js
// name/failover.mjs -- b34 becomes unreachable at second 100. TTL determines the failover
// window and the requests dropped in it; the budget figures are taken from the K01 computations.
import { run, DETECTION, REQUESTS_PER_SECOND } from "./model.mjs";

const OUTAGE = 100, DURATION = 1200;
const MONTHLY_BUDGET_S = (1 - 99.9 / 100) * 30 * 24 * 3600;  // K01: 99.9% -> 43.2 min/month
const K01_PASSIVE_DROPPED = 1541.67;                          // K01: active-passive, 3 s window

console.log(`b34 goes down at second ${OUTAGE}, the health check marks it ${DETECTION} s later.`);
console.log(`monthly downtime budget = ${(MONTHLY_BUDGET_S / 60).toFixed(1)} min (99.9%)`);
console.log();
console.log("ttl | window | dropped req | equiv. downtime | budget share | upstream query");
console.log("----|--------|-------------|------------------|---------------|---------------");
for (const ttl of [30, 60, 300, 900]) {
  const r = run({ policy: "geolocation", ttl, outageStart: OUTAGE, duration: DURATION });
  const equivalent = r.dropped / REQUESTS_PER_SECOND;
  console.log(`${String(ttl).padStart(3)} | ${String(r.window).padStart(4)} s | ` +
    `${String(r.dropped).padStart(11)} | ${equivalent.toFixed(2).padStart(14)} s | ` +
    `${`%${((equivalent / MONTHLY_BUDGET_S) * 100).toFixed(2)}`.padStart(13)} | ` +
    `${String(r.upstreamQueries).padStart(15)}`);
}
const r = run({ policy: "geolocation", ttl: 300, outageStart: OUTAGE, duration: DURATION });
console.log();
console.log(`ttl 300: window ${r.window} s, equivalent downtime ` +
  `${(r.dropped / REQUESTS_PER_SECOND).toFixed(2)} s -> ` +
  `%${((r.dropped / (r.window * REQUESTS_PER_SECOND)) * 100).toFixed(1)} of the window's traffic was dropped`);
console.log(`K01's active-passive failover dropped ${K01_PASSIVE_DROPPED} requests in 3 s; ` +
  `this failover drops ${(r.dropped / K01_PASSIVE_DROPPED).toFixed(1)} times that`);
```

```
b34 goes down at second 100, the health check marks it 10 s later.
monthly downtime budget = 43.2 min (99.9%)

ttl | window | dropped req | equiv. downtime | budget share | upstream query
----|--------|-------------|------------------|---------------|---------------
 30 |   39 s |        6666 |          12.97 s |         %0.50 |            1600
 60 |   69 s |        9486 |          18.46 s |         %0.71 |             800
300 |  305 s |       57906 |         112.66 s |         %4.35 |             160
900 |  905 s |      190206 |         370.05 s |        %14.28 |              64

ttl 300: window 305 s, equivalent downtime 112.66 s -> %36.9 of the window's traffic was dropped
K01's active-passive failover dropped 1541.67 requests in 3 s; this failover drops 37.6 times that
```

The window tracks the TTL almost one to one — 39, 69, 305, 905 seconds — so the TTL stops being
merely a cache setting and becomes the failover duration itself. The number the name owner picks
to reduce query load is the same number that decides how long traffic keeps hitting a dead address
once a zone fails.

The gap between dropped requests and equivalent downtime confirms a distinction from the
introductory course: at a 300-second TTL the window is 305 seconds, but the dropped requests'
equivalent is only 112.66 — 36.9% of the window's traffic, not all of it. The Availability in
Numbers lesson said time-based and request-based measurement diverge; a zone failover is exactly
where that lands. Someone counting by time sees 305 seconds of downtime, someone counting by
requests sees 112.66 — both describing the same event.

The budget share column settles the decision. At a 99.9% target, the monthly budget is 43.2
minutes; a single zone failover spends 14.28% of it at a 900-second TTL, 4.35% at 300 seconds,
0.50% at 30 seconds. The cost of the same event changes twenty-eightfold, and the only thing that
changed is a number in the name record.

The comparison closes on the last line: the active–passive failover in the Availability Patterns
lesson dropped 1,541.67 requests in a three-second window; the name layer's failover, at the
baseline TTL, drops 37.6 times that many. The name layer is the slowest failover instrument
available — seconds at the replica level become minutes here. Design consequence: use the name
layer for zone selection, not as the sole failure response — the stop that closes the window is
the subject of the following lessons.

#### Summary

- Name resolution is a routing decision: the geolocation policy answers from a coarse table, the
  latency-based policy from measured proximity, and the weighted policy from a target share.
- On the same flow, distance units per request were 1.171 for latency-based, 1.710 for
  geolocation, and 2.144 for weighted; weighted gave the distribution closest to the target share
  (deviation 0.022).
- Writing the geolocation table by hand is a measurable flaw: in two of the four client clusters,
  the table did not point to the nearest zone.
- The weighted policy's deviation did not improve as the TTL shortened (0.082–0.195, oscillating),
  but upstream queries rose thirtyfold; equalizing shares dropped the deviation to 0.000 — the
  control unit is the resolution, but load arrives per request.
- The failover window tracks the TTL (39, 69, 305, 905 s), and a single zone failover takes up
  anywhere from 0.50% to 14.28% of the monthly downtime budget.
- At a 300-second TTL the window is 305 seconds but the equivalent downtime is 112.66 seconds; the
  name layer's failover drops 37.6 times as many requests as the replica-level one.

#### Next Step

The name layer chose which zone the request would go to, paying for that choice in the distance
unit and the failover window. But what it chose was an address — the request still has not reached
the application once it arrives there. In that gap sits a stop that can answer the request without
letting it reach the application. Caching was built in the Caching, Queues and Asynchronous
Processing course, but there the cache sat next to the application, as the introductory course's
arithmetic assumed too: a 90% hit rate brought `reads behind cache/s` down to 41.67. The next
lesson relocates the same cache — next to the user instead of the application — and measures what
happens to the requests reaching the application, the bytes crossing the boundary, and the
introductory course's computations.

### Content Delivery Networks

Source: https://academia.sh/en/courses/traffic-layer/content-delivery-networks

Treating the edge cache as a location decision: measuring wire bytes with local origin and edge processes, counting the edge hit rate and the requests reaching the application with an in-process trace, showing that chaining two caches in series leaves the second one useless, and showing which of the introductory course's computations changes.

The name layer chose which zone the request would go to, but what it chose was an address — the
request still has not reached the application once it arrives there. In that gap sits a stop that
can answer the request without letting it reach the application. Caching was built in the Caching,
Queues and Asynchronous Processing course, where the cache sat next to the application, as the
Introduction to System Design course's arithmetic assumed too: a 90% hit rate brought `reads
behind cache/s` down to 41.67.

This lesson relocates the same cache; strategy, invalidation, and key design are not repeated,
since the only thing that changes is where the cache sits. A **content delivery network** keeps
the same content at many points close to the user, and each such point is an **edge cache**. The
question: at the same hit rate, next to the user instead of next to the application, which number
changes and which does not.

#### What the Location Decision Changes

A cache next to the application versus next to the user separates three things at the same hit
rate. First, a hit never reaches the application — it drops out of the load application processes
see. Second, a hit's response does not use the link between origin and edge, so bytes crossing the
boundary fall. Third, a miss now passes through three stops instead of two.

None of this grows the hit rate on its own. What this lesson measures is that location does not
change the rate — it changes what the rate is worth.

#### Bytes on the Wire

The first setup is two local processes: origin and edge, measuring how many bytes the same body
takes up on a real connection. The introductory course's calculation left this out, saying header
and protocol overhead would be a separate assumption; here a measurement replaces it.

```js
// edge/server.mjs — two local processes: the origin listens on 8471, the edge cache on 8472.
// Cache capacities are three keys to keep the example small; both expose their counters at /counter.
import http from "node:http";

const BODY = 480;                          // K01 V5: tracking response body
const body = (num) => {
  const head = JSON.stringify({ tracking: num, state: "in-transit", zone: "b34" });
  return head + " ".repeat(BODY - head.length);
};
const newCache = (capacity) => ({
  capacity, m: new Map(),
  get(num) { const b = this.m.get(num); if (b !== undefined) this.m.delete(num); return b; },
  put(num, b) {
    if (this.m.size >= this.capacity) this.m.delete(this.m.keys().next().value);
    this.m.set(num, b);
  },
});
const counterRoute = (s) => (request, response) => {
  response.writeHead(200, { "content-type": "application/json" });
  response.end(JSON.stringify(s));
};

const originStats = { requests: 0, hits: 0, stored: 0 };
const originCache = newCache(3);
http.createServer((request, response) => {
  if (request.url === "/counter") return counterRoute(originStats)(request, response);
  const num = request.url.slice("/tracking/".length);
  originStats.requests += 1;
  let b = originCache.get(num);
  if (b === undefined) { originStats.stored += 1; b = body(num); } else originStats.hits += 1;
  originCache.put(num, b);
  response.writeHead(200, { "content-type": "application/json", "content-length": BODY });
  response.end(b);
}).listen(8471);

const edgeStats = { requests: 0, hits: 0, misses: 0, originRequests: 0, originBytes: 0 };
const edgeCache = newCache(3);
http.createServer(async (request, response) => {
  if (request.url === "/counter") return counterRoute(edgeStats)(request, response);
  const num = request.url.slice("/tracking/".length);
  edgeStats.requests += 1;
  let b = edgeCache.get(num), status = "hit";
  if (b === undefined) {
    status = "miss";
    edgeStats.misses += 1; edgeStats.originRequests += 1;
    b = await (await fetch(`http://127.0.0.1:8471/tracking/${num}`)).text();
    edgeStats.originBytes += Buffer.byteLength(b);
  } else edgeStats.hits += 1;
  edgeCache.put(num, b);
  response.writeHead(200, { "content-type": "application/json",
    "content-length": Buffer.byteLength(b), "x-edge": status });
  response.end(b);
}).listen(8472);
```

```bash
# edge-measure.sh — first one direct request to the origin, then the same two tracking numbers
# queried three times each through the edge. Header bytes depend on the header set the server
# produces; the ratio between body and wire is not tied to that.
node edge/server.mjs & SERVER=$!
until curl -sf http://127.0.0.1:8472/counter >/dev/null; do sleep 0.2; done
measure() { curl -s -o /dev/null -D - "$1" -w '%{size_download} %{size_header}\n' 2>/dev/null; }

printf '%-18s %-7s %6s %7s %5s\n' request edge body header wire
measure http://127.0.0.1:8471/tracking/TR900010 |
  awk 'END{printf "%-18s %-7s %6d %7d %5d\n", "origin direct", "-", $1, $2, $1+$2}'
for round in 1 2 3; do
  for num in TR900011 TR900012; do
    measure "http://127.0.0.1:8472/tracking/$num" |
      awk -v n="$num" -v t="$round" '/^x-edge/{k=$2; sub(/\r/, "", k)}
        END{printf "%-18s %-7s %6d %7d %5d\n", "edge " n " r" t, k, $1, $2, $1+$2}'
  done
done
printf '\nedge counter = %s\n' "$(curl -s http://127.0.0.1:8472/counter)"
printf 'origin counter = %s\n' "$(curl -s http://127.0.0.1:8471/counter)"
kill $SERVER
```

```
request            edge      body  header  wire
origin direct      -          480     156   636
edge TR900011 r1   miss       480     170   650
edge TR900012 r1   miss       480     170   650
edge TR900011 r2   hit        480     169   649
edge TR900012 r2   hit        480     169   649
edge TR900011 r3   hit        480     169   649
edge TR900012 r3   hit        480     169   649

edge counter = {"requests":6,"hits":4,"misses":2,"originRequests":2,"originBytes":960}
origin counter = {"requests":3,"hits":0,"stored":3}
```

These numbers belong to the measurement class. Header bytes depend on the server's header set and
differ in another implementation; the ratio between body and wire, 649 / 480 = 1.352, carries
forward as a lower bound, since the header set can only grow.

The counters give the location decision's first result: the edge saw six requests, two misses,
four hits. Only two requests reached the origin — the second and third query for the same two
tracking numbers never got there. The origin's own counter shows three, because the direct
measurement counted too, and its hit counter is zero: requests reaching it are so sparse that its
own cache never helped. A small preview of the result the second half of the lesson measures.

#### Where the Hit Rate Comes From

The second setup is an in-process model, labeled as one: the same request trace runs first with a
single cache, then with an edge cache placed in front of it. The trace is this course's
assumption; the rates and counts are measurements.

The model's input derives from the introductory course: 12,000,000 daily tracking queries,
400,000 new shipments a day. One assumption is added — **T6: a shipment stays in transit for
3 days** — giving 1,200,000 active shipments; daily queries per shipment is 12,000,000 /
1,200,000 = 10, a computed value.

Ten queries are spread across a shipment's three-day life. A cache entry cannot live three days,
so what sets the rate is how many of the ten land in the same short window: a user opens the
tracking page and refreshes it a few times. This is the second assumption — **T7: burst length**,
how many of the ten queries share a burst. The third is **T8: 100 bursts** open at once; the
fourth is **T9: each cache holds 1,000 keys**.

```js
// edge/trace.mjs — in-process model: the same request trace runs first with a single cache,
// then with an edge cache placed in front of it. The trace is an assumption (T7); ratios and counts are measurements.
const REQUESTS = 200_000;    // K01 peak read 416.67 requests/s x 480 s
const QUERIES = 10;          // computed value: V1*V2 / (V3*T6) = 12,000,000 / 1,200,000
const OPEN = 100;            // T8: tracking pages open at the same time (burst)
const CAPACITY = 1_000;      // T9: each cache's key capacity

function* trace(burst) {     // burst: how many of a shipment's 10 queries land in the same burst
  const open = [];
  let fresh = 1, single = 1, index = 0;
  for (let i = 0; i < REQUESTS; i++) {
    if (i % QUERIES >= burst) { yield `D${single++}`; continue; }  // key seen once in the window
    while (open.length < OPEN) open.push({ id: `G${fresh++}`, remaining: burst });
    index = (index + 1) % open.length;
    const o = open[index];
    yield o.id;
    if (--o.remaining === 0) open.splice(index, 1);
  }
}

class Cache {                // most-recently-used sits last; the Map's insertion order is enough
  constructor(k) { this.k = k; this.m = new Map(); this.hits = 0; this.misses = 0; }
  probe(a) {
    if (this.m.has(a)) { this.hits += 1; this.m.delete(a); this.m.set(a, 1); return true; }
    this.misses += 1;
    if (this.m.size >= this.k) this.m.delete(this.m.keys().next().value);
    this.m.set(a, 1);
    return false;
  }
  get rate() { return this.hits / (this.hits + this.misses); }
}

export function run(burst) {
  const single = new Cache(CAPACITY);
  const edge = new Cache(CAPACITY), app = new Cache(CAPACITY);
  for (const a of trace(burst)) {
    single.probe(a);
    if (!edge.probe(a)) app.probe(a);
  }
  return { requests: REQUESTS, singleRate: single.rate, singleStored: single.misses,
    edgeRate: edge.rate, appRequests: edge.misses,
    appRate: app.rate, edgeStored: app.misses };
}

if (process.argv[1].endsWith("trace.mjs")) {    // does not print when imported, only exposes run
  console.log(`${REQUESTS} requests, ${QUERIES} queries per shipment, ${OPEN} open bursts, ` +
    `cache capacity ${CAPACITY} keys`);
  console.log();
  console.log("burst | single cache | edge hit | reaching app | app hit | reaching store");
  console.log("------|--------------|----------|---------------|---------|----------------");
  for (const burst of [10, 5, 2]) {
    const r = run(burst);
    console.log(`${String(burst).padStart(5)} | ${r.singleRate.toFixed(4).padStart(12)} | ` +
      `${r.edgeRate.toFixed(4).padStart(8)} | ${String(r.appRequests).padStart(13)} | ` +
      `${r.appRate.toFixed(4).padStart(7)} | ${String(r.edgeStored).padStart(14)}`);
  }
  const r = run(10);
  console.log();
  console.log(`burst 10: requests reaching store without edge ${r.singleStored}, with edge ${r.edgeStored} ` +
    `(x${(r.edgeStored / r.singleStored).toFixed(3)})`);
  console.log(`requests reaching app ${r.requests} -> ${r.appRequests} ` +
    `(x${(r.appRequests / r.requests).toFixed(3)})`);
  console.log(`measured edge hit rate ${r.edgeRate.toFixed(4)}, V9 assumption 0.90 -> difference ` +
    `${(Math.abs(r.edgeRate - 0.9) * 100).toFixed(2)} points`);
}
```

```
200000 requests, 10 queries per shipment, 100 open bursts, cache capacity 1000 keys

burst | single cache | edge hit | reaching app | app hit | reaching store
------|--------------|----------|---------------|---------|----------------
   10 |       0.8998 |   0.8998 |         20046 |  0.0000 |          20046
    5 |       0.3998 |   0.3998 |        120044 |  0.0000 |         120044
    2 |       0.0759 |   0.0759 |        184815 |  0.0021 |         184418

burst 10: requests reaching store without edge 20046, with edge 20046 (x1.000)
requests reaching app 200000 -> 20046 (x0.100)
measured edge hit rate 0.8998, V9 assumption 0.90 -> difference 0.02 points
```

The first line exposes an assumption from the introductory course: the cache hit ratio there,
V9 = 0.90, was justified only as "the same tracking number gets asked again within a short
interval." At burst length 10, the measured rate is 0.8998 — a 0.02-point difference — so
V9 = 0.90 was quietly assuming all ten of a shipment's queries land in the same burst. Sensitivity
shows in the next two lines: at burst 5 the rate is 0.3998, at burst 2 it is 0.0759. Half the
queries falling outside the burst drops the rate from 0.90 to 0.40. A hit rate is not a cache
property — it is a trace property.

The second result is the location decision's real finding: adding the edge cache dropped requests
reaching the application from 200,000 to 20,046 — a tenth. Requests reaching the store did not
change at all — 20,046 with or without the edge, a ratio of 1.000 — because the application
cache's hit rate is 0.0000. The edge cache absorbs every repeat, so only first-seen keys reach the
cache behind it, and a first-seen key cannot be found in any cache.

This is a general property of chaining two caches in series: if the last cache holds every
distinct key in the window, requests reaching the store are independent of the caches in front of
it. The third row shows the limit: at burst 2 the edge cache is already failing (0.0759), and the
cache behind it adds a small contribution (0.0021), bringing requests reaching the store from
184,815 to 184,418. The second cache only earns its keep once the first fails. Capacity and hit
rate's relationship was measured in the Cache Metrics lesson; added here is that caching the same
key twice is not free the second time.

#### Back to the Numbers

```js
// edge/compute.mjs — folding the measured hit rate and wire bytes back into the K01 computations
import { run } from "./trace.mjs";

const PEAK_READ = 416.67, PEAK_WRITE = 97.22;   // K01 calculation
const V5 = 480, V9 = 0.90;                      // K01 assumption table
const K01_BEHIND_CACHE = 41.67, K01_STORE = 138.89, K01_EGRESS = 1.60;
const WIRE_EDGE = 649, WIRE_ORIGIN = 636;       // measurement: body + header (local processes)

const r = run(10);
const app = PEAK_READ * (1 - r.edgeRate);
const storeReads = PEAK_READ * (r.edgeStored / r.requests);
const mbit = (requests, bytes) => (requests * bytes * 8) / 1e6;

console.log(`measured edge hit rate = ${r.edgeRate.toFixed(4)} (V9 assumption ${V9})`);
console.log();
console.log(`${"calculation".padEnd(28)}${"K01".padStart(9)}${"with edge".padStart(11)}  note`);
const row = (name, before, after, note) =>
  console.log(`${name.padEnd(28)}${before.toFixed(2).padStart(9)}${after.toFixed(2).padStart(11)}  ${note}`);
row("reads reaching app/s", PEAK_READ, app, "edge cache removed these");
row("reads behind cache/s", K01_BEHIND_CACHE, storeReads, "unchanged");
row("requests reaching store/s", K01_STORE, storeReads + PEAK_WRITE, "unchanged");
row("read egress Mbit/s", K01_EGRESS, mbit(PEAK_READ, WIRE_EDGE), "K01 counted body only");
row("origin egress Mbit/s", K01_EGRESS, mbit(app, WIRE_ORIGIN), "edge sits in front of the origin");
console.log();
console.log(`wire/body ratio = ${(WIRE_EDGE / V5).toFixed(3)}; K01's ${K01_EGRESS.toFixed(2)} Mbit/s is a lower bound`);
console.log(`origin egress / user egress = ` +
  `${(mbit(app, WIRE_ORIGIN) / mbit(PEAK_READ, WIRE_EDGE)).toFixed(4)}`);
console.log(`app cache's contribution = ${(r.appRate * 100).toFixed(2)}% hit`);
```

```
measured edge hit rate = 0.8998 (V9 assumption 0.9)

calculation                       K01  with edge  note
reads reaching app/s           416.67      41.76  edge cache removed these
reads behind cache/s            41.67      41.76  unchanged
requests reaching store/s      138.89     138.98  unchanged
read egress Mbit/s               1.60       2.16  K01 counted body only
origin egress Mbit/s             1.60       0.21  edge sits in front of the origin

wire/body ratio = 1.352; K01's 1.60 Mbit/s is a lower bound
origin egress / user egress = 0.0982
app cache's contribution = 0.00% hit
```

The table gives four results at once. Reads reaching the application drop from 416.67 to 41.76 —
a number the introductory course never computed, since there the cache sat inside the application
and reaching it was unavoidable. `reads behind cache/s` and `requests reaching store/s` do not
change (41.67 against 41.76, 138.89 against 138.98; the gap is the trace measurement's 0.02-point
deviation). The edge cache takes the same 0.90 and moves it from one place to another.

Bandwidth gets corrected in both directions: egress to the user, with measured wire bytes, rises
from 1.60 to 2.16 Mbit/s — the introductory course's number was a lower bound, since it counted
only the body. Origin egress is 0.21 Mbit/s, 0.0982 of egress to the user. One link in the same
design demands 2.16 Mbit/s, the other 0.21, and the introductory course's single number cannot
stand for both.

#### Summary

- The edge cache is a location decision: a hit never reaches the application and does not use the
  link between origin and edge, but the hit rate does not grow because of this decision.
- The same body's footprint on a real connection was measured: a 480-byte body takes up 649 bytes
  on the wire, a ratio of 1.352; the introductory course's 1.60 Mbit/s is therefore a lower bound.
- At a burst length of 10, the measured hit rate came out to 0.8998: the V9 = 0.90 assumption
  assumed all ten of a shipment's queries land in the same burst. At burst 5 the rate falls to
  0.3998.
- The edge cache brought requests reaching the application from 200,000 down to 20,046, but
  requests reaching the store stayed at 20,046 (ratio 1.000), and the application cache's hit rate
  was 0.0000.
- If the last cache can hold every distinct key in the window, requests reaching the store are
  independent of the caches in front of it; the second cache only contributes once the first one
  fails.
- Back to the numbers: reads reaching the application fell from 416.67 to 41.76, `requests
  reaching store/s` stayed at 138.89, and origin egress was 0.21 Mbit/s, 0.0982 of egress to the
  user.

#### Next Step

The edge cache in this lesson waited for the request: the first time a key was asked for, it
missed, went to the origin, and stored the response. The measurement also counted those first
requests — 20,046 of the 200,000 missed at the edge, each a trip to the origin. This waiting is
not mandatory: content can be sent to the edge before it is ever asked for. The choice between the
two models changes the miss count, the content volume held at the edge, and how long an update
takes to become visible at every edge. The next lesson measures both on the same setup and shows
which fits which kind of content.

### Push and Pull Based Distribution

Source: https://academia.sh/en/courses/traffic-layer/push-and-pull-based-distribution

Propagating content to edges with two distinct models: pull-based distribution producing the first miss and a stale-version window, push-based distribution producing wasted copies and a message multiplier, choosing between the two by content class, and the choice's effect on the introductory course's write bandwidth and storage calculations.

The edge cache in the previous lesson waited for the request: the first time a key was asked for,
it missed, went to the origin, and stored the response. The measurement also counted those first
requests — 20,046 of the 200,000 requests missed at the edge, each a trip to the origin. This
waiting is not mandatory.

Content can be sent to the edge before it is ever asked for. There are two ways, and the choice
between them is a propagation model decision. **Pull-based distribution** pulls content on the
first request: the edge misses, goes to the origin, stores the response. **Push-based
distribution** sends content to edges before any request arrives: the edge never misses, but
whether the pushed content will ever be requested is unknown. This lesson measures both models on
the same content set.

#### What Marks the Difference

The difference between the two models shows up in three places. First, **origin requests**: in
pull, every edge goes to the origin at least once per object; in push, never. Second, **wasted
copies**: push sends an object even to edges that will never request it. Third, **stale
versions**: when an object changes, pull's edges keep serving the old version until their TTL
expires, while push sends the new version to every edge in one step.

All three tie back to a single quantity: how many edges request an object. If every edge requests
it, push wastes nothing; if only one edge does, all but one of push's copies are wasted. This
lesson measures that.

#### Setup

The model is in-process, and a round is an abstract step, the same kind of abstraction as the
introductory course's round. Content splits into two classes. **Common** objects are the tracking
page's shell, style, and script files: requested from every edge. **Unique** objects are
per-shipment tracking responses, and their count comes from the previous lesson's measurement —
20,046 distinct responses in the window.

| Code | Assumption | Value | Rationale |
|---|---|---|---|
| T10 | edge count | 12 | four entry points in each of the three zones |
| T11 | common object count | 20 | shell, style, script, and icon files |
| T12 | edges requesting a unique object | 1 | a shipment's queries come from the same user, the same edge |

```js
// propagation/model.mjs — in-process model: the same content set is distributed first pull
// based, then push based. A round is an abstract step; TTL and the update round are parameters.
export const EDGES = 12;        // T10: edge count
export const TTL = 300;         // T1: TTL (rounds)
export const ROUNDS = 900, UPDATE = 100;

// T11: common objects are requested from every edge. T12: a shipment's queries come from a
// single edge, so a unique object is requested from only one edge. UNIQUE is the previous lesson's measurement.
export const CLASS = [
  { name: "common", objects: 20, requesting: EDGES },
  { name: "unique", objects: 20_046, requesting: 1 },
];

export function cost(cls) {
  const pull = cls.objects * cls.requesting;        // one trip to the origin on the first miss
  const push = cls.objects * EDGES;                 // the set is pushed to every edge
  return { pull, push, wasted: push - pull, wastedShare: 1 - cls.requesting / EDGES };
}

// mode: "pull", "push", or "hybrid" (common objects pushed, unique objects pulled)
const modeOf = (mode, name) => (mode === "hybrid" ? (name === "common" ? "push" : "pull") : mode);

export function propagate(mode) {
  const s = { origin: 0, pushed: 0, wasted: 0, stale: 0, messages: 0 };
  for (const cls of CLASS) {
    const m = cost(cls);
    if (modeOf(mode, cls.name) === "push") { s.pushed += m.push; s.wasted += m.wasted; s.messages += m.push; }
    else { s.origin += m.pull; s.messages += m.pull; }
  }
  const commonMode = modeOf(mode, "common");
  const version = new Array(EDGES).fill(1);          // the common object's version each edge sees
  const expires = Array.from({ length: EDGES }, (_, k) => 1 + ((k * 27) % TTL));
  for (let round = 1; round <= ROUNDS; round++) {
    if (commonMode === "push" && round === UPDATE) { version.fill(2); s.messages += EDGES; }
    for (let k = 0; k < EDGES; k++) {
      if (commonMode === "pull" && round >= expires[k]) {  // TTL expired, refetched
        version[k] = round >= UPDATE ? 2 : 1;
        expires[k] = round + TTL;
        s.origin += 1; s.messages += 1;
      }
      if (round >= UPDATE && version[k] === 1) s.stale += 1;   // round in which the stale version was served
    }
  }
  return s;
}
```

```js
// propagation/measure.mjs — comparing two propagation modes on the same content set
import { propagate, cost, CLASS, EDGES, TTL, ROUNDS, UPDATE } from "./model.mjs";

console.log(`${EDGES} edges, TTL ${TTL} rounds, ${ROUNDS} rounds, ` +
  `common object's version changes at round ${UPDATE}`);
console.log();
console.log("class  | objects | requesting edges | pull req | push req | wasted | wasted share");
console.log("-------|---------|-------------------|----------|----------|--------|-------------");
for (const cls of CLASS) {
  const m = cost(cls);
  console.log(`${cls.name.padEnd(6)} | ${String(cls.objects).padStart(7)} | ` +
    `${String(cls.requesting).padStart(17)} | ${String(m.pull).padStart(8)} | ` +
    `${String(m.push).padStart(8)} | ${String(m.wasted).padStart(6)} | ` +
    `${`%${(m.wastedShare * 100).toFixed(1)}`.padStart(11)}`);
}
console.log();
console.log("mode   | origin req | pushed obj | wasted | stale version rounds | messages");
console.log("-------|------------|------------|--------|-----------------------|---------");
const record = {};
for (const mode of ["pull", "push", "hybrid"]) {
  const r = record[mode] = propagate(mode);
  console.log(`${mode.padEnd(6)} | ${String(r.origin).padStart(10)} | ` +
    `${String(r.pushed).padStart(10)} | ${String(r.wasted).padStart(6)} | ` +
    `${String(r.stale).padStart(21)} | ${String(r.messages).padStart(8)}`);
}
const pull = record.pull, push = record.push;
console.log();
console.log(`message ratio push/pull = ${(push.messages / pull.messages).toFixed(2)}`);
console.log(`stale version rounds, average per edge = ${(pull.stale / EDGES).toFixed(1)} rounds ` +
  `(${((pull.stale / EDGES) / TTL).toFixed(2)} times the TTL)`);
const two = cost({ ...CLASS[1], requesting: 2 });
console.log(`T12 sensitivity: if the unique object were requested from two edges, wasted share ` +
  `%${(two.wastedShare * 100).toFixed(1)} (%${(cost(CLASS[1]).wastedShare * 100).toFixed(1)} at one edge)`);
```

```
12 edges, TTL 300 rounds, 900 rounds, common object's version changes at round 100

class  | objects | requesting edges | pull req | push req | wasted | wasted share
-------|---------|-------------------|----------|----------|--------|-------------
common |      20 |                12 |      240 |      240 |      0 |        %0.0
unique |   20046 |                 1 |    20046 |   240552 | 220506 |       %91.7

mode   | origin req | pushed obj | wasted | stale version rounds | messages
-------|------------|------------|--------|-----------------------|---------
pull   |      20322 |          0 |      0 |                  1794 |    20322
push   |          0 |     240792 | 220506 |                     0 |   240804
hybrid |      20046 |        240 |      0 |                     0 |    20298

message ratio push/pull = 11.85
stale version rounds, average per edge = 149.5 rounds (0.50 times the TTL)
T12 sensitivity: if the unique object were requested from two edges, wasted share %83.3 (%91.7 at one edge)
```

These numbers belong to the measurement class; their inputs are the T10–T12 assumptions and the
previous lesson's 20,046 measurement.

The first table confirms the selection criterion. In the common class, pull and push request the
same amount (240 to 240) with zero wasted copies, since the object is already requested from all
twelve edges. In the unique class, push sends 240,552 copies, 220,506 of which will never be
requested: a wasted share of 91.7%. The same criterion shows up as a formula — the wasted share is
the complement of the ratio of requesting edges to total edges. Playing T12 drops the share from
91.7% to 83.3%; even doubled, the assumption still leaves push heavily wasted in the unique class.

The second table shows where the cost lands. Pull generates 20,322 requests to the origin and, in
return, serves stale versions for 1,794 rounds — an average of 149.5 rounds per edge, half the
TTL. Push produces no stale versions but sends 240,804 messages, 11.85 times pull's count. The two
models pay for the same work in different currencies: one in stale responses, the other in wasted
messages. This mirrors the tradeoff between the two failover patterns in the introductory course,
where the cost split between downtime and lost data.

The third row shows the decision is made at the content-class level, not the system level. The
hybrid mode pushes common objects and pulls unique ones: 20,046 origin requests, 0 wasted copies,
0 stale-version rounds, 20,298 messages — better than both other modes on all three counts. A
design forced to pick a single model pays unnecessary cost on one of the two classes.

#### Back to the Numbers

```js
// propagation/compute.mjs — which of K01's computations change when a propagation mode is chosen
import { propagate, cost, CLASS, EDGES } from "./model.mjs";

const PEAK_READ = 416.67, PEAK_WRITE = 97.22, DAILY_EVENTS = 2_800_000;  // K01 calculation
const V5 = 480, ACTIVE = 1_200_000;             // K01 V5; active shipment count computed with T6
const K01_BEHIND_CACHE = 41.67, K01_WRITE = 0.17, K01_READ = 1.60;
const WINDOW = 480;                             // s: the previous lesson's measurement window

const pull = propagate("pull"), hybrid = propagate("hybrid");
const mbit = (rate, bytes) => (rate * bytes * 8) / 1e6;
const pushRate = PEAK_WRITE * EDGES;
const staleResponses = pull.stale * (PEAK_READ / EDGES);
const pushGB = (ACTIVE * V5 * EDGES) / 1e9, pullMB = (CLASS[1].objects * V5) / 1e6;

console.log(`pull: ${pull.origin} requests to the origin / ${WINDOW} s = ${(pull.origin / WINDOW).toFixed(2)} ` +
  `requests/s -- K01 reads behind cache/s = ${K01_BEHIND_CACHE}`);
console.log(`hybrid: ${(hybrid.origin / WINDOW).toFixed(2)} requests/s to the origin, no cold start ` +
  `miss because the common object is pushed`);
console.log();
console.log(`push: every state event goes to ${EDGES} edges`);
console.log(`  peak push rate = ${pushRate.toFixed(2)} pushes/s, ${DAILY_EVENTS * EDGES} pushes/day`);
console.log(`  push bandwidth = ${mbit(pushRate, V5).toFixed(2)} Mbit/s = ` +
  `${(mbit(pushRate, V5) / K01_WRITE).toFixed(1)} times K01's write ingress (${K01_WRITE}), ` +
  `${(mbit(pushRate, V5) / K01_READ).toFixed(2)} times its read egress (${K01_READ.toFixed(2)})`);
console.log(`  unique content held at the edges = ${pushGB.toFixed(2)} GB ` +
  `(${(ACTIVE * V5 / 1e6).toFixed(0)} MB x ${EDGES} edges)`);
console.log(`  same content in pull mode = ${pullMB.toFixed(2)} MB, ratio ` +
  `${((pushGB * 1e3) / pullMB).toFixed(0)}`);
console.log();
console.log(`stale responses from one version change in pull mode = ` +
  `${staleResponses.toFixed(0)} (${pull.stale} rounds x ${(PEAK_READ / EDGES).toFixed(2)} requests/s)`);
console.log(`unique object's wasted push share = ` +
  `%${(cost(CLASS[1]).wastedShare * 100).toFixed(1)}; a push is only not wasted when the object is ` +
  `requested from all ${EDGES} edges`);
```

```
pull: 20322 requests to the origin / 480 s = 42.34 requests/s -- K01 reads behind cache/s = 41.67
hybrid: 41.76 requests/s to the origin, no cold start miss because the common object is pushed

push: every state event goes to 12 edges
  peak push rate = 1166.64 pushes/s, 33600000 pushes/day
  push bandwidth = 4.48 Mbit/s = 26.4 times K01's write ingress (0.17), 2.80 times its read egress (1.60)
  unique content held at the edges = 6.91 GB (576 MB x 12 edges)
  same content in pull mode = 9.62 MB, ratio 718

stale responses from one version change in pull mode = 62292 (1794 rounds x 34.72 requests/s)
unique object's wasted push share = %91.7; a push is only not wasted when the object is requested from all 12 edges
```

The first line shows pull staying consistent with the introductory course: the request rate to
the origin is 42.34, against the course's `reads behind cache/s` of 41.67. The two numbers come
from separate paths — one from the assumption table, the other from this lesson's model run — and
land within two percent of each other. When pull is chosen, the introductory course's arithmetic
stays valid as it is.

Push breaks that same arithmetic. Since every state event changes the corresponding tracking
response, the new version must go to all twelve edges: peak push rate is 1,166.64 pushes/s,
33,600,000 pushes a day. That bandwidth is 4.48 Mbit/s — 26.4 times the course's `write ingress
Mbit/s` (0.17) and 2.80 times its `read egress Mbit/s` (1.60). Push turns read load into write
load and multiplies it by the edge count. A design's largest bandwidth line item shifts, once push
is chosen, to the write side flowing at 97 events per second.

Storage moves the same way. Holding 1,200,000 active shipments' tracking responses at every edge
costs 576 MB per edge, 6.91 GB across twelve edges. In pull mode, the same content's total
footprint at the edges is 9.62 MB — a 718-fold difference. This figure is not added to the
course's `stored data GB` calculation, because the edge copies do not replace the store — they sit
on top of it.

The last line gives pull's bill. When a common object's version changes, the stale version is
served for 1,794 rounds, and at 34.72 requests per second per edge, that means 62,292 stale
responses. The number ties directly to the TTL and repeats the first lesson's finding: the TTL is
not a cache setting but a single decision that determines both the failover duration and the
version propagation time.

#### Summary

- The propagation model is a decision: pull-based distribution pulls content on the first
  request, push-based distribution sends it before any request arrives.
- The selection criterion is how many edges request an object; the wasted push share is the
  complement of the requesting-edge ratio. The measured share was 0% in the common class, 91.7% in
  the unique class.
- Pull generated 20,322 requests to the origin and served stale versions for 1,794 rounds; push
  produced no stale versions but sent 240,804 messages — 11.85 times pull's count.
- Hybrid mode beats both other modes on all three counts (20,046 origin requests, 0 wasted, 0
  stale rounds): the decision is made at the content-class level, not the system level.
- Pull preserves the introductory course's arithmetic: 42.34 requests/s reach the origin, against
  the course's `reads behind cache/s` of 41.67.
- Push breaks it: a peak of 1,166.64 pushes/s and 4.48 Mbit/s — 26.4 times write ingress; content
  held at the edges is 6.91 GB, 718 times pull mode's footprint.

#### Next Step

These 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. The next lesson separates that path: it measures what happens to
the requests reaching the application, the number of stops a request touches, and the introductory
course's peak edge request rate once content is served from a place separate from the application.

### Static Content Hosting

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

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.

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

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

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

## Load Balancing

The layer that decides which replica a request lands on: the load balancer's distribution, health check, and TLS termination responsibilities, the separation of connection-level distribution from content-aware routing, the comparison of round robin, least connections, and consistent hashing algorithms, the functional overlap with the reverse proxy, and the cost of session stickiness to scaling.

### Role of the Load Balancer

Source: https://academia.sh/en/courses/traffic-layer/role-of-the-load-balancer

The three responsibilities of the layer that distributes requests among replicas doing the same work: measuring distribution with local processes, comparing the answered-request rate between setups with and without health checking when a replica goes down, treating TLS termination as a placement decision, and dividing the peak request rate computed in the introductory course by the replica count.

The previous topic managed to keep some requests from reaching the application **at all**: the
edge cache met nine-tenths of tracking responses at the boundary, the separated content path
pulled away almost all of a page load's requests, and the DNS layer chose which zone the request
would go to. What remains are the requests that genuinely need to reach the application — 513.89
of them per second at the edge. One question is still unanswered: once the zone is chosen, which
of the several replicas doing the same work does a request land on, and if that replica is down,
who notices.

This lesson ties those two questions to a single component. The **load balancer** sits in front
of replicas doing the same work and routes an incoming request to one of them. The lesson
enumerates this device's responsibilities, gets each one's code-level measurable result, and
shows what dividing the peak request rate computed in the Introduction to System Design course by
the replica count yields.

#### Three Responsibilities

The balancer's jobs can be named separately, each requiring its own decision.

**Distribution.** Choosing which replica an incoming request goes to. The selection rule is the
subject of later lessons; here, in its plainest form, it is **round robin** — replicas chosen in
turn.

**Health checking.** Independently testing whether a replica can still accept requests, and
removing the ones that cannot from the pool. The check request is separate from the user's
request: the balancer polls the replicas at fixed intervals and drops one from distribution when
it gets no answer.

**TLS termination.** Choosing where the encrypted connection ends. If it ends at the balancer, the
balancer sees the request's content; if it stays encrypted all the way to the replica, it does
not.

Some of these jobs are also done by the **reverse proxy** from the Server-Side Fundamentals
course; their overlap and distinction is this topic's fifth lesson. Here the balancer keeps a
single role: sharing requests among replicas doing the same work.

#### Setup

The measurement is set up with local processes. Three replicas are each a `node:http` server that
writes its own name into its response; the balancer does round robin distribution and can run
with health checking on or off. No real load balancer, cloud environment, or container is set up.

```js
// balance/replica.mjs — an application replica: returns the tracking response, writes its own name to the header
import { createServer } from "node:http";

const [port, name] = [Number(process.argv[2]), process.argv[3]];

const BODY = (no) => JSON.stringify({
  no, state: "at transfer hub", zone: "35", step: 4,
  route: ["accepted", "departed", "transfer-34"],
});

if (Number.isInteger(port) === false) console.log("usage: node balance/replica.mjs <port> <name>");
else createServer((req, res) => {
  const url = new URL(req.url, "http://local");
  if (url.pathname === "/health") { res.writeHead(200, { "x-replica": name }).end("up"); return; }
  const body = BODY(url.searchParams.get("no") ?? "-");
  res.writeHead(200, { "content-type": "application/json", "x-replica": name });
  res.end(body);
}).listen(port, "127.0.0.1");
```

The balancer does two of the three jobs: it chooses a replica in turn and checks the replicas'
health on a separate timer. The check interval is 200 milliseconds, and a single failed round
counts a replica as down; both are this topic's assumptions, referred to below as Y3.

```js
// balance/balancer.mjs — round robin distribution + health check. Usage:
//   node balance/balancer.mjs <port> <on|off> <replica-ports...>
import { createServer, request, get } from "node:http";

const INTERVAL = 200;                // assumption Y3: health check interval (ms)
const THRESHOLD = 1;                 // assumption Y3: consecutive failures counted as down

const port = Number(process.argv[2]);
const healthOn = process.argv[3] === "on";
const replicas = process.argv.slice(4).map((p) => ({ port: Number(p), up: true, failures: 0 }));

let next = 0, unreachable = 0;

function check(r) {
  const req = get({ port: r.port, path: "/health", timeout: 100 }, (res) => {
    res.resume();
    if (res.statusCode === 200) { r.failures = 0; r.up = true; } else fail(r);
  });
  req.on("error", () => fail(r));
  req.on("timeout", () => req.destroy());
}
function fail(r) { r.failures += 1; if (r.failures >= THRESHOLD) r.up = false; }

if (Number.isInteger(port) === false || replicas.length === 0) {
  console.log("usage: node balance/balancer.mjs <port> <on|off> <replica-ports...>");
} else {
  if (healthOn) setInterval(() => replicas.forEach(check), INTERVAL).unref();
  createServer((outerReq, outerRes) => {
    if (outerReq.url === "/manage/status") {
      outerRes.writeHead(200, { "content-type": "application/json" });
      outerRes.end(JSON.stringify({ unreachable,
        up: replicas.filter((r) => r.up).map((r) => r.port) }));
      return;
    }
    const pool = replicas.filter((r) => r.up);
    if (pool.length === 0) { outerRes.writeHead(503).end("pool empty"); return; }
    const chosen = pool[next++ % pool.length];
    const innerReq = request({ port: chosen.port, path: outerReq.url,
      method: outerReq.method, headers: outerReq.headers }, (innerRes) => {
      outerRes.writeHead(innerRes.statusCode, innerRes.headers);
      innerRes.pipe(outerRes);
    });
    innerReq.on("error", () => { unreachable += 1; outerRes.writeHead(502).end("replica unreachable"); });
    outerReq.pipe(innerReq);
  }).listen(port, "127.0.0.1");
}
```

The client sends requests **in sequence**; keeping concurrency at 1 makes the distribution counts
deterministic — otherwise the same setup would give a different split on every run.

```js
// balance/client.mjs — sends requests in sequence, counts which replica answered each one
import { Agent, request } from "node:http";

const [port, count, label] = [Number(process.argv[2]), Number(process.argv[3]), process.argv[4]];
const agent = new Agent({ keepAlive: true, maxSockets: 1 });

function one(no) {
  return new Promise((resolve, reject) => {
    const r = request({ port, path: `/tracking?no=${no}`, agent }, (res) => {
      res.resume();
      res.on("end", () => resolve({ code: res.statusCode, replica: res.headers["x-replica"] ?? "-" }));
    });
    r.on("error", reject);
    r.end();
  });
}

if (Number.isInteger(port) === false) console.log("usage: node balance/client.mjs <port> <count> <label>");
else {
  const counts = new Map();
  let succeeded = 0, failed = 0;
  for (let i = 0; i < count; i += 1) {
    const s = await one(`G${i}`);
    if (s.code === 200) succeeded += 1; else failed += 1;
    const key = s.code === 200 ? s.replica : `code ${s.code}`;
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  const distribution = [...counts].sort().map(([a, n]) => `${a}=${n}`).join("  ");
  console.log(`${label.padEnd(30)} answered ${String(succeeded).padStart(4)}/${count}` +
    `  failed ${String(failed).padStart(3)}  |  ${distribution}`);
  agent.destroy();
}
```

The driver script puts two balancers in front of the same three replicas, one with health
checking on and the other off, then kills replica `k2` and sends the same load to both. Port
numbers are environment-dependent and should change if already in use.

```bash
# measure.sh — three replicas, two balancers (health check on and off); replica k2 is dropped
node balance/replica.mjs 8861 k1 & K1=$!
node balance/replica.mjs 8862 k2 & K2=$!
node balance/replica.mjs 8863 k3 & K3=$!
node balance/balancer.mjs 8851 on  8861 8862 8863 & BON=$!
node balance/balancer.mjs 8852 off 8861 8862 8863 & BOFF=$!
sleep 1

node balance/client.mjs 8851 300 "three replicas up"
kill $K2; sleep 1
node balance/client.mjs 8851 300 "k2 down, health check on"
node balance/client.mjs 8852 300 "k2 down, health check off"
echo "pool (health check on) : $(curl -s localhost:8851/manage/status)"
echo "pool (health check off): $(curl -s localhost:8852/manage/status)"
kill $K1 $K3 $BON $BOFF
```

```
three replicas up              answered  300/300  failed   0  |  k1=100  k2=100  k3=100
k2 down, health check on       answered  300/300  failed   0  |  k1=150  k3=150
k2 down, health check off      answered  200/300  failed 100  |  code 502=100  k1=100  k3=100
pool (health check on) : {"unreachable":0,"up":[8861,8863]}
pool (health check off): {"unreachable":100,"up":[8861,8862,8863]}
```

#### Measured Effect

These numbers are in the **measurement** class: they come from local processes, and they are
deterministic, since requests are sent in sequence and the distribution rule is round robin —
another machine gives the same split, though its port numbers will differ.

The first row shows the distribution: with three replicas up, 300 requests split 100–100–100.
The balancer alone produces no gain, only a three-way division — which becomes a capacity
calculation in the next section.

The second and third rows are the lesson's real measurement. Same failure, same load, same
replicas. With health checking on, the balancer answered 300 of 300 requests and split the load
150–150 across the remaining two; the pool status shows `k2`'s port dropped from the list. With
it off, the balancer answered only 200 of 300: round robin sent one in three requests to the dead
replica, and 100 came back with 502. The pool status still counts all three replicas as up.

This result changes the balancer's definition. **Distribution alone does not produce service
availability.** Without a check that notices the drop, adding replicas only dilutes the failure:
the system does not stop, but a third of requests keep dropping, and it never announces itself as
a mass outage. Health checking is what turns replication into a service availability tool.

#### Where TLS Termination Ends

Where the encrypted connection ends is a **placement decision**, and the result measured here is
not a duration but a visibility. The balancer above reads `outerReq.url` to tell apart the
`/manage/status` path — that line exists only because the connection terminates at the balancer.
If the encrypted stream instead runs all the way to the replica, the balancer has only bytes in
hand, not a path, headers, or a body. TLS and the handshake were established in the Network
Models and Protocols course and are not retold here; this decision's result for this lesson is:
**termination is the precondition for a content-aware distribution rule.**

The decision's second result is countable. The number of endpoints holding the certificate is 1
when termination happens at the balancer, and equal to the replica count when it happens at the
replicas; adding a replica costs 0 certificate work in the first case, 1 in the second. The
processor cost of encryption is not measured here — it depends on the cipher suite and hardware,
and this course writes down no unmeasured number.

#### Back to the Calculation

The Introduction to System Design course calculated the peak request rate at the edge as 513.89
req/s. That number is not the load a single replica carries; it is divided by the replica count.
The division needs this topic's own assumptions, **not added** to K01's V1–V13 table:

**Y1 — saturation throughput per replica: 400 req/s.** Rationale: the upper bound measured on a
bare endpoint in K01's Latency and Throughput lesson was 4268 req/s; the real tracking endpoint
looks up the store, serializes, and authorizes, so the planning number sits well below the
measured one. **Y2 — target utilization 0.50.** Rationale: the same lesson's queuing model showed
utilization at 50 percent multiplying duration by 1.4, and at 90 percent by 7. **Y3 — health
check interval 200 ms, threshold 1 round.** Rationale: the values used in the setup.

```js
// balance/capacity.mjs — K01's calculation divided by replica count, and the health check's effect on the budget
const PEAK_EDGE = 513.89;          // K01 Back-of-the-Envelope Estimation: peak edge req/s
const DAILY_REQUESTS = 12_000_000 + 2_800_000;   // K01: daily tracking query + status event
const SATURATION = 400;            // assumption Y1: saturation throughput per replica (req/s)
const TARGET_UTILIZATION = 0.5;    // assumption Y2: replica's target utilization
const CHECK_MS = 200;              // assumption Y3: health check interval, threshold 1 round
const RECOVERY_MIN = 10;           // K01 Availability in Numbers: recovery time
const FAILURES_MONTH = 2.82;       // K01: failures per month that fit the budget

const safeRate = SATURATION * TARGET_UTILIZATION;
const minReplicas = Math.ceil(PEAK_EDGE / safeRate);
console.log(`safe rate per replica = ${safeRate} req/s (${SATURATION} x ${TARGET_UTILIZATION})`);
console.log(`min replicas = ceil(${PEAK_EDGE} / ${safeRate}) = ${minReplicas}\n`);

console.log("replicas  req/s per replica  utilization  req/s if one drops  utilization then");
for (const n of [minReplicas, minReplicas + 1, minReplicas + 2]) {
  const share = PEAK_EDGE / n, afterDrop = PEAK_EDGE / (n - 1);
  console.log(`${String(n).padStart(8)}  ${share.toFixed(2).padStart(17)}  ` +
    `${(share / SATURATION).toFixed(3).padStart(11)}  ${afterDrop.toFixed(2).padStart(18)}  ` +
    `${(afterDrop / SATURATION).toFixed(3).padStart(16)}`);
}

const average = DAILY_REQUESTS / 86_400;
console.log(`\naverage edge rate = ${average.toFixed(2)} req/s; peak / ${minReplicas} replicas = ` +
  `${(PEAK_EDGE / minReplicas).toFixed(2)} req/s (same number, since the peak multiplier is ${minReplicas})`);

const monthlyRequests = DAILY_REQUESTS * 30;
console.log(`\nwindow             dropped req/failure   monthly dropped req   request based availability`);
for (const [name, sec] of [["health check", CHECK_MS / 1000], ["manual recovery", RECOVERY_MIN * 60]]) {
  const perFailure = (PEAK_EDGE / minReplicas) * sec, monthly = perFailure * FAILURES_MONTH;
  console.log(`${name.padEnd(18)} ${perFailure.toFixed(2).padStart(17)} ${monthly.toFixed(0).padStart(19)}   ` +
    `${(100 * (1 - monthly / monthlyRequests)).toFixed(5)}%`);
}

const checkLoad = (minReplicas * 1000) / CHECK_MS;
console.log(`\ncheck load = ${checkLoad} req/s, ${((checkLoad / PEAK_EDGE) * 100).toFixed(1)}% of the peak edge rate`);
console.log(`sensitivity: if Y1 doubles, min replicas = ${Math.ceil(PEAK_EDGE / (2 * SATURATION * TARGET_UTILIZATION))}, ` +
  `if halved = ${Math.ceil(PEAK_EDGE / (0.5 * SATURATION * TARGET_UTILIZATION))}`);
```

```
safe rate per replica = 200 req/s (400 x 0.5)
min replicas = ceil(513.89 / 200) = 3

replicas  req/s per replica  utilization  req/s if one drops  utilization then
       3             171.30        0.428              256.94             0.642
       4             128.47        0.321              171.30             0.428
       5             102.78        0.257              128.47             0.321

average edge rate = 171.30 req/s; peak / 3 replicas = 171.30 req/s (same number, since the peak multiplier is 3)

window             dropped req/failure   monthly dropped req   request based availability
health check                   34.26                  97   99.99998%
manual recovery            102778.00              289834   99.93472%

check load = 15 req/s, 2.9% of the peak edge rate
sensitivity: if Y1 doubles, min replicas = 2, if halved = 6
```

All these numbers are in the **computed** class. The minimum replica count is three, each
carrying 171.30 req/s. The second table's first column shows what happens the **moment** a
replica drops: the remaining two rise to 256.94 req/s and 0.642 utilization — a three-replica
design overshoots the target on a single failure. With four replicas, utilization in the same
situation stays at 0.428. So "minimum replica count" and "replica count that still holds the
target if one drops" are two separate numbers, one replica apart.

The average row records a coincidence: the peak divided by three replicas is 171.30 req/s, and
the system's average edge rate is also 171.30 req/s. The two numbers match because the peak
multiplier is 3 and the replica count was also chosen as 3; this is not a relationship, it is a
coincidence the assumptions produce. When the replica count changes, the equality breaks.

The last table is health checking's counterpart in the outage budget. K01's Availability in
Numbers lesson set aside 28.2 of the monthly 43.2-minute budget for failures, counting 2.82
failures a month at a ten-minute recovery time — assuming every failure was a **total outage**.
In a pool of three replicas, one dropping is not: with health checking, 34.26 requests drop per
failure, 97 a month. Without it, the failure lasts until recovery: 102,778 requests per failure,
289,834 a month — 3000 times as many. Checking's own bill: 15 req/s to the replicas, 2.9 percent
of the peak edge load.

The request-based column carries a warning. The unchecked setup's ratio, 99.93472 percent, sits
above the 99.9 percent target, yet the same failures spend the entire failure share of the
time-based budget. The same failure looks like two different magnitudes on the two bases, since
only a third of requests drop. K01 kept these bases separate; the numbers here show why.

#### Summary

- The load balancer carries three responsibilities: distribution, health checking, and TLS
  termination — the first shares the load, the second drops a failed replica from the pool, the
  third decides where the encrypted connection ends.
- In the same failure, health checking on answered 300/300 requests and split the load 150–150;
  off, it answered 200/300 and 100 requests came back with 502.
- Distribution alone does not produce service availability: without a check that notices the
  drop, a third of requests keep failing without looking like a mass outage.
- TLS termination's measured result here is visibility: without it ending at the balancer, the
  path and headers cannot be read, so no content-aware rule can be built.
- The 513.89 req/s peak, with a 200 req/s safe rate per replica (Y1, Y2), needs a minimum of 3
  replicas at 171.30 req/s each; losing one pushes the rest to 256.94 req/s and 0.642
  utilization — over target, unlike with four replicas.
- Health checking cuts dropped requests per failure from 102,778 to 34.26 (289,834 monthly versus
  97, 3000 times); its own load is 15 req/s, 2.9 percent of the peak edge.

#### Next Step

This lesson's balancer decided on every request, reading the request's path to do it — the
`/manage/status` distinction depended on that. Reading is a choice, not a requirement. A balancer
can work without looking into the request: deciding once, at connection setup, then passing
everything on that connection to the same replica. Less work, less knowledge. The next lesson
builds this level: what connection-level distribution cannot know, how persistent connections
break it, and what skew a once-per-connection decision produces in the per-request split.

### Layer 4 Balancing

Source: https://academia.sh/en/courses/traffic-layer/layer-4-balancing

The decision being made once, when the connection is established, and its cost: measuring the same total load with even and uneven client shares, perfect connection distribution producing four times the request skew, counting the information read to make the decision, and computing how many replicas the measured imbalance corresponds to at the introductory course's peak rate.

The previous lesson's balancer decided on every request, reading the request's path to do it.
Reading is a choice, not a requirement. A balancer can work without looking into the request: it
decides once, when the connection is established, then passes everything that flows through it
to the same replica.

This level is called **layer 4 balancing**. The layer model was established in the Network
Models and Protocols course: layer 4 is the transport layer, and what is visible there is
address, port, and byte stream; path, headers, and body are a layer up. This lesson builds that
level, counts what it cannot know, and measures what not knowing does to distribution.

#### One Decision Per Connection

A layer 4 balancer accepts a connection, chooses a replica, opens its own connection to that
replica, and relays bytes between the two directions as-is. The choice is made once and holds
until the connection closes.

```js
// l4/balancer.mjs — layer 4 balancer: the decision is made once when the connection is
// established, then both directions are relayed as-is. Usage: node l4/balancer.mjs <port> <counter.json> <replicas...>
import { createServer, connect } from "node:net";
import { writeFileSync } from "node:fs";

const port = Number(process.argv[2]);
const counterPath = process.argv[3];
const replicas = process.argv.slice(4).map(Number);
const counter = { decisions: 0, connections: Object.fromEntries(replicas.map((p) => [p, 0])), bytes: 0 };
let next = 0;

if (Number.isInteger(port) === false || replicas.length === 0) {
  console.log("usage: node l4/balancer.mjs <port> <counter.json> <replicas...>");
} else {
  const s = createServer((outer) => {
    const target = replicas[next++ % replicas.length];   // single decision: which replica this connection belongs to
    counter.decisions += 1;
    counter.connections[target] += 1;
    const inner = connect(target, "127.0.0.1");
    outer.on("data", (chunk) => { counter.bytes += chunk.length; });   // bytes passed are counted, content is not read
    outer.pipe(inner);
    inner.pipe(outer);
    for (const [a, b] of [[outer, inner], [inner, outer]]) {
      a.on("error", () => b.destroy());
      a.on("close", () => b.destroy());
    }
  });
  s.listen(port, "127.0.0.1");
  process.on("SIGTERM", () => { writeFileSync(counterPath, JSON.stringify(counter)); process.exit(0); });
}
```

The distinction the code carries is the distinction between `node:net` and `node:http`. There is
no HTTP request object in this file, so there is no path, header, or tracking number either. The
only content measure the counter can hold is the number of **bytes passed** — and even that is a
count of bytes whose content is unknown.

The replicas run a layer up and count the requests they see by path. They report their counters
through the `/counter` path; this path is queried directly, not through the balancer, so it does
not interfere with the measurement.

```js
// l4/replica.mjs — application replica: counts the requests it sees by path, reports via /counter
import { createServer } from "node:http";

const [port, name] = [Number(process.argv[2]), process.argv[3]];
const counter = { name, requests: 0, paths: {} };

if (Number.isInteger(port) === false) console.log("usage: node l4/replica.mjs <port> <name>");
else createServer((req, res) => {
  res.sendDate = false;
  const path = new URL(req.url, "http://local").pathname;
  if (path === "/counter") {
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(counter));
    return;
  }
  counter.requests += 1;
  counter.paths[path] = (counter.paths[path] ?? 0) + 1;
  res.writeHead(200, { "content-type": "application/json", "x-replica": name });
  res.end(JSON.stringify({ state: "at transfer hub", zone: "35", step: 4 }));
}).listen(port, "127.0.0.1");
```

The client carries the setup's real parameter: each client opens **a single persistent
connection** and sends its share of requests through it. Whether the shares are even or uneven is
the measurement's input. Clients run in sequence, so the connection order — and hence the mapping
round robin distribution produces — is deterministic.

```js
// l4/client.mjs — each client opens a single persistent connection and sends its share of requests in sequence
import { Agent, request } from "node:http";

const port = Number(process.argv[2]);
const SHARES = (process.argv[3] ?? "1").split(",").map(Number);   // requests per client

function one(agent, no, path) {
  return new Promise((resolve, reject) => {
    const r = request({ port, path: `${path}?no=${no}`, agent }, (res) => {
      res.resume();
      res.on("end", () => resolve(res.headers["x-replica"] ?? "-"));
    });
    r.on("error", reject);
    r.end();
  });
}

if (Number.isInteger(port) === false) console.log("usage: node l4/client.mjs <port> <share,share,...>");
else {
  let total = 0;
  for (let i = 0; i < SHARES.length; i += 1) {          // clients run in sequence: connection order is deterministic
    const agent = new Agent({ keepAlive: true, maxSockets: 1 });
    for (let j = 0; j < SHARES[i]; j += 1) {
      await one(agent, `I${i}-${j}`, j % 2 === 0 ? "/tracking" : "/event");
      total += 1;
    }
    agent.destroy();
  }
  console.log(`${SHARES.length} clients, ${total} requests sent (shares: ${SHARES.join(",")})`);
}
```

The report script merges two sources: the balancer's connection counters and the replicas'
request counters.

```js
// l4/report.mjs — merges the balancer's counter file with the replicas' /counter output into a single table
import { existsSync, readFileSync } from "node:fs";

const [counterPath, ...ports] = process.argv.slice(2);
if (counterPath === undefined || existsSync(counterPath) === false) {
  console.log("usage: node l4/report.mjs <counter.json> <replica-ports...>");
  process.exit(0);
}
const data = JSON.parse(readFileSync(counterPath, "utf8"));

const rows = [];
for (const p of ports) {
  const r = await fetch(`http://127.0.0.1:${p}/counter`).then((res) => res.json());
  rows.push({ name: r.name, port: p, connections: data.connections[p], requests: r.requests,
    paths: Object.entries(r.paths).sort().map(([a, n]) => `${a.slice(1)}=${n}`).join(" ") });
}

const requests = rows.map((s) => s.requests);
console.log("replica  connections  requests  path distribution");
for (const s of rows) {
  console.log(`${s.name.padEnd(7)}  ${String(s.connections).padStart(11)}  ${String(s.requests).padStart(8)}  ${s.paths}`);
}
console.log(`decisions = ${data.decisions} (1 per connection), decisions per request = ` +
  `${(data.decisions / requests.reduce((a, b) => a + b, 0)).toFixed(3)}`);
console.log(`connection skew = ${(Math.max(...rows.map((s) => s.connections)) / Math.min(...rows.map((s) => s.connections))).toFixed(2)}, ` +
  `request skew = ${(Math.max(...requests) / Math.min(...requests)).toFixed(2)}`);
console.log(`request bytes through the balancer = ${data.bytes}`);
```

The setup runs twice. Both runs have six clients and three replicas; the only thing that changes
is how requests are distributed among clients. Port numbers are environment-dependent.

```bash
# measure.sh — the same total load with two client mixes: even shares and uneven shares
for mix in "10,10,10,10,10,10" "1,2,4,8,16,32"; do
  node l4/replica.mjs 8871 k1 & K1=$!
  node l4/replica.mjs 8872 k2 & K2=$!
  node l4/replica.mjs 8873 k3 & K3=$!
  node l4/balancer.mjs 8841 counter.json 8871 8872 8873 & D=$!
  sleep 1
  node l4/client.mjs 8841 "$mix"
  kill -TERM $D; sleep 0.3
  node l4/report.mjs counter.json 8871 8872 8873
  kill $K1 $K2 $K3; sleep 0.3
  echo
done
```

```
6 clients, 60 requests sent (shares: 10,10,10,10,10,10)
replica  connections  requests  path distribution
k1                 2        20  event=10 tracking=10
k2                 2        20  event=10 tracking=10
k3                 2        20  event=10 tracking=10
decisions = 6 (1 per connection), decisions per request = 0.100
connection skew = 1.00, request skew = 1.00
request bytes through the balancer = 4710

6 clients, 63 requests sent (shares: 1,2,4,8,16,32)
replica  connections  requests  path distribution
k1                 2         9  event=4 tracking=5
k2                 2        18  event=9 tracking=9
k3                 2        36  event=18 tracking=18
decisions = 6 (1 per connection), decisions per request = 0.095
connection skew = 1.00, request skew = 4.00
request bytes through the balancer = 4975
```

#### Two Skews in One Table

These numbers are in the **measurement** class and are deterministic; since clients run in
sequence and the distribution rule is round robin, another machine gives the same split.

Connection skew is 1.00 in both runs: the balancer split the six connections two-and-two across
the three replicas, with no flaw in that split. It does the job it was given perfectly. In the
first run, request skew is also 1.00: since the shares are even, balanced connections translate
into balanced requests.

In the second run, that same perfect connection distribution produces **4.00 times** the request
skew. The replicas saw 9, 18, and 36 requests; the most loaded replica carried four times what
the least loaded one did. The only thing that changed is how requests were distributed among
connections. **A layer 4 balancer balances connections, not requests;** since the two are not the
same thing, the measurement needs two separate skew columns.

The skew's source is the persistent connection. If a connection carries one request, the two
numbers are the same; if it carries thirty-two, one decision by the balancer ties thirty-two
requests together. Because the decision is made once, it cannot be corrected: the balancer does
not notice that 36 requests went to the same replica, because it never counts them.

#### What Is Not Known

The decision column is the measure of not knowing: 6 decisions for 63 requests, 0.095 decisions
per request. The layer 4 balancer's work is cheaper than that ratio suggests — choosing once per
connection and then passing bytes through is less work than parsing and rewriting every request.

The bill paid shows up in the path distribution column. Every replica received both `tracking`
and `event` requests, in the same ratio as the connection shares. Splitting some replicas off for
reading and others for writing is **impossible** at this level: the decision is made when the
connection is established, and no path has been sent yet at that point. The same constraint holds
for finer distinctions too — no rule can look at a tracking number, a client type, or a header,
because none of these are visible at layer 4. The 4975 bytes the balancer sees are a stream of
unknown content.

Not knowing also has an upside. Because the layer 4 balancer does not know HTTP, it is not tied
to HTTP: the same code works for another connection-based protocol too, and since it does not
buffer the body, streamed data passes through without interference.

#### Back to the Calculation

The previous lesson divided the 513.89 req/s peak rate across three replicas and found 171.30
req/s per replica. That division assumed the distribution was perfect. The measured skew breaks
the assumption. The calculation below derives an **imbalance factor** from the measured shares:
the heaviest replica's share multiplied by the replica count, which is 1 in perfect balance.

One more assumption is needed. **Y4 — average requests per persistent connection: 10.** Rationale:
when a user opens the tracking page, several queries pass over the same connection; the number is
this topic's own assumption and is not added to K01's table.

```js
// l4/capacity.mjs — what the measured imbalance corresponds to at K01's peak rate, and the replicas it requires
const PEAK_EDGE = 513.89;          // K01 Back-of-the-Envelope Estimation: peak edge req/s
const SAFE_RATE = 200;             // lesson 01: Y1 x Y2 = 400 x 0.50
const REQ_PER_CONN = 10;           // assumption Y4: average requests per persistent connection

const MEASURED = { even: [20, 20, 20], uneven: [9, 18, 36] };   // lesson 02's measurement

const imbalanceFactor = (shares) => (Math.max(...shares) / shares.reduce((a, b) => a + b, 0)) * shares.length;
const required = (shares) => Math.ceil((imbalanceFactor(shares) * PEAK_EDGE) / SAFE_RATE);

console.log("mix      shares        heaviest share  imbalance  heaviest replica req/s  utilization  required replicas");
for (const [name, shares] of Object.entries(MEASURED)) {
  const heaviestShare = Math.max(...shares) / shares.reduce((a, b) => a + b, 0);
  const rate = heaviestShare * PEAK_EDGE;
  console.log(`${name.padEnd(8)} ${shares.join("/").padEnd(13)} ${heaviestShare.toFixed(4).padStart(15)} ` +
    `${imbalanceFactor(shares).toFixed(3).padStart(10)} ${rate.toFixed(2).padStart(23)} ` +
    `${(rate / 400).toFixed(3).padStart(12)} ${String(required(shares)).padStart(18)}`);
}

console.log(`\nreplicas in perfect balance = ${required(MEASURED.even)}, at measured imbalance = ` +
  `${required(MEASURED.uneven)}: difference ${required(MEASURED.uneven) - required(MEASURED.even)} replicas, ` +
  `purely for distribution skew`);

console.log(`\ndecision rate (1 decision per connection, Y4 = ${REQ_PER_CONN}): ` +
  `${(PEAK_EDGE / REQ_PER_CONN).toFixed(2)} decisions/s`);
console.log(`if a decision were made per request: ${PEAK_EDGE} decisions/s, ${REQ_PER_CONN} times`);
console.log(`sensitivity: if Y4 = 1 the two rates are equal, if Y4 = 100 the ratio becomes 100 times`);
```

```
mix      shares        heaviest share  imbalance  heaviest replica req/s  utilization  required replicas
even     20/20/20               0.3333      1.000                  171.30        0.428                  3
uneven   9/18/36                0.5714      1.714                  293.65        0.734                  5

replicas in perfect balance = 3, at measured imbalance = 5: difference 2 replicas, purely for distribution skew

decision rate (1 decision per connection, Y4 = 10): 51.39 decisions/s
if a decision were made per request: 513.89 decisions/s, 10 times
sensitivity: if Y4 = 1 the two rates are equal, if Y4 = 100 the ratio becomes 100 times
```

These numbers, in the computed class, correct the previous lesson's result. In perfect balance
the heaviest replica carries 171.30 req/s and 0.428 utilization; at the measured imbalance it
carries 293.65 req/s and 0.734 utilization. The second number sits above the 0.50 target set by
Y2, meaning the three-replica design does not hold the target at peak load. The replica count
needed to hold the target is **5**, not 3, and the extra two replicas are paid not for load
growth but purely for distribution skew.

The decision-rate row also gives the next lesson's price tag. With a decision per connection, the
balancer produces 51.39 decisions a second; if it decided per request, it would produce 513.89,
ten times as many. Correcting the skew means paying that tenfold price.

#### Summary

- Layer 4 balancing makes its decision once, when the connection is established; every request
  flowing through that connection goes to the same replica, and the decision is never corrected
  afterward.
- The same total load split 20–20–20 with even shares and 9–18–36 with uneven shares: connection
  skew was 1.00 in both runs, request skew was 1.00 against 4.00.
- Balancing connections is not balancing requests; the skew's source is that persistent
  connections do not carry equal request counts, and the balancer cannot measure this because it
  does not count requests.
- The decision count is 6 for 63 requests (0.095 per request); this cheapness is bought by the
  path, headers, and tracking number staying invisible — splitting reads and writes across
  separate replicas is impossible at this level.
- The measured imbalance factor of 1.714 pushes the heaviest replica above target at the peak
  rate, to 293.65 req/s and 0.734 utilization; holding the target needs 5 replicas instead of 3.
- The cost of deciding per request, under the Y4 = 10 assumption, is 513.89 decisions/s instead
  of 51.39 — ten times as many.

#### Next Step

The measured 4.00 times skew and the two streams that cannot be split come from the same cause:
the decision is made before the information it needs exists. That information sits one layer up
— path, headers, method, and query string start flowing right after the connection is
established. If a balancer chooses to read them, it can decide again for every request, route it
to a different set of replicas by content, and pay the bill for producing ten times the
decisions. The next lesson builds this level: it measures what the skew becomes when the same
client mix passes through a content-aware balancer, how splitting reads and writes into separate
pools changes the replica count, and what new responsibilities parsing the request hands the
balancer.

### Layer 7 Balancing

Source: https://academia.sh/en/courses/traffic-layer/layer-7-balancing

The decision being made again for every request, and the request's content being read: the same client mix's request skew dropping from four times to one, splitting read and write streams into separate replica pools, external connections collapsing into internal ones, and measuring how many replicas the split costs in the introductory course's calculation.

The previous lesson's measured 4.00 times skew and the two streams that could not be split came
from the same cause: the decision was made before the information it needed existed. That
information sits one layer up. Path, method, headers, and query string start flowing right after
the connection is established; if a balancer chooses to read them, it can decide again for every
request.

This level is called **layer 7 balancing**. Layer 7 is the application layer, and HTTP is
defined there; the protocol itself is the subject of the Network Models and Protocols course and
is not retold here. This lesson's job is to measure what deciding **per request** and **by
content** gains and what it costs.

#### Deciding Per Request

A layer 7 balancer splits a connection into two parts: the one it holds with the client and the
one it holds with the replica. In between, it parses the request, reads the path, chooses a pool,
and forwards the request rewritten.

```js
// l7/balancer.mjs — layer 7 balancer: the path is read on every request, a pool is chosen, round
// robin distribution is applied. Usage: node l7/balancer.mjs <port> <counter.json> <read> <write>
import { createServer, request, Agent } from "node:http";
import { writeFileSync } from "node:fs";

const port = Number(process.argv[2]);
const counterPath = process.argv[3];
const POOL = {
  read: (process.argv[4] ?? "").split(",").filter(Boolean).map(Number),
  write: (process.argv[5] ?? "").split(",").filter(Boolean).map(Number),
};
const next = { read: 0, write: 0 };
const agents = new Map();                     // one persistent inner connection per replica
const seen = new WeakSet();                   // unique count of inner connections
const counter = { decisions: 0, externalConnections: 0, internalConnections: 0, distribution: {} };

function count(replica, pool) {
  const entry = (counter.distribution[replica] ??= { read: 0, write: 0 });
  entry[pool] += 1;
}

if (Number.isInteger(port) === false || POOL.read.length === 0) {
  console.log("usage: node l7/balancer.mjs <port> <counter.json> <read> <write>");
} else {
  const s = createServer((outerReq, outerRes) => {
    const path = new URL(outerReq.url, "http://local").pathname;   // read on every request
    const pool = path === "/event" ? "write" : "read";
    const list = POOL[pool];
    const target = list[next[pool]++ % list.length];
    counter.decisions += 1;
    count(target, pool);
    if (agents.has(target) === false) agents.set(target, new Agent({ keepAlive: true, maxSockets: 1 }));
    const innerReq = request({ port: target, path: outerReq.url, method: outerReq.method,
      headers: { ...outerReq.headers, forwarded: `for="${outerReq.socket.remoteAddress}"` },
      agent: agents.get(target) }, (innerRes) => {
      outerRes.writeHead(innerRes.statusCode, innerRes.headers);
      innerRes.pipe(outerRes);
    });
    innerReq.on("socket", (socket) => {
      if (seen.has(socket) === false) { seen.add(socket); counter.internalConnections += 1; }
    });
    innerReq.on("error", () => outerRes.writeHead(502).end("replica unreachable"));
    outerReq.pipe(innerReq);
  });
  s.on("connection", () => { counter.externalConnections += 1; });
  s.listen(port, "127.0.0.1");
  process.on("SIGTERM", () => { writeFileSync(counterPath, JSON.stringify(counter)); process.exit(0); });
}
```

In this setup, the replicas report only their own name; counting now lives in the balancer,
since it makes the decision and knows which request went where.

```js
// l7/replica.mjs — application replica: only reports its own name, counting is kept in the balancer
import { createServer } from "node:http";

const [port, name] = [Number(process.argv[2]), process.argv[3]];

if (Number.isInteger(port) === false) console.log("usage: node l7/replica.mjs <port> <name>");
else createServer((req, res) => {
  res.sendDate = false;
  res.writeHead(200, { "content-type": "application/json", "x-replica": name });
  res.end(JSON.stringify({ state: "at transfer hub", zone: "35", step: 4 }));
}).listen(port, "127.0.0.1");
```

The client repeats the previous lesson's mix exactly — six clients, uneven shares, one persistent
connection per client — and adds one thing: a path mix. One in five requests is a status event,
so the setup's read/write ratio stays close to the one K01 calculated.

```js
// l7/client.mjs — lesson 02's same mix: each client one persistent connection, uneven shares.
// The path mix approximately keeps K01's read/write ratio: one in five requests is a status event.
import { Agent, request } from "node:http";

const port = Number(process.argv[2]);
const SHARES = (process.argv[3] ?? "1").split(",").map(Number);

function one(agent, no, path) {
  return new Promise((resolve, reject) => {
    const r = request({ port, path: `${path}?no=${no}`, agent }, (res) => {
      res.resume();
      res.on("end", () => resolve(res.headers["x-replica"] ?? "-"));
    });
    r.on("error", reject);
    r.end();
  });
}

if (Number.isInteger(port) === false) console.log("usage: node l7/client.mjs <port> <share,share,...>");
else {
  let g = 0, tracking = 0, event = 0;
  for (let i = 0; i < SHARES.length; i += 1) {
    const agent = new Agent({ keepAlive: true, maxSockets: 1 });
    for (let j = 0; j < SHARES[i]; j += 1) {
      const path = g % 5 === 4 ? "/event" : "/tracking";
      if (path === "/event") event += 1; else tracking += 1;
      await one(agent, `I${i}-${j}`, path);
      g += 1;
    }
    agent.destroy();
  }
  console.log(`${SHARES.length} clients, ${g} requests: tracking ${tracking}, event ${event} ` +
    `(ratio ${(tracking / event).toFixed(2)}; K01's peak read/write = 4.29)`);
}
```

```js
// l7/report.mjs — turns the balancer's counter file into a table
import { existsSync, readFileSync } from "node:fs";

const counterPath = process.argv[2];
if (counterPath === undefined || existsSync(counterPath) === false) {
  console.log("usage: node l7/report.mjs <counter.json>");
  process.exit(0);
}
const data = JSON.parse(readFileSync(counterPath, "utf8"));
const rows = Object.entries(data.distribution).sort();
const totals = rows.map(([, v]) => v.read + v.write);

console.log("replica  tracking  event  total");
for (const [name, v] of rows) {
  console.log(`${name.padEnd(7)}  ${String(v.read).padStart(8)}  ${String(v.write).padStart(5)}  ` +
    `${String(v.read + v.write).padStart(5)}`);
}
console.log(`decisions = ${data.decisions} (1 per request), external connections = ${data.externalConnections}, ` +
  `internal connections = ${data.internalConnections}`);
console.log(`request skew = ${(Math.max(...totals) / Math.min(...totals)).toFixed(2)}`);
```

The setup brings up the same four replicas twice. In the first run all four are in a single pool;
in the second, three are in the read pool and one in the write pool. Port numbers are
environment-dependent.

```bash
# measure.sh — the same client mix with two layouts: four replicas in a single pool, then read/write split
for layout in "8881,8882,8883,8884:8881,8882,8883,8884" "8881,8882,8883:8884"; do
  READ=${layout%%:*}; WRITE=${layout##*:}
  node l7/replica.mjs 8881 k1 & K1=$!
  node l7/replica.mjs 8882 k2 & K2=$!
  node l7/replica.mjs 8883 k3 & K3=$!
  node l7/replica.mjs 8884 k4 & K4=$!
  node l7/balancer.mjs 8845 counter.json "$READ" "$WRITE" & D=$!
  sleep 1
  echo "read pool = $READ   write pool = $WRITE"
  node l7/client.mjs 8845 "1,2,4,8,16,32"
  kill -TERM $D; sleep 0.3
  node l7/report.mjs counter.json
  kill $K1 $K2 $K3 $K4; sleep 0.3
  echo
done
```

```
read pool = 8881,8882,8883,8884   write pool = 8881,8882,8883,8884
6 clients, 63 requests: tracking 51, event 12 (ratio 4.25; K01's peak read/write = 4.29)
replica  tracking  event  total
8881           13      3     16
8882           13      3     16
8883           13      3     16
8884           12      3     15
decisions = 63 (1 per request), external connections = 6, internal connections = 4
request skew = 1.07

read pool = 8881,8882,8883   write pool = 8884
6 clients, 63 requests: tracking 51, event 12 (ratio 4.25; K01's peak read/write = 4.29)
replica  tracking  event  total
8881           17      0     17
8882           17      0     17
8883           17      0     17
8884            0     12     12
decisions = 63 (1 per request), external connections = 6, internal connections = 4
request skew = 1.42
```

#### The Skew Disappearing

These numbers are in the **measurement** class and are deterministic. The comparison is made
against the previous lesson's table: the same six clients, the same uneven shares, the same total
request count.

At layer 4, request skew was 4.00. At layer 7, it is 1.07. Why the skew disappears is visible in
the table: the decision count is 63, that is, 1 per request. The client that sent thirty-two
requests over a single connection is now subject to thirty-two separate decisions, and those
requests spread across four replicas. The remaining 1.07 comes from not dividing evenly — 63
requests split four ways come out 16, 16, 16, and 15.

The second run shows a separate gain. Because the same balancer reads the path, it sends
`/tracking` requests to the read pool and `/event` requests to the write pool: the read replicas
got 17 tracking requests each, the write replica got 12 event requests, with no cross-leak. The
split that was impossible at layer 4 has been reduced here to a list assignment. Total skew
rising to 1.42 is not a flaw: since the pools now serve different streams, comparing them with a
single skew number is meaningless — each pool is sized to its own stream's rate.

The connection column carries a third difference. External connections: 6, internal: 4. The
balancer collapses the clients' six connections into one persistent connection per replica; this
kind of collapse was impossible at layer 4, since every external connection had a matching
internal one there. The number of connections replicas see becomes independent of the client
count.

#### The Cost Paid

Every gain is priced on the same line: **63 decisions**. Layer 4 passed the same load with 6. The
work done per decision has also grown — the balancer parses the request line, copies the headers,
adds its own `forwarded` header, and rewrites the request. This work's duration is not measured,
since parsing cost depends on the implementation and the machine; what is measured is **how many
times** the work is done.

The second cost is a transfer of responsibility. The moment the balancer parses the request, it
becomes the owner of some of the decisions about it: which headers get forwarded, whether the
body gets buffered, whether a request gets retried on another replica while one is not answering.
None of these questions existed at the layer 4 balancer, since there was no concept of a request
there. A content-aware balancer becomes part of the application.

The third cost is dependency. Layer 7 rules are tied to the protocol and to path names. When the
`/event` path is renamed to `/status-event`, the balancer's configuration must change too; a
layer 4 balancer would never be affected by that change.

#### Back to the Calculation

The previous two lessons assumed a single pool and divided the 513.89 req/s peak rate.
Content-aware routing opens the option of splitting that division in two, and K01 had already
calculated the two stream rates separately: peak read 416.67 req/s, peak write 97.22 req/s.

```js
// l7/pool.mjs — splitting K01's stream rates from a single pool into separate pools, and the effect of write overflow
const READ = 416.67;               // K01 Back-of-the-Envelope Estimation: peak read req/s
const WRITE = 97.22;               // K01: peak write req/s
const SATURATION = 400;            // lesson 01 assumption Y1
const SAFE_RATE = 200;             // lesson 01: Y1 x Y2

const replicas = (rate) => Math.ceil(rate / SAFE_RATE);
const utilization = (rate, n) => rate / (n * SATURATION);

const single = replicas(READ + WRITE);
const readReplicas = replicas(READ), writeReplicas = replicas(WRITE);
console.log(`single pool: ${single} replicas, utilization ${utilization(READ + WRITE, single).toFixed(3)}`);
console.log(`split pools: read ${readReplicas} + write ${writeReplicas} = ${readReplicas + writeReplicas} replicas, ` +
  `utilization read ${utilization(READ, readReplicas).toFixed(3)}, write ${utilization(WRITE, writeReplicas).toFixed(3)}`);
console.log(`cost of splitting = ${readReplicas + writeReplicas - single} replica(s)\n`);

console.log("write multiplier   single pool utilization   split pool read   split pool write   write pool overflowed");
for (const c of [1, 2, 3, 4]) {
  const writeRate = WRITE * c;
  console.log(`${`x${c}`.padStart(16)}   ${utilization(READ + writeRate, single).toFixed(3).padStart(23)}   ` +
    `${utilization(READ, readReplicas).toFixed(3).padStart(16)}   ${utilization(writeRate, writeReplicas).toFixed(3).padStart(17)}   ` +
    `${String(writeRate / writeReplicas > SAFE_RATE).padStart(20)}`);
}

console.log(`\ndecision rate: layer 4 at ${((READ + WRITE) / 10).toFixed(2)} decisions/s (Y4 = 10), ` +
  `layer 7 at ${(READ + WRITE).toFixed(2)} decisions/s`);
console.log(`sensitivity: once the write rate passes ${(SAFE_RATE * writeReplicas).toFixed(0)} req/s the write pool grows to ` +
  `${writeReplicas + 1} replicas, the read pool does not change`);
```

```
single pool: 3 replicas, utilization 0.428
split pools: read 3 + write 1 = 4 replicas, utilization read 0.347, write 0.243
cost of splitting = 1 replica(s)

write multiplier   single pool utilization   split pool read   split pool write   write pool overflowed
              x1                     0.428              0.347               0.243                  false
              x2                     0.509              0.347               0.486                  false
              x3                     0.590              0.347               0.729                   true
              x4                     0.671              0.347               0.972                   true

decision rate: layer 4 at 51.39 decisions/s (Y4 = 10), layer 7 at 513.89 decisions/s
sensitivity: once the write rate passes 200 req/s the write pool grows to 2 replicas, the read pool does not change
```

The first two lines give the bill for the split as a **computed** value: a single pool needs 3
replicas, split pools need 3 plus 1, 4. Splitting costs one replica, because each pool rounds up
separately to its own rate, and the write pool's 97.22 req/s leaves half of one replica's 200
req/s share empty. Content-aware routing has no capacity gain on its own; on the contrary, it
needs resources.

What it gets in return is in the second table. When the status event stream from the carrier
triples — a transfer hub sending accumulated scans in bulk — the single pool's utilization climbs
from 0.428 to 0.590, and this rise **affects tracking queries too**, because the same replicas
serve both. In split pools, read utilization stays fixed at 0.347 across every multiplier; the
overflow happens only in the write pool, and there, from the third row on, growing that pool's
replica count is enough. **What the split buys is not capacity but isolation:** one stream
overflowing does not change the other's utilization.

The last lines carry the lesson's price tag to K01's scale. At layer 4, the balancer would make
51.39 decisions a second; at layer 7, it makes 513.89. Ten times the decisions, one extra
replica, and one stream's isolation — the three are parts of the same decision.

#### Summary

- Layer 7 balancing decides again for every request, deciding by the request's content; the
  connection held with the replica is separate from the one held with the client.
- In the same client mix, request skew is 4.00 at layer 4 and 1.07 at layer 7; the remaining skew
  comes from 63 requests not dividing evenly by four.
- Reading the path makes it possible to split streams into separate pools: the read replicas got
  17 tracking requests each, the write replica got 12 event requests, with no cross-leak.
- The balancer collapsed six external connections into four internal ones; the connection count
  replicas see became independent of the client count.
- The cost is the decision count: 63 instead of 6, 513.89 decisions/s instead of 51.39 at K01's
  scale; parsing the request also makes the balancer the owner of header, buffering, and retry
  decisions.
- Splitting streams costs one replica (4 instead of 3) but buys isolation: when the write stream
  triples, the single pool's utilization climbs from 0.428 to 0.590, while split-pool read
  utilization stays at 0.347.

#### Next Step

The three lessons so far kept the distribution rule fixed: replicas were chosen in turn. Round
robin assumes replicas are each other's equal and that every request does the same work. Neither
is always true — a replica may have slowed down, one request may cost ten times another, and
which replica a request lands on may need to stay **stable** in a way that serves the cache. The
next lesson changes the rule itself: it compares round robin against a rule that looks at open
connection count and one that looks at a key, under the same load, and counts how many keys move
when the third one's replica count changes.

### Balancing Algorithms

Source: https://academia.sh/en/courses/traffic-layer/balancing-algorithms

Changing the distribution rule itself: comparing the round-robin and least-connections rules by wait time and queue length under equal and slowed replicas, counting the fraction of keys that move under modulo hashing and consistent hashing when a node changes, the effect of virtual node count on distribution, and tracing that movement back to the introductory course's computation through the cache hit ratio.

The three lessons so far held the distribution rule fixed: replicas were picked in sequence. The
round-robin rule assumes two things — that the replicas are each other's equals, and that every
request does the same amount of work. Neither is always true. A replica can slow down, one
request can cost ten times more than another, and which replica a request lands on may need to
stay stable in a way that serves that replica's cache.

This lesson changes the rule itself. Three rules are covered: **round robin**, **least
connections**, and **consistent hashing**. The first two target how the load is split; the third
targets making the same key always land on the same replica.

#### The Queue Model

The first two rules are compared with an in-process model. The model is a queue in which requests
arrive at fixed intervals and each replica is a single sequential server. A round is an abstract
step; arrival interval, request cost, and replica slowdown are model parameters, not measured
durations. Costs come from a fixed-seed generator, so both rules face the **same** request
sequence.

```js
// alg/queue.mjs — comparing the round-robin and least-connections rules under the same load.
// This is a MODEL: a round is an abstract step, and arrival interval, request cost, and replica
// slowdown are model parameters, not measured durations.
const REPLICAS = 3, REQUESTS = 300, ARRIVAL = 2;   // arrival interval (rounds)
const SEED = 20240730;

function costs(n) {                 // fixed-seed generator: same sequence on every run
  let s = SEED;
  const c = [];
  for (let i = 0; i < n; i += 1) { s = (s * 1103515245 + 12345) % 2147483648; c.push(1 + (s >>> 8) % 10); }
  return c;
}

function run(rule, cost, multiplier) {
  const reps = Array.from({ length: REPLICAS }, () => ({ freeAt: 0, work: 0, done: [], p: 0 }));
  let totalWait = 0, maxQueue = 0;
  for (let i = 0; i < cost.length; i += 1) {
    const t = i * ARRIVAL;
    for (const r of reps) while (r.p < r.done.length && r.done[r.p] <= t) r.p += 1;
    const queued = reps.map((r) => r.done.length - r.p);
    maxQueue = Math.max(maxQueue, ...queued);
    const target = rule === "round-robin" ? i % REPLICAS : queued.indexOf(Math.min(...queued));
    const r = reps[target];
    const start = Math.max(t, r.freeAt);
    totalWait += start - t;
    r.freeAt = start + cost[i] * multiplier[target];
    r.work += cost[i] * multiplier[target];
    r.done.push(r.freeAt);
  }
  return { wait: totalWait / cost.length, maxQueue,
    work: reps.map((r) => r.work), finish: Math.max(...reps.map((r) => r.freeAt)) };
}

const cost = costs(REQUESTS);
const totalWork = cost.reduce((a, b) => a + b, 0);
console.log(`${REQUESTS} requests, ${REPLICAS} replicas, arrival interval ${ARRIVAL} rounds, total work ${totalWork} rounds`);
console.log(`model utilization with equal replicas = ${((totalWork / (REQUESTS * ARRIVAL)) / REPLICAS).toFixed(3)}\n`);

for (const [name, multiplier] of [["equal replicas", [1, 1, 1]], ["k1 three times slower", [3, 1, 1]]]) {
  console.log(`scenario: ${name}`);
  console.log("  rule               average wait  max queue  finish round  work per replica");
  for (const rule of ["round-robin", "least-connections"]) {
    const r = run(rule, cost, multiplier);
    console.log(`  ${rule.padEnd(18)} ${r.wait.toFixed(2).padStart(13)} ` +
      `${String(r.maxQueue).padStart(10)} ${String(r.finish).padStart(13)}  ${r.work.join("/").padStart(16)}`);
  }
  console.log();
}
```

```
300 requests, 3 replicas, arrival interval 2 rounds, total work 1679 rounds
model utilization with equal replicas = 0.933

scenario: equal replicas
  rule               average wait  max queue  finish round  work per replica
  round-robin                 4.04          4           614       545/581/553
  least-connections           1.90          2           610       589/567/523

scenario: k1 three times slower
  rule               average wait  max queue  finish round  work per replica
  round-robin               170.97         63          1635      1635/581/553
  least-connections          56.99         18           843       843/697/701
```

With equal replicas, the two rules do the same total work and finish in nearly the same round: 614
versus 610. The difference shows up in waiting: average wait drops from 4.04 rounds to 1.90, and
the max queue drops from 4 to 2. Round robin can send a request to a busy replica while another
sits idle, because it counts its turn without looking at the replicas' state.

The work-per-replica column carries a reversed result: least connections' work distribution is
**more unbalanced** (589/567/523 versus 545/581/553). The rule is not trying to equalize work; it
equalizes the number of pending requests. Wherever the expensive requests land, the total work
piles up there. **Least connections is a waiting rule, not a fairness rule.**

The second scenario widens the gap. When one replica slows down threefold, round robin keeps
sending it a third of the requests: average wait climbs to 170.97 rounds, the max queue to 63, and
the finish round to 1635. Under least connections at the same slowdown, wait is 56.99, the queue
is 18, and the finish round is 843 — all three roughly three times lower. The rule sees the slow
replica's queue lengthen and, because of it, sends fewer requests there. This is the failure mode
the health check does not catch: the replica is responding, just slowly, so it is never dropped
from the pool.

#### The Same Key, Always the Same Replica

The third rule answers a different question. If each replica keeps a local cache, having the same
tracking number always land on the same replica serves that cache; if the number lands on a
different replica every time, the same record ends up held in as many places as there are
replicas. A mapping from key to replica is needed, and its simplest form is the remainder of the
key's hash divided by the replica count.

The hash function was built in the Hash Tables lesson and is not re-derived here; below it is used
only as a tool. What is measured is **how many keys move when the node count changes**. The
numbers are deterministic and machine-independent, so this falls in the **computation** class, not
the measurement class.

```js
// alg/hash.mjs — comparing modulo hashing with consistent hashing: the fraction of keys that
// move when the replica count changes, distribution by virtual node count, and the C01 effect
const KEYS = 100_000;              // number of tracking numbers
const READ = 416.67;               // C01 Back-of-the-Envelope Estimation: peak read requests/s
const WRITE = 97.22;               // C01: peak write requests/s
const HIT = 0.90;                  // C01 assumption V9: fraction served from cache
const BASE_STORE = 138.89;         // C01: requests/s reaching the store
const WARMUP_S = 60;               // assumption W5: cache warmup window

function hash(text) {              // FNV-1a plus a final mix step; avalanche effect defined in
  let h = 2166136261;               // the Hash Tables lesson: similar keys must give distant values
  for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 16777619) >>> 0;
  h ^= h >>> 15; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13;
  return h >>> 0;
}

const keys = Array.from({ length: KEYS }, (_, i) => `G${1_000_000 + i}`);

const modulo = (a, replicas) => replicas[hash(a) % replicas.length];

function ring(replicas, virtual) {  // virtual node positions per replica, sorted by position
  const h = [];
  for (const k of replicas) for (let v = 0; v < virtual; v += 1) h.push([hash(`${k}#${v}`), k]);
  return h.sort((x, y) => x[0] - y[0]);
}
function onRing(a, h) {             // first position after the key's hash, wrapping to the start
  const c = hash(a);
  let lo = 0, hi = h.length - 1;
  if (c > h[hi][0]) return h[0][1];
  while (lo < hi) { const m = (lo + hi) >> 1; if (h[m][0] < c) lo = m + 1; else hi = m; }
  return h[lo][1];
}

const imbalance = (placement, replicas) => {
  const s = new Map(replicas.map((k) => [k, 0]));
  for (const k of placement) s.set(k, s.get(k) + 1);
  return (Math.max(...s.values()) / keys.length) * replicas.length;
};
const moved = (a, b) => a.filter((k, i) => k !== b[i]).length / a.length;

const THREE = ["k1", "k2", "k3"], FOUR = [...THREE, "k4"];
const result = {};

console.log("rule                           3 replicas imbalance   4 replicas imbalance  3->4 moved  4->3 moved");
{
  const u = keys.map((a) => modulo(a, THREE));
  const d = keys.map((a) => modulo(a, FOUR));
  result["modulo hashing"] = moved(u, d);
  console.log(`${"modulo hashing".padEnd(29)} ${imbalance(u, THREE).toFixed(3).padStart(21)}  ` +
    `${imbalance(d, FOUR).toFixed(3).padStart(21)}  ${moved(u, d).toFixed(4).padStart(10)}  ` +
    `${moved(d, u).toFixed(4).padStart(10)}`);
}
for (const virtual of [1, 10, 100, 1000]) {
  const hu = ring(THREE, virtual), hd = ring(FOUR, virtual);
  const u = keys.map((a) => onRing(a, hu));
  const d = keys.map((a) => onRing(a, hd));
  if (virtual === 1000) result["consistent hashing"] = moved(u, d);
  console.log(`${`consistent hashing (v=${virtual})`.padEnd(29)} ${imbalance(u, THREE).toFixed(3).padStart(21)}  ` +
    `${imbalance(d, FOUR).toFixed(3).padStart(21)}  ${moved(u, d).toFixed(4).padStart(10)}  ` +
    `${moved(d, u).toFixed(4).padStart(10)}`);
}

console.log(`\nimbalance under perfect distribution 1.000, perfect-move 3->4 ratio = ${(1 / 4).toFixed(4)}\n`);
console.log("rule                   moved   misses during warmup   reads behind cache/s   requests reaching store/s   x base   extra store requests");
for (const [name, ratio] of Object.entries(result)) {
  const miss = ratio + (1 - ratio) * (1 - HIT);
  const behind = READ * miss, store = behind + WRITE;
  console.log(`${name.padEnd(20)}${ratio.toFixed(4).padStart(8)}  ${miss.toFixed(4).padStart(21)}  ` +
    `${behind.toFixed(2).padStart(21)}  ${store.toFixed(2).padStart(26)}  ` +
    `${(store / BASE_STORE).toFixed(2).padStart(7)}  ${((store - BASE_STORE) * WARMUP_S).toFixed(0).padStart(21)}`);
}
console.log(`base (no warmup): reads behind cache ${(READ * (1 - HIT)).toFixed(2)} req/s, ` +
  `reaching store ${BASE_STORE} req/s`);
```

```
rule                           3 replicas imbalance   4 replicas imbalance  3->4 moved  4->3 moved
modulo hashing                                1.008                  1.004      0.7485      0.7485
consistent hashing (v=1)                      2.021                  2.231      0.5577      0.5577
consistent hashing (v=10)                     1.548                  1.722      0.1557      0.1557
consistent hashing (v=100)                    1.122                  1.204      0.2563      0.2563
consistent hashing (v=1000)                   1.010                  1.059      0.2648      0.2648

imbalance under perfect distribution 1.000, perfect-move 3->4 ratio = 0.2500

rule                   moved   misses during warmup   reads behind cache/s   requests reaching store/s   x base   extra store requests
modulo hashing        0.7485                 0.7736                 322.35                      419.57     3.02                  16841
consistent hashing    0.2648                 0.3383                 140.96                      238.18     1.71                   5957
base (no warmup): reads behind cache 41.67 req/s, reaching store 138.89 req/s
```

#### Two Metrics That Do Not Improve Together

The first table carries two separate metrics, and the rules rank in opposite order on each.

**Distribution.** Modulo hashing splits keys almost perfectly: imbalance 1.008 and 1.004.
Consistent hashing with a single virtual node distributes very poorly — 2.021 at three replicas,
2.231 at four, meaning one replica carries more than twice a perfect share. Because ring positions
fall randomly, the gaps between them are not equal length. As the virtual node count rises, the
number of gaps rises and distribution improves: 1.122 and 1.204 at 100, 1.010 and 1.059 at 1000.
**Virtual node count is consistent hashing's distribution knob**, and its cost is the ring's size:
four replicas and 1000 virtual nodes means 4000 positions.

**Movement.** The ranking reverses. Moving from three replicas to four, modulo hashing moves
74.85 percent of the keys; the ratio that **needs** to move is 0.2500 — the new replica's share.
Modulo hashing moves three times more than it needs to, because changing the divisor changes every
remainder. Consistent hashing at 1000 virtual nodes moves 0.2648, close to the theoretical 0.2500;
with fewer virtual nodes the deviation grows in both directions at once (imbalance 2.021 at v=1,
moved 0.5577). The remove-a-node column gives the same number, because what is counted is the keys
that differ between the two placements, and the difference is the same in both directions.

What the two tables say together: modulo hashing is good at distribution and bad at stability;
consistent hashing is good at stability and buys its distribution with virtual node count.

#### Back to the Computation

The fraction of keys that move is not a design outcome by itself; the outcome emerges once it
combines with the cache. C01's V9 assumption said 90 percent of tracking queries were served from
the cache, and that ratio held the request rate reaching the store at 138.89 req/s. In a replica
set routed by key, a moved key's cache does not exist on the new replica; that key's hit ratio is
zero until the cache warms back up.

This calls for one more assumption. **W5 — cache warmup window: 60 seconds.** Rationale: the time
needed for moved keys to be queried again and re-enter the cache; this number is this topic's own
assumption and is not added to C01's table. Its sensitivity is linear — if the window doubles, the
extra store requests double too.

The last table gives the state during the warmup window as a **computation**. Adding a replica
with modulo hashing pushes the miss ratio to 0.7736: the moved 74.85 percent never hits at all, and
the remaining 25.15 percent comes with the normal 10 percent miss ratio. Reads behind the cache
climb from 41.67 req/s to 322.35 req/s, and the request rate reaching the store climbs from 138.89
to 419.57 — 3.02 times the base value. Over the sixty-second window, the store takes 16,841 extra
requests.

With consistent hashing, the same event holds the miss ratio at 0.3383: reads behind the cache are
140.96, the rate reaching the store is 238.18 req/s, 1.71 times the base, and the window adds 5,957
extra requests. The gap between the two rules is 10,884 requests, and the entire gap is the bill
for adding one replica.

The design implication is direct. Horizontal scaling happens by adding replicas; adding a replica
disrupts the cache; disrupting the cache multiplies the load reaching the store. So a system's
scalability depends not only on the replica count but on **how much moves when a replica is
added**. In a design that chose modulo hashing, the moment of growth is the system's most fragile
moment: the replica added in response to rising load piles three times the load onto the store.

#### Summary

- Round robin does not look at the replicas' state; with equal replicas, average wait was 4.04
  rounds and max queue 4, versus 1.90 rounds and 2 under least connections.
- Least connections is a waiting rule, not a fairness rule: it halved the wait while making
  work-per-replica distribution slightly more unbalanced (589/567/523 versus 545/581/553).
- When one replica slows down threefold, wait climbs to 170.97 rounds and the queue to 63 under
  round robin; 56.99 and 18 under least connections. Slowdown is the failure mode the health check
  does not catch.
- Modulo hashing is almost perfect at distribution (imbalance 1.008) but unstable: moving from
  three replicas to four moves 74.85 percent of the keys, when the needed ratio is 0.2500.
- Consistent hashing with 1000 virtual nodes moves 0.2648 and brings imbalance down to 1.010;
  virtual node count is the distribution knob, and with a single node imbalance climbs to 2.021.
- Adding one replica, the request rate reaching the store during the warmup window climbs from
  138.89 to 419.57 with modulo hashing (3.02x) and to 238.18 with consistent hashing (1.71x); over
  a 60-second window the gap between them is 10,884 requests.

#### Next Step

All three rules looked at only one thing while binding a request to a replica: turn, queue length,
or key. The decision itself was always made by the same component, the balancer. But there is
another component that does the same kind of work and was already built in earlier courses: the
**reverse proxy**, which forwards a request to one of several backend servers by a rule. The work
of the two components overlaps to a large degree; one process can take on both. The next lesson
takes up that overlap: it compares gathering the two roles into one process against splitting them
across separate processes, measured in hop count and composite service availability, and states
where the distinction actually lies — in responsibility and in location.

### Load Balancer and Reverse Proxy

Source: https://academia.sh/en/courses/traffic-layer/load-balancer-and-reverse-proxy

Explaining two components with the same program and where the distinction actually lies: a single relay program taking on both roles through its rule table, measuring what splitting the roles into separate processes versus gathering them into one changes through hop chains and the effect of killing a process, writing the distinction as responsibility and location, and billing the cost of adding a hop against the introductory course's composite availability computation.

The previous three lessons built the balancer as a routing device: it takes a request, applies a
rule, forwards it to a process, and writes the response back. The same definition was given for
another component in the Server-Side Fundamentals course. A **reverse proxy** also takes a
request, forwards it to one of several backend servers by a rule, and writes the response back.
The two definitions do not come apart.

This lesson starts by accepting the overlap and looks for the distinction somewhere else. Neither
is redefined; what is measured is what changes when the two roles are gathered into the same
process versus split across separate processes.

#### Same Program, Two Rule Tables

The shortest proof of the overlap is a single program. The relay below takes a rule table: a path
prefix, a target list. If the list has one element, the work done is **routing** — which
component. If the list holds several copies of the same component, the work done is
**distribution** — which copy. The two can sit side by side in the same table.

```js
// proxy/relay.mjs — a single relay program. The rule table describes both roles:
//   "/assets=8891;*=8850"            -> routing to separate components by path (reverse proxy)
//   "*=8892,8893,8894"               -> distributing across copies of the same component (load balancer)
//   "/assets=8891;*=8892,8893,8894"  -> both at once (a combined process)
// Usage: node proxy/relay.mjs <port> <name> <rule>
import { createServer, request } from "node:http";

const [port, name, ruleText] = [Number(process.argv[2]), process.argv[3], process.argv[4] ?? ""];
const RULES = ruleText.split(";").filter(Boolean)
  .map((p) => { const [prefix, list] = p.split("="); return { prefix, targets: list.split(",").map(Number), turn: 0 }; });

if (Number.isInteger(port) === false || RULES.length === 0) {
  console.log("usage: node proxy/relay.mjs <port> <name> <prefix=target[,target];...>");
} else createServer((clientReq, clientRes) => {
  clientRes.sendDate = false;
  const path = new URL(clientReq.url, "http://local").pathname;
  const rule = RULES.find((r) => r.prefix !== "*" && path.startsWith(r.prefix)) ?? RULES.find((r) => r.prefix === "*");
  if (rule === undefined) { clientRes.writeHead(404).end("no rule"); return; }
  const target = rule.targets[rule.turn++ % rule.targets.length];
  const previous = clientReq.headers["x-hop"];
  const backendReq = request({ port: target, path: clientReq.url, method: clientReq.method,
    headers: { ...clientReq.headers, "x-hop": previous === undefined ? name : `${previous} > ${name}` } },
  (backendRes) => { clientRes.writeHead(backendRes.statusCode, backendRes.headers); backendRes.pipe(clientRes); });
  backendReq.on("error", () => clientRes.writeHead(502, { "x-hop": `${name} > unreachable` }).end("none"));
  clientReq.pipe(backendReq);
}).listen(port, "127.0.0.1");
```

The processes at the end of the chain only report their names; the `x-hop` header accumulates
through the request and carries the hops the request touched.

```js
// proxy/endpoint.mjs — the process at the end of the chain: adds its name to the x-hop header
import { createServer } from "node:http";

const [port, name] = [Number(process.argv[2]), process.argv[3]];

if (Number.isInteger(port) === false) console.log("usage: node proxy/endpoint.mjs <port> <name>");
else createServer((req, res) => {
  res.sendDate = false;
  const previous = req.headers["x-hop"];
  res.writeHead(200, { "content-type": "application/json",
    "x-hop": previous === undefined ? name : `${previous} > ${name}` });
  res.end(JSON.stringify({ state: "at transit hub", zone: "35" }));
}).listen(port, "127.0.0.1");
```

```js
// proxy/client.mjs — tries both paths, prints the touched hop chain and the answered request count
import { request } from "node:http";

const [port, label] = [Number(process.argv[2]), process.argv[3] ?? "-"];
const PATHS = ["/assets/style.css", "/tracking"];
const COUNT = 6;

function one(path) {
  return new Promise((resolve) => {
    const r = request({ port, path }, (res) => {
      res.resume();
      res.on("end", () => resolve({ code: res.statusCode, hop: res.headers["x-hop"] ?? "-" }));
    });
    r.on("error", () => resolve({ code: 0, hop: "no connection" }));
    r.end();
  });
}

if (Number.isInteger(port) === false) console.log("usage: node proxy/client.mjs <port> <label>");
else for (const path of PATHS) {
  const chain = new Map();
  let answered = 0;
  for (let i = 0; i < COUNT; i += 1) {
    const s = await one(path);
    if (s.code === 200) answered += 1;
    chain.set(s.hop, (chain.get(s.hop) ?? 0) + 1);
  }
  const d = [...chain].sort().map(([a, n]) => `${a} (${n})`).join("  |  ");
  console.log(`${label.padEnd(22)} ${path.padEnd(17)} ${String(answered)}/${COUNT}  ${d}`);
}
```

The setup runs the same job under two layouts. In the **separate** layout there are two relay
processes: `rp` splits the content path from the application path, `lb` distributes across the
copies. In the **combined** layout, a single `rplb` process does both. In each layout, the process
holding the replica list is killed and which path stays up is counted.

```bash
# measure.sh — the same job under two layouts: two separate relay processes, then one combined process.
# In the second stage, the process holding the replica list is killed and which path stays up is counted.
node proxy/endpoint.mjs 8891 static & S=$!
node proxy/endpoint.mjs 8892 k1 & K1=$!
node proxy/endpoint.mjs 8893 k2 & K2=$!
node proxy/endpoint.mjs 8894 k3 & K3=$!

node proxy/relay.mjs 8860 rp "/assets=8891;*=8850" & RP=$!
node proxy/relay.mjs 8850 lb "*=8892,8893,8894" & LB=$!
sleep 1
node proxy/client.mjs 8860 "separate: rp + lb"
kill $LB; sleep 0.5
node proxy/client.mjs 8860 "separate: lb killed"
kill $RP; sleep 0.3

node proxy/relay.mjs 8861 rplb "/assets=8891;*=8892,8893,8894" & COMBINED=$!
sleep 1
node proxy/client.mjs 8861 "combined: rplb"
kill $COMBINED; sleep 0.5
node proxy/client.mjs 8861 "combined: killed"

kill $S $K1 $K2 $K3
```

```
separate: rp + lb      /assets/style.css 6/6  rp > static (6)
separate: rp + lb      /tracking         6/6  rp > lb > k1 (2)  |  rp > lb > k2 (2)  |  rp > lb > k3 (2)
separate: lb killed    /assets/style.css 6/6  rp > static (6)
separate: lb killed    /tracking         0/6  rp > unreachable (6)
combined: rplb         /assets/style.css 6/6  rplb > static (6)
combined: rplb         /tracking         6/6  rplb > k1 (2)  |  rplb > k2 (2)  |  rplb > k3 (2)
combined: killed       /assets/style.css 0/6  no connection (6)
combined: killed       /tracking         0/6  no connection (6)
```

#### Two Numbers, Two Directions

These numbers are in the **measurement** class and are deterministic. The two layouts rank in
opposite order on two separate columns.

**Hop count.** In the separate layout, a tracking request touches three hops: `rp > lb > k1`. In
the combined layout, two hops: `rplb > k1`. The content path is two hops in both. Every hop means a
network jump, a parse, and a rewrite; the separate layout puts the same request through one extra
pair of hands.

**Isolation.** When the process holding the replica list is killed, the content path in the
separate layout keeps answering 6/6, and only the tracking path drops to 0/6 — and even that drop
comes back through `rp` as a 502, meaning the client gets a response. In the combined layout both
drop to 0/6 and the client gets no response at all, because there is no port left to connect to. A
change to the replica list is a configuration change and a common one; in the combined process,
that change also stops the content path.

The two numbers are two faces of the same decision: **splitting adds a hop, merging adds a failure
surface.**

#### Where the Distinction Lies

Since the same program can take on both roles, the distinction is not in the program. It is in two
other places.

**Responsibility.** The reverse proxy answers "which component": a path starting with `/assets`
goes to the content process, everything else to the application. The prefixes in the rule table
point to components doing different jobs, and their target lists have one element. The load
balancer answers "which copy": its target list is made of processes doing the same job, and which
one gets picked does not change the outcome of the request. In the first question, a wrong choice
is **wrong behavior**; in the second, it is only a different copy.

**Location.** The reverse proxy sits at the application boundary: it presents the system's outer
face as a single path space and hides the component split behind it. The load balancer sits at the
replica boundary: it hides how many copies a component runs on. The two forms of hiding operate at
different scales, which is why the two can sit at different points in the chain — the `rp > lb`
order in the measurement is an expression of that; the reverse order would not make sense.

A single process taking on both does not remove this distinction, it only writes both into the
same configuration file. What keeps the distinction visible is the shape of the rule table: which
row's prefix is a component split, and which row's target list is a set of copies.

#### Back to the Computation

The bill for splitting showed up in the hop count; that number's counterpart in service
availability was already established in C01. The Availability in Numbers lesson took the tracking
path as a four-component series chain and, at 99.9 percent per component, found a composite
availability of 99.6006 percent and a monthly outage of 172.5 minutes. The relay processes are new
components added to that chain.

```js
// proxy/chain.mjs — the cost of adding a hop in the C01 composite availability calculation
const MONTH = 30 * 24 * 60;        // minutes
const BASE = 4;                    // C01 Availability in Numbers: four-component tracking path
const COMPONENT = 0.999;           // C01 assumption: service availability per component

const series = (a, n) => a ** n;
const replicated = (a, k) => 1 - (1 - a) ** k;
const minutes = (a) => (1 - a) * MONTH;

console.log("layout                   hops  chain components  composite availability  monthly outage  vs base");
const base = series(COMPONENT, BASE);
for (const [name, hops, replicas] of [
  ["no hops (C01)", 0, 1], ["combined process", 1, 1], ["two separate processes", 2, 1],
  ["separate, 2 replicas each", 2, 2], ["combined, 2 replicas", 1, 2]]) {
  const a = base * series(replicated(COMPONENT, replicas), hops);
  console.log(`${name.padEnd(26)}${String(hops).padStart(6)}${String(BASE + hops).padStart(17)}  ` +
    `${(a * 100).toFixed(4).padStart(22)}%  ${minutes(a).toFixed(1).padStart(11)} min  ` +
    `${(minutes(a) - minutes(base)).toFixed(1).padStart(9)} min`);
}

const combined = base * series(COMPONENT, 1), separate = base * series(COMPONENT, 2);
console.log(`\nbill for splitting the roles into two processes = ${(minutes(separate) - minutes(combined)).toFixed(1)} min/month`);
console.log(`same bill when the hops have 2 replicas each = ` +
  `${(minutes(base * series(replicated(COMPONENT, 2), 2)) - minutes(base * series(replicated(COMPONENT, 2), 1))).toFixed(2)} min/month`);
console.log(`C01 monthly outage budget (99.9%) = ${((1 - 0.999) * MONTH).toFixed(1)} min; ` +
  `separate layout's outage is ${(minutes(separate) / ((1 - 0.999) * MONTH)).toFixed(2)}x the budget`);
```

```
layout                   hops  chain components  composite availability  monthly outage  vs base
no hops (C01)                  0                4                 99.6006%        172.5 min        0.0 min
combined process               1                5                 99.5010%        215.6 min       43.0 min
two separate processes         2                6                 99.4015%        258.6 min       86.0 min
separate, 2 replicas each      2                6                 99.6004%        172.6 min        0.1 min
combined, 2 replicas           1                5                 99.6005%        172.6 min        0.0 min

bill for splitting the roles into two processes = 43.0 min/month
same bill when the hops have 2 replicas each = 0.04 min/month
C01 monthly outage budget (99.9%) = 43.2 min; separate layout's outage is 5.99x the budget
```

These numbers are in the **computation** class. The first three rows give the bill for splitting:
with single-copy hops, separating the roles into two processes raises the monthly outage from
215.6 minutes to 258.6 minutes — 43.0 minutes a month. That number is nearly equal to the
**entire** monthly budget C01 set aside for the 99.9 percent target (43.2 minutes). So the decision
to separate the roles, by itself, costs about a month's outage budget.

The fourth and fifth rows show where the bill comes from. When the hops have two replicas each,
the same split drops to 0.04 minutes and the two layouts become indistinguishable. The rule from
C01's replica table holds here too: outage share is raised to a power across parallel replicas.
**The bill for adding a hop does not come from adding the hop — it comes from leaving that hop
single-copy.**

The last row closes the table with one measure. The separate layout's monthly outage is 258.6
minutes, 5.99 times the 99.9 percent budget. This does not say the layout is bad; it says a 99.9
percent target **cannot be met with 99.9 percent per component** — C01 found the same result with
four components, and adding two hops sharpens it. When an edge layer is being designed, hop count
is not an architectural preference; it is a direct availability line item.

#### Summary

- The load balancer and the reverse proxy can be written as the same program: in the rule table, a
  single-element target list means routing, and a list of copies of the same component means
  distribution.
- In the separate layout the tracking request touched three hops (`rp > lb > k1`); in the combined
  layout, two (`rplb > k1`); splitting puts the request through one extra pair of hands.
- When the process holding the replica list was killed, the content path in the separate layout
  kept answering 6/6; in the combined layout both paths dropped to 0/6 and the client got no
  response at all.
- The distinction is responsibility and location: the reverse proxy answers "which component" at
  the application boundary, the load balancer answers "which copy" at the replica boundary; a
  wrong choice is wrong behavior in the first case and only a different copy in the second.
- Separating the roles with single-copy hops raises monthly outage from 215.6 to 258.6 minutes:
  43.0 minutes a month, the entire monthly budget for the 99.9 percent target.
- With two replicas per hop the same bill drops to 0.04 minutes; the cost of adding a hop comes
  from leaving it single-copy.

#### Next Step

The five lessons so far never questioned one thing: the balancer's choice is **free**. Round
robin, least connections, and both layouts all assumed a request could go to any copy; even
consistent hashing, while it wanted the choice to stay stable, did not make it mandatory. There is
a case where the replicas stop being each other's equals: if state left over from a client's
previous request sits in a particular replica's memory, that client's next request can no longer
go to just any replica. Choice stops being a preference and becomes a **constraint**. The next
lesson names and measures that constraint: it counts how much distribution breaks down, how much
state is lost when a replica drops, and why adding a replica does not lower the peak load.

### Session Stickiness

Source: https://academia.sh/en/courses/traffic-layer/session-stickiness

Choice becoming a constraint and the scaling bill that follows: measuring how much sticky binding breaks distribution, counting the sessions lost when a replica drops and the sessions whose binding breaks when a replica is added, the busiest replica barely dropping despite the added replica, and converting the unsplittable share into an upper bound with the introductory course's serial fraction relation.

The five lessons so far never questioned one thing: the balancer's choice was free. A request
could go to any replica, because the replicas were each other's equals. Even consistent hashing,
while it wanted the choice to stay stable, did not make it mandatory — if a key landed on a
different replica, the response would still be correct, only the cache would run cold.

The picture changes if state left over from a client's previous request sits in a particular
replica's memory. That client's next request can no longer go to just any replica; choice stops
being a preference and becomes a **constraint**. This binding is called **session stickiness**.
The **sticky routing** from the Caching, Queues and Asynchronous Processing course is the same
thing: the client is always routed to the same instance. Session state itself and its management
are the Server-Side Fundamentals course's subject and are not re-derived here; this lesson's job
is to measure stickiness's **scaling bill**.

#### Measuring the Binding

The model takes a client mix and compares three rules under the same mix. Under the **non-sticky**
rule, the decision is made per request. Under **sticky-modulo**, the client identity's hash is
divided by the replica count. Under **sticky-consistent**, the binding is read from the consistent
hashing ring from the previous lesson.

The client mix carries the shape of a real parcel tracking service: one major seller integration
sends many queries from a single session, nine medium sellers send fewer, ninety minor sellers
very few. The numbers are model parameters.

```js
// sticky/model.mjs — the effect of stickiness on distribution, session loss, and scale-up.
// This is a MODEL: the client mix and request counts are model parameters.
const CLIENTS = [                  // [name prefix, client count, requests per client]
  ["major", 1, 2000], ["medium", 9, 400], ["minor", 90, 50],
];
const VIRTUAL = 200;               // consistent hashing virtual node count

function hash(text) {              // FNV-1a plus a final mix step; avalanche effect defined in
  let h = 2166136261;               // the Hash Tables lesson: similar keys must give distant values
  for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 16777619) >>> 0;
  h ^= h >>> 15; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13;
  return h >>> 0;
}

const clients = CLIENTS.flatMap(([name, count, requests]) =>
  Array.from({ length: count }, (_, i) => ({ name: `${name}-${i}`, requests })));
const TOTAL = clients.reduce((a, c) => a + c.requests, 0);

function ring(replicas) {
  const h = [];
  for (const k of replicas) for (let v = 0; v < VIRTUAL; v += 1) h.push([hash(`${k}#${v}`), k]);
  return h.sort((x, y) => x[0] - y[0]);
}
function onRing(a, h) {
  const c = hash(a);
  if (c > h[h.length - 1][0]) return h[0][1];
  let lo = 0, hi = h.length - 1;
  while (lo < hi) { const m = (lo + hi) >> 1; if (h[m][0] < c) lo = m + 1; else hi = m; }
  return h[lo][1];
}

function binding(rule, replicas) {   // client -> replica mapping (no mapping for non-sticky)
  if (rule === "sticky-modulo") return new Map(clients.map((c) => [c.name, replicas[hash(c.name) % replicas.length]]));
  if (rule === "sticky-consistent") { const h = ring(replicas); return new Map(clients.map((c) => [c.name, onRing(c.name, h)])); }
  return null;
}

function load(rule, replicas) {      // requests per replica
  const y = new Map(replicas.map((k) => [k, 0]));
  const b = binding(rule, replicas);
  if (b === null) {                  // per-request round robin: each request decided separately
    let s = 0;
    for (const c of clients) for (let i = 0; i < c.requests; i += 1) { const k = replicas[s++ % replicas.length]; y.set(k, y.get(k) + 1); }
  } else for (const c of clients) y.set(b.get(c.name), y.get(b.get(c.name)) + c.requests);
  return y;
}

const imbalance = (y, n) => (Math.max(...y.values()) / TOTAL) * n;
const THREE = ["k1", "k2", "k3"], FOUR = [...THREE, "k4"];

console.log(`${clients.length} clients, ${TOTAL} requests; largest client's share = ` +
  `${(Math.max(...clients.map((c) => c.requests)) / TOTAL).toFixed(4)}\n`);
console.log("rule               3 replicas load        imbalance   4 replicas load             imbalance   busiest replica drop");
for (const rule of ["non-sticky", "sticky-modulo", "sticky-consistent"]) {
  const u = load(rule, THREE), d = load(rule, FOUR);
  const pu = Math.max(...u.values()) / TOTAL, pd = Math.max(...d.values()) / TOTAL;
  console.log(`${rule.padEnd(18)} ${[...u.values()].join("/").padEnd(20)} ${imbalance(u, 3).toFixed(3).padStart(11)}   ` +
    `${[...d.values()].join("/").padEnd(25)} ${imbalance(d, 4).toFixed(3).padStart(11)}   ` +
    `${(1 - pd / pu).toFixed(4).padStart(21)}`);
}

console.log("\nclients whose binding changes when a replica is added, and their request share:");
for (const rule of ["sticky-modulo", "sticky-consistent"]) {
  const bu = binding(rule, THREE), bd = binding(rule, FOUR);
  const moved = clients.filter((c) => bu.get(c.name) !== bd.get(c.name));
  console.log(`  ${rule.padEnd(18)} clients ${String(moved.length).padStart(3)}/${clients.length}  ` +
    `request share ${(moved.reduce((a, c) => a + c.requests, 0) / TOTAL).toFixed(4)}`);
}

console.log("\nsessions lost when a replica drops, and that replica's request share (3 replicas):");
for (const rule of ["non-sticky", "sticky-modulo", "sticky-consistent"]) {
  const b = binding(rule, THREE);
  if (b === null) { console.log(`  ${"non-sticky".padEnd(18)} sessions   0/${clients.length}  request share 0.0000 (no state sits on the replica)`); continue; }
  const dropped = clients.filter((c) => b.get(c.name) === "k1");
  console.log(`  ${rule.padEnd(18)} sessions ${String(dropped.length).padStart(3)}/${clients.length}  ` +
    `request share ${(dropped.reduce((a, c) => a + c.requests, 0) / TOTAL).toFixed(4)}`);
}
```

```
100 clients, 10100 requests; largest client's share = 0.1980

rule               3 replicas load        imbalance   4 replicas load             imbalance   busiest replica drop
non-sticky         3367/3367/3366             1.000   2525/2525/2525/2525             1.000                  0.2501
sticky-modulo      2700/2250/5150             1.530   4450/2400/1800/1450             1.762                  0.1359
sticky-consistent  2200/3150/4750             1.411   1250/2850/4300/1700             1.703                  0.0947

clients whose binding changes when a replica is added, and their request share:
  sticky-modulo      clients  70/100  request share 0.7475
  sticky-consistent  clients  27/100  request share 0.1683

sessions lost when a replica drops, and that replica's request share (3 replicas):
  non-sticky         sessions   0/100  request share 0.0000 (no state sits on the replica)
  sticky-modulo      sessions  40/100  request share 0.2673
  sticky-consistent  sessions  30/100  request share 0.2178
```

#### Three Separate Bills

The tables show stickiness being paid for in three separate items.

**Distribution.** Under the non-sticky rule, imbalance is 1.000 — requests split by count and
client identity plays no part. Under sticky-modulo it is 1.530, under sticky-consistent 1.411. The
deviation's source is that clients do not send equal request counts: the major seller's 2000
requests land on a single replica, and the balancer has no authority to correct that. This is the
same connection skew as the second lesson, except there the binding was a connection's lifetime,
here it is a session.

**Replica drop.** Under the non-sticky rule, a dropped replica carries no session at all; requests
route to the remaining replicas and nothing is lost. Under the sticky rules, sessions bound to the
dropped replica drop too: under sticky-consistent, 30 of 100 sessions and 21.78 percent of
requests. The health check from the first lesson does not reduce this loss — dropping a replica
from the pool gets requests answered, but it does not bring back the sessions that lived in that
replica's memory. The health check's 34.26-request window does not apply here; the loss is
measured in sessions, not a window.

**Replica addition.** The third table is stickiness's most insidious item. Under sticky-modulo,
when a fourth replica is added, 70 of 100 clients bind to a different replica, and those clients
carry 74.75 percent of the total requests — scaling up breaks nearly three-quarters of the load's
session bindings. Under sticky-consistent the ratio drops to 27 clients and 16.83 percent — the
previous lesson's movement result repeats here in terms of sessions.

The column at the end of the same row gives the actual result. When the fourth replica is added,
the busiest replica's load drops 25.01 percent under the non-sticky rule — the figure a perfect
split gives. Under the sticky rules the drop is 13.59 percent and 9.47 percent. **Adding a replica
does not lower the peak load**, because the large session that creates the busiest replica's load
stays put. Sticky-consistent being better at preserving sessions works against it here: keeping
the binding means not moving the load.

#### Back to the Computation

This behavior's name was already set in C01. The Horizontal and Vertical Scaling lesson named the
unsplittable part of a job the **serial fraction** and wrote the best possible speedup with $n$
units as:

$$
S(n) = \frac{1}{s + \dfrac{1 - s}{n}}
$$

Stickiness's counterpart to the serial fraction is direct: **a single session cannot be split.**
The largest session's ratio to the total load stays on the busiest replica no matter how many
replicas are added.

```js
// sticky/scale.mjs — converting stickiness to a serial fraction and its counterpart in C01's peak rate
const CLIENTS = [["major", 1, 2000], ["medium", 9, 400], ["minor", 90, 50]];   // same as model.mjs
const PEAK_EDGE = 513.89;          // C01 Back-of-the-Envelope Estimation: peak edge requests/s
const SATURATION = 400;            // lesson 01 assumption Y1
const SAFE = 200;                  // lesson 01: Y1 x Y2
const MEASURED = { 3: 1.411, 4: 1.703 };   // model.mjs output: sticky-consistent imbalance

const total = CLIENTS.reduce((a, [, count, req]) => a + count * req, 0);
const s = Math.max(...CLIENTS.map(([, , req]) => req)) / total;   // the part that cannot be split

const amdahl = (n) => 1 / (s + (1 - s) / n);   // C01 Horizontal and Vertical Scaling lesson's relation
console.log(`total requests = ${total}, largest session's share (serial fraction s) = ${s.toFixed(4)}`);
console.log(`upper bound 1/s = ${(1 / s).toFixed(2)}: no matter the replica count, peak load cannot be split beyond this factor\n`);

console.log("replicas  non-sticky speedup  sticky speedup  busiest replica lower bound req/s  utilization now");
for (const n of [3, 4, 8, 16, 64]) {
  const lowerBound = PEAK_EDGE / amdahl(n);
  console.log(`${String(n).padStart(8)}  ${String(n).padStart(18)}  ${amdahl(n).toFixed(2).padStart(14)}  ` +
    `${lowerBound.toFixed(2).padStart(34)}  ${(lowerBound / SATURATION).toFixed(3).padStart(15)}`);
}
console.log(`${"infinite".padStart(8)}  ${"infinite".padStart(18)}  ${(1 / s).toFixed(2).padStart(14)}  ` +
  `${(PEAK_EDGE * s).toFixed(2).padStart(34)}  ${((PEAK_EDGE * s) / SATURATION).toFixed(3).padStart(15)}\n`);

console.log("replicas  measured imbalance  busiest replica req/s  utilization  replicas needed");
for (const [n, d] of Object.entries(MEASURED)) {
  const rate = (d * PEAK_EDGE) / n;
  console.log(`${n.padStart(8)}  ${d.toFixed(3).padStart(18)}  ${rate.toFixed(2).padStart(22)}  ` +
    `${(rate / SATURATION).toFixed(3).padStart(11)}  ${String(Math.ceil((d * PEAK_EDGE) / SAFE)).padStart(16)}`);
}
console.log(`\nsensitivity: if the largest session were 10% of the total, the upper bound would be ${(1 / 0.10).toFixed(2)}; ` +
  `at 30% it would be ${(1 / 0.30).toFixed(2)}`);
```

```
total requests = 10100, largest session's share (serial fraction s) = 0.1980
upper bound 1/s = 5.05: no matter the replica count, peak load cannot be split beyond this factor

replicas  non-sticky speedup  sticky speedup  busiest replica lower bound req/s  utilization now
       3                   3            2.15                              239.14            0.598
       4                   4            2.51                              204.79            0.512
       8                   8            3.35                              153.28            0.383
      16                  16            4.03                              127.52            0.319
      64                  64            4.75                              108.20            0.270
infinite            infinite            5.05                              101.76            0.254

replicas  measured imbalance  busiest replica req/s  utilization  replicas needed
       3               1.411                  241.70        0.604                 4
       4               1.703                  218.79        0.547                 5

sensitivity: if the largest session were 10% of the total, the upper bound would be 10.00; at 30% it would be 3.33
```

These numbers are in the **computation** class. The serial fraction is 0.1980 and the upper bound
is 5.05: even if the replica count goes to infinity, the peak load cannot be split more than
fivefold. The gap widens next to the non-sticky column — at 16 replicas the non-sticky rule splits
the load 16-fold, the sticky rule 4.03-fold. Most of the extra replicas added do nothing for the
busiest replica.

The utilization column ties this to a threshold. The first lesson chose a target utilization of
0.50 per replica (Y2). Under the sticky regime, three replicas do not hold that target: the
busiest replica carries at least 239.14 req/s and 0.598 utilization. At four replicas, 204.79 and
0.512 — still above target. Reaching below target takes eight replicas, and at that point eight
replicas do the work of three. **Stickiness more than doubles the replica count needed, and no
capacity arrives in return for the increase.**

The second table runs the same computation with the measured imbalance. At three replicas the
busiest replica is 241.70 req/s and 0.604 utilization; at four, 218.79 and 0.547. Adding one
replica lowers the busiest replica's load by only 9.5 percent. Holding the imbalance measured at
three replicas against the safe per-replica rate implies 4 replicas are actually needed; the
imbalance measured at four implies 5. The point of comparison is the first lesson's number:
without stickiness 3 replicas were enough; the second lesson's connection skew raised that to 5;
here it comes out to 4 or 5 — the session binding's own share.

The result is this layer's counterpart to C01's horizontal-scaling rule. That lesson said
horizontal scaling does not happen just by increasing the number of units, but because the job can
be split in a way that requires no state shared between units, and it measured index partitioning's
serial fraction at 28.0 percent. Session stickiness is the traffic layer's version of the same
statement: as long as state stays in a replica's memory, the balancer cannot split the load no
matter how smart its rule is. Removing stickiness is not a balancing decision — it is a decision to
move the state out of the process.

#### Summary

- Session stickiness binds a client's requests to a particular replica and turns the distribution
  choice into a constraint; sticky routing from the Caching, Queues and Asynchronous Processing
  course is the same binding.
- Under the same client mix, imbalance came out to 1.000 for non-sticky, 1.530 for sticky-modulo,
  and 1.411 for sticky-consistent; the deviation's source is that clients do not send equal request
  counts.
- When a replica drops, sticky-consistent loses 30 of 100 sessions and 21.78 percent of requests;
  the health check saves the request, not the session.
- When a fourth replica is added, the busiest replica's load drops 25.01 percent under non-sticky,
  13.59 percent under sticky-modulo, and 9.47 percent under sticky-consistent; keeping the binding
  means not moving the load.
- The largest session's share, 0.1980, is a serial fraction and holds the scaling upper bound at
  5.05: at 16 replicas the non-sticky rule splits the load 16-fold, the sticky rule 4.03-fold.
- The 0.50 utilization target set by Y2 does not hold under the sticky regime at three replicas
  (0.598) or at four (0.512); the measured imbalance implies 4 replicas are needed at the
  three-replica reading, versus 3 without stickiness.

#### Next Step

This topic settled which replica a request lands on. The balancer is a routing device: it makes
its decision at the connection level or the content level, picks its rule by turn, queue length, or
key, drops a failed replica from the pool through the health check, and loses its power to choose
once state stays in a replica's memory. The work piling up at the edge does not end there. The same
point authenticates identity, applies rate limits, translates into the shape a client expects, and
gathers responses from several services to satisfy a single user request. Whether this work belongs
to the balancer, to the application, or to a separate layer was left open — the third option has a
name, and the next topic starts with it: what a gateway, where edge responsibilities gather, should
take on, and what it should not.

## Gateway Patterns

Placing the responsibilities gathered at the edge: what the API gateway does and must not do, aggregating multiple service calls into one request, offloading common work to the edge, building a client-specific interface, separating concerns with helper processes, and isolating the validation layer.

### API Gateway

Source: https://academia.sh/en/courses/traffic-layer/api-gateway

Placing the responsibilities that pile up at the edge: which decision separates the API gateway from the load balancer and the reverse proxy, measuring the three layouts of five edge responsibilities by repeated lines and units regulated, and calculating which component collects the edge's peak request rate into a single point.

The previous topic settled which replica a request lands on. The load balancer is a routing
device: it decides at the connection level or by the request's content, drops a failed replica
through the health check, and loses its power to choose once state stays in a replica's memory —
session stickiness priced this as the needed replica count rising from 3 to 4. The decision's
subject was always the same: which peer replica.

The work piling up at the edge goes further. Before a tracking request reaches the application,
its identity may need verifying, its rate limiting, its body translating into the shape the
service expects, and responses from several services gathering into one body. None of these is
the "which replica" question, and each can be placed in one of three spots: the balancer, the
application, or a separate layer. This lesson names the third option and reduces the question to
a single layout decision: at each service, in one component at the edge, or split between the
two.

#### Which Decision Does the Gateway Make

The **API gateway** is a system's single externally facing entry point, and it decides at the
*transaction* level: which service an incoming request belongs to, in what shape it forwards, and
which checks it must have already passed. To make this decision the gateway carries a **service
map** — the mapping of paths to services.

This is not the load balancer's decision. The balancer picks one among replicas doing the same
work; it need not know what each service does, and it assumes its pool is made of peers. The
reverse proxy, in the form built in the Server-Side Fundamentals course, is the process that takes
a request and forwards it to the server behind it; TLS termination and static file serving were
handled there. The three names often converge on the same process, which is not a contradiction:
the distinction is not the process count but the decision made. For a request, "which replica" is
the balancer's question, "which service and which shape" is the gateway's.

This distinction also sets what the gateway must **not** do. When the billing context's tariff
rule or the delivery operations context's route rule is placed on the gateway, the edge starts
knowing both contexts' domain rules at once, and every change to either context forces a
redeploy. Work that belongs at the edge is work decidable from the request's **metadata**; work
that requires looking at the shipment itself belongs to the service. The topic's third lesson
turns this criterion into a mechanical rule.

#### Five Responsibilities, Three Layouts

The measurement uses an in-process model: there is no real gateway product, separate process, or
network. The model holds three services (tracking, event, fee — the read, write, and batch flows
respectively) and five edge responsibilities as a data structure. Token validation was built in
the Authentication and Authorization course, rate limiting in the Asynchronous Processing course; neither's
algorithm is revisited here, only its **placement**.

```js
// edge/layout.mjs — three services, five edge responsibilities, three layouts; in-process model
export const SERVICE = { tracking: "read", event: "write", fee: "batch" };

// How many lines each responsibility's implementation takes (model value, written once)
export const RESPONSIBILITY = {
  "route matching": 7,
  "token validation": 12,
  "rate limiting": 9,
  "request logging": 6,
  "format translation": 11,
};

export const LAYOUT = {
  "at each service": { gateway: [], service: Object.keys(RESPONSIBILITY) },
  "at the gateway": { gateway: Object.keys(RESPONSIBILITY), service: [] },
  hybrid: {
    gateway: ["route matching", "rate limiting", "request logging", "token validation"],
    service: ["token validation", "format translation"],
  },
};

export function measure(y) {
  const serviceCount = Object.keys(SERVICE).length;
  const total = (list) => list.reduce((t, s) => t + RESPONSIBILITY[s], 0);
  const hasGateway = y.gateway.length > 0;
  return {
    "addresses the client knows": hasGateway ? 1 : serviceCount,
    "lines collected at the edge": total(y.gateway),
    "lines repeated across services": serviceCount * total(y.service),
    "units carrying the responsibility (average)":
      Number((Object.keys(RESPONSIBILITY).reduce((t, s) =>
        t + (y.gateway.includes(s) ? 1 : 0) + (y.service.includes(s) ? serviceCount : 0), 0)
        / Object.keys(RESPONSIBILITY).length).toFixed(2)),
    "hops a request touches": hasGateway ? 2 : 1,
    "flows the gateway touches": hasGateway ? serviceCount : 0,
    "flows answered when gateway stops": `${hasGateway ? 0 : serviceCount}/${serviceCount}`,
  };
}
```

The line counts are the model's input and belong to the **assumption** class: they were not
measured from an implementation, they only carry an order of magnitude. What is read is not the
total itself but the difference multiplying it by three produces; even if the five numbers were
cut in half, the ratio of lines repeated to lines collected at the edge would stay the same.

In the third layout, `token validation` appears in both lists at once. This is not a mistake, it
is a deliberate choice: the gateway validates the token, and the service validates it too, and
the second validation covers the case where the gateway is bypassed. The count shows this as a
price paid.

```js
// edge/measure.mjs — measures the three layouts with the same counters and ties them to the K01 calculation
import { LAYOUT, measure } from "./layout.mjs";

const FIELD = ["addresses the client knows", "lines collected at the edge",
  "lines repeated across services", "units carrying the responsibility (average)",
  "hops a request touches", "flows the gateway touches",
  "flows answered when gateway stops"];

const measurement = Object.entries(LAYOUT).map(([name, y]) => ({ name, ...measure(y) }));
console.log(`${"measure".padEnd(46)}${measurement.map((o) => o.name.padStart(18)).join("")}`);
for (const f of FIELD) {
  console.log(`${f.padEnd(46)}${measurement.map((o) => String(o[f]).padStart(18)).join("")}`);
}

// K01 Back-of-the-Envelope Estimation lesson's numbers (computed-value class, taken from there)
const K01 = { readPeak: 416.67, writePeak: 97.22, edgePeak: 513.89 };
const write = (name, value, note = "") => console.log(`${name.padEnd(37)}${value.padStart(10)}  ${note}`);

console.log("");
write("K01 — peak requests/s at the edge", K01.edgePeak.toFixed(2), "read 416.67 + write 97.22");
write("no gateway — heaviest component", K01.readPeak.toFixed(2), "tracking service");
write("with gateway — heaviest component", K01.edgePeak.toFixed(2),
  `gateway, x${(K01.edgePeak / K01.readPeak).toFixed(2)}`);
for (const o of measurement) {
  write(`hop-requests/s — ${o.name}`,
    (K01.edgePeak * o["hops a request touches"]).toFixed(2),
    `hop ${o["hops a request touches"]}`);
}
```

```
measure                                          at each service    at the gateway            hybrid
addresses the client knows                                     3                 1                 1
lines collected at the edge                                    0                45                34
lines repeated across services                               135                 0                69
units carrying the responsibility (average)                    3                 1                 2
hops a request touches                                         1                 2                 2
flows the gateway touches                                      0                 3                 3
flows answered when gateway stops                            3/3               0/3               0/3

K01 — peak requests/s at the edge        513.89  read 416.67 + write 97.22
no gateway — heaviest component          416.67  tracking service
with gateway — heaviest component        513.89  gateway, x1.23
hop-requests/s — at each service         513.89  hop 1
hop-requests/s — at the gateway         1027.78  hop 2
hop-requests/s — hybrid                 1027.78  hop 2
```

#### Reading the Numbers

The table's first three rows say what the gateway is built for. Without a gateway the client
must know three addresses, and five responsibilities take up 135 lines across three services;
with the gateway, addresses drop to 1 and those lines drop to 45 at the edge. The line count
carrying the same work does not fall to a third — it rises from zero to 45 while 135 is zeroed
out. The difference is removed repetition: the average units carrying a responsibility falls from
3 to 1, so when the rate-limiting rule changes, 1 unit needs updating instead of 3.

The next three rows give the cost. Every request now touches two hops; even with the peak edge
rate held constant, hop-requests processed per second rises from 513.89 to 1027.78 — exactly
double. This is not latency — no duration was measured — but it shows where latency will grow
from: every request now enters one extra component's queue.

The last row is the most expensive. Flows answered when the gateway stops drop from 3/3 to 0/3. In
the gateway-free layout, one service going down drops only that flow; with a gateway, a single
component drops all three at once. The hybrid layout does not fix this: validating the token in two
places covers the gateway being bypassed, not the gateway itself stopping. Its real gain sits in
the middle — lines repeated 69 instead of 135, lines collected at the edge 34 instead of 45 — and
so does its cost: average units per responsibility is 2, so token validation is still watched at
both the gateway and the service.

#### Back to the Calculation

Under the course's rule, this decision's counterpart in the K01 calculation gets written down.
K01's Back-of-the-Envelope Estimation lesson put the peak edge request rate at 513.89 per second
(416.67 read + 97.22 write), **spread across three separate endpoints**; that lesson's heaviest
edge was tracking, at 416.67 requests/s. Once the gateway is in place, this spread disappears: a
single component carries 513.89 requests/s, 1.23 times the former heaviest component.

More importantly, the flows the gateway touches become 3. In the What Is System Design lesson,
this number was 3 for `shipment-store` alone, and that lesson left a rule: a design's most
fragile point is the component the most flows touch. With the gateway in place, the system has
two such components — one in the data layer, one at the edge. For the edge, this is the same
fact as `flows answered when gateway stops` reading 0/3.

What the calculation leaves unchanged matters too: `reads behind cache/s` at 41.67, `read egress
Mbit/s` at 1.60, and daily data growth of 976 MB do not move. In the form built here, the gateway
does not change the body per request; it only changes hop count and responsibility placement. The
decisions that change the body are measured in the next two lessons.

#### Summary

- The API gateway decides at the transaction level and carries a service map; the load balancer
  picks among peer replicas, the reverse proxy forwards the request. The three can converge in one
  process; the distinction is the decision made.
- Work that belongs at the edge is decidable from the request's metadata; a domain rule requiring
  the shipment itself stays at the service.
- Five responsibilities repeat as 135 lines across three services but drop to 45 at the edge;
  average units carrying a responsibility falls from 3 to 1.
- Numbers paid: every request touches 2 hops instead of 1 (hop-requests/s 513.89 → 1027.78), and
  flows answered when the gateway stops drop 3/3 → 0/3.
- The hybrid layout sits in the middle: 69 lines repeated, 34 collected at the edge, but average
  units per responsibility is 2 and the 0/3 row does not improve.
- Back to K01: 513.89 requests/s at the edge collects from a three-edge spread into one component
  (1.23 times the former heaviest), and flows the gateway touches becomes 3.

#### Next Step

This lesson used the gateway's service map as an assumption: paths were said to map to services,
but the mapping itself was not measured. The mapping does two separate jobs. The first is
routing — an incoming path translates to a single service, and the client never sees the split
behind it. The second does more: if a tracking page wants both a shipment's status and its fee,
the gateway can spread one request across two services and merge the responses into one body. The
next lesson builds both with real local processes and measures how far aggregation cuts the
client's request count, and how many bytes it grows the body in exchange.

### Gateway Routing and Aggregation

Source: https://academia.sh/en/courses/traffic-layer/gateway-routing-and-aggregation

The service map's two separate jobs: converting a path to a single service, and spreading one request across several services and merging the responses into one body; measuring three access patterns with real local processes and calculating how far aggregation cuts the request count while it grows the body at the edge.

The previous lesson said the gateway carries a service map but left the map as an assumption.
The map can do two separate jobs. The first is **gateway routing**: an incoming path translates
to a single service, and the client never sees the split behind it. The second is **gateway
aggregation**: a single request spreads across several services, and the responses that come back
merge into one body.

The concrete case of this distinction is the tracking page. The page takes a shipment's state
from the delivery operations context and its fee from the billing context. Two contexts are two
separate services, so the page needs two responses. This lesson measures how many requests, how
many bytes, and how many rounds those two responses take with real local processes; then it
applies the measured ratio to K01's Back-of-the-Envelope Estimation lesson's tracking-response
count.

#### Mechanism

Two domain services and one gateway run as separate processes. The field set the services return
was determined by the resource design rules in the Web API Design course and is not revisited
here; what gets measured is the decision made at the edge.

```js
// gateway/service.mjs — two domain services: delivery-ops 8471, billing 8472
import { createServer } from "node:http";

const STATE = {
  "TR-4821": { state: "out-for-delivery", zone: "35", updatedAt: "2026-03-11T08:24:00Z",
    route: ["34", "41", "35"] },
  "TR-9007": { state: "accepted", zone: "34", updatedAt: "2026-03-11T07:02:00Z", route: ["34"] },
};
const FEE = {
  "TR-4821": { tariff: 9600, discount: 1440, net: 8160, contractNo: "S7" },
};

const serve = (port, name, table) => createServer((request, response) => {
  const no = request.url.split("/").pop();
  const record = table[no];
  const body = JSON.stringify(record ? { trackingNo: no, ...record } : { error: "not_found" });
  response.writeHead(record ? 200 : 404, { "Content-Type": "application/json" });
  response.end(body);
}).listen(port, "127.0.0.1", () => console.log(`${name} 127.0.0.1:${port}`));

serve(8471, "delivery-ops ", STATE);
serve(8472, "billing      ", FEE);
```

The gateway holds the map as an object and separates two paths: `/tracking/<no>/<sub>` converts
to a single service, while `/tracking/<no>` calls two services in one round. A third path,
`/counter`, reads and resets the counters between the gateway and the services; it is a
measurement tip, not part of the design.

```js
// gateway/gateway.mjs — API gateway 8473: routing and single-request aggregation via the service map
import { createServer } from "node:http";

const MAP = { state: 8471, fee: 8472 };           // path segment -> service
let counter = { requests: 0, bytes: 0, round: 0 }; // counters between gateway and services

async function callService(name, no) {
  counter.requests += 1;
  const y = await fetch(`http://127.0.0.1:${MAP[name]}/${name}/${no}`);
  const text = await y.text();
  counter.bytes += Buffer.byteLength(text);
  return y.ok ? JSON.parse(text) : null;
}

createServer(async (request, response) => {
  const [root, no, sub] = request.url.split("/").filter(Boolean);
  let body;
  if (root === "counter") {                          // measurement tip: reads and resets counters
    body = counter;
    counter = { requests: 0, bytes: 0, round: 0 };
  } else if (root !== "tracking") {
    body = null;
  } else if (sub) {                                   // routing: path converts to a single service
    counter.round += 1;
    body = await callService(sub, no);
  } else {                                             // aggregation: two services called in one round
    counter.round += 1;
    const [state, fee] = await Promise.all([callService("state", no), callService("fee", no)]);
    body = state && { ...state, fee: fee && { tariff: fee.tariff,
      discount: fee.discount, net: fee.net, contractNo: fee.contractNo } };
  }
  const text = JSON.stringify(body ?? { error: "not_found" });
  response.writeHead(body ? 200 : 404, { "Content-Type": "application/json" });
  response.end(text);
}).listen(8473, "127.0.0.1", () => console.log("gateway       127.0.0.1:8473"));
```

The client tries the three access patterns in turn and collects six numbers for each. Byte
counts count only the body; header and protocol overhead were left out of K01's calculation too.

```js
// gateway/measure.mjs — measures the three access patterns and ties them to K01's tracking-response calculation
const get = async (url) => {
  const y = await fetch(url);
  const text = await y.text();
  return { bytes: Buffer.byteLength(text), data: y.ok ? JSON.parse(text) : null };
};
const readCounter = async () => (await get("http://127.0.0.1:8473/counter")).data;
const leaf = (d) => (typeof d !== "object" || d === null ? 1
  : Object.values(d).reduce((t, v) => t + leaf(v), 0));

async function measure(name, addresses, requests) {
  await readCounter();                                 // resets the previous scenario's counters
  let bytes = 0, fields = 0;
  for (const u of requests) {
    const r = await get(u);
    bytes += r.bytes;
    fields += r.data ? leaf(r.data) : 0;
  }
  const internal = await readCounter();
  return { name, "client requests": requests.length, "addresses the client knows": addresses,
    "bytes reaching the client": bytes, "fields carried": fields,
    "gateway-service requests": internal.requests, "gateway-service bytes": internal.bytes,
    "gateway rounds": internal.round };
}

const N = "TR-4821";
const S = [
  await measure("separate calls", 2, [`http://127.0.0.1:8471/state/${N}`, `http://127.0.0.1:8472/fee/${N}`]),
  await measure("routing", 1, [`http://127.0.0.1:8473/tracking/${N}/state`,
    `http://127.0.0.1:8473/tracking/${N}/fee`]),
  await measure("aggregation", 1, [`http://127.0.0.1:8473/tracking/${N}`]),
];
const FIELD = ["client requests", "addresses the client knows", "bytes reaching the client",
  "fields carried", "gateway-service requests", "gateway-service bytes", "gateway rounds"];
console.log(`${"measure".padEnd(26)}${S.map((s) => s.name.padStart(15)).join("")}`);
for (const f of FIELD) console.log(`${f.padEnd(26)}${S.map((s) => String(s[f]).padStart(15)).join("")}`);

// Missing piece: billing does not know this tracking number
const missing = await get(`http://127.0.0.1:8473/tracking/TR-9007`);
console.log(`\naggregation with a missing piece = ${missing.bytes} bytes, fields carried = ${leaf(missing.data)}`);
console.log(`  body: ${JSON.stringify(missing.data)}`);

// K01 Back-of-the-Envelope Estimation: V5 = 480 bytes (assumption), read peak 416.67 requests/s (computed value)
const stateBytes = (await get(`http://127.0.0.1:8471/state/${N}`)).bytes;
const feeBytes = (await get(`http://127.0.0.1:8472/fee/${N}`)).bytes;
const combined = (await get(`http://127.0.0.1:8473/tracking/${N}`)).bytes;
const V5 = 480, READ_PEAK = 416.67;
const mbit = (requests, bytes) => (requests * bytes * 8) / 1e6;
console.log(`\nstate ${stateBytes} B, fee ${feeBytes} B, combined ${combined} B`);
console.log(`ratio — fee/state = ${(feeBytes / stateBytes).toFixed(3)}, ` +
  `combined/state = ${(combined / stateBytes).toFixed(3)}`);
const SETUP = [
  ["K01 (state only)", READ_PEAK, V5],
  ["separate calls", READ_PEAK * 2, (V5 * (stateBytes + feeBytes)) / stateBytes / 2],
  ["aggregation", READ_PEAK, (V5 * combined) / stateBytes],
];
console.log(`${"setup".padEnd(20)}${"requests/s".padStart(12)}${"bytes".padStart(9)}${"Mbit/s".padStart(9)}`);
for (const [name, requests, bytes] of SETUP) {
  console.log(`${name.padEnd(20)}${requests.toFixed(2).padStart(12)}${bytes.toFixed(0).padStart(9)}` +
    `${mbit(requests, bytes).toFixed(2).padStart(9)}`);
}
const [, separate, agg] = SETUP;
console.log(`aggregation / separate calls — requests x${(agg[1] / separate[1]).toFixed(2)}, ` +
  `egress x${(mbit(agg[1], agg[2]) / mbit(separate[1], separate[2])).toFixed(2)}`);

// T1 sensitivity: if only a portion of views ask for the fee (T1 = 0.40)
const T1 = 0.4;
const separateRequests = READ_PEAK * (1 + T1);
const separateBytes = (V5 * (stateBytes + T1 * feeBytes)) / stateBytes / (1 + T1);
const aggBytes = (V5 * (stateBytes + T1 * (combined - stateBytes))) / stateBytes;
console.log(`T1 = ${T1.toFixed(2)} -> separate calls ${separateRequests.toFixed(2)} requests/s ` +
  `${mbit(separateRequests, separateBytes).toFixed(2)} Mbit/s | aggregation ${READ_PEAK.toFixed(2)} requests/s ` +
  `${mbit(READ_PEAK, aggBytes).toFixed(2)} Mbit/s`);
```

```bash
node gateway/service.mjs & s=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8472/fee/TR-4821
node gateway/gateway.mjs & g=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8473/counter
node gateway/measure.mjs
kill $s $g
```

```
delivery-ops  127.0.0.1:8471
billing       127.0.0.1:8472
gateway       127.0.0.1:8473
measure                    separate calls        routing    aggregation
client requests                         2              2              1
addresses the client knows              2              1              1
bytes reaching the client             206            206            190
fields carried                         12             12             11
gateway-service requests                0              2              2
gateway-service bytes                   0            206            206
gateway rounds                          0              2              1

aggregation with a missing piece = 116 bytes, fields carried = 6
  body: {"trackingNo":"TR-9007","state":"accepted","zone":"34","updatedAt":"2026-03-11T07:02:00Z","route":["34"],"fee":null}

state 123 B, fee 83 B, combined 190 B
ratio — fee/state = 0.675, combined/state = 1.545
setup                 requests/s    bytes   Mbit/s
K01 (state only)          416.67      480     1.60
separate calls            833.34      402     2.68
aggregation               416.67      741     2.47
aggregation / separate calls — requests x0.50, egress x0.92
T1 = 0.40 -> separate calls 583.34 requests/s 2.03 Mbit/s | aggregation 416.67 requests/s 1.95 Mbit/s
```

#### What Routing Alone Gains

The table's first two columns answer this. Once routing is in place, client requests stay at 2,
bytes reaching the client stay at 206, and fields carried stay at 12. The only number that
changes is addresses the client knows: 2 instead of 1. Routing is not a performance decision, it
is a **binding** decision. Because the service map sits at the gateway, when the billing service
moves to a different address or splits into two, the client is not touched; the map is.

In exchange, gateway-service requests rise from 0 to 2. The same two calls still happen, only
behind the gateway now. Routing does not eliminate a call; it changes who makes it.

#### What Aggregation Changes

The third column moves four numbers at once. Client requests drop from 2 to 1, fields carried
from 12 to 11, bytes reaching the client from 206 to 190, and gateway rounds from 2 to 1. The
field count drops because `trackingNo` moves from being carried in two bodies to one; that is
also where the byte reduction comes from. The gateway round dropping to 1 is because the two
calls happen in one round without waiting on each other; the side waiting for both services to
respond is the gateway, not the client.

The number that stays the same matters: gateway-service requests is still 2 under aggregation.
Aggregation does not reduce how many calls the system makes — it reduces the call count **visible
outside the edge**. This distinction decides where the decision gets written: the gain sits in
the client's round count and in the information the client carries, the cost sits in the
gateway's code waiting on two responses.

#### A Missing Piece Is a Contract Decision

Shipment `TR-9007` has no billing record. Aggregation returns a 116-byte body and 6 fields in
this case; the `fee` field is `null`. This is a choice in the code, and its alternatives are
countable: the merged response can return with the missing piece, it can be treated as a full
failure, or the missing piece can be reported to the client in a separate field. A gateway that
aggregates has to make this choice, because a single response now holds the state of more than
one source. Left unwritten, the client can confuse a missing `fee` field with "the fee is zero."
The decision itself does not belong to the gateway — it belongs to the response shape the two
sides have agreed on.

#### Back to the Calculation

K01's assumption table counted V5, the tracking response, as 480 bytes, with the reasoning "state,
zone, update time, and the last three route steps" — that is, the state piece alone. The tracking
page also showing the fee was not in K01; it is **this course's assumption** (T1 = 1.00: every
tracking view also asks for fee information). The reasoning is that the page shows two pieces of
information on one screen; its sensitivity is calculated below.

The ratios measured from the mechanism are deterministic: bodies are JSON text, and byte counts
are independent of the machine. The fee body is 0.675 times the state body, the combined body
1.545 times. Applying these ratios to V5 produces three setups. K01's own row is 416.67
requests/s and 480 bytes at 1.60 Mbit/s. With separate calls the request rate doubles (833.34)
and the average body per request falls to 402 bytes; egress is 2.68 Mbit/s. With aggregation the
request rate stays at 416.67 but the body rises to 741 bytes; egress is 2.47 Mbit/s.

Three numbers say three separate things. First, aggregation cuts the request rate in half (×0.50)
but only cuts egress to ×0.92: what determines bandwidth is not packaging but the set of fields
carried. Second, K01's 1.60 Mbit/s is exceeded under both setups; once a requirement grows, the
calculation is rerun, the old number is not kept. Third, T1's sensitivity: if only forty percent
of views ask for the fee, separate calls come out to 583.34 requests/s and 2.03 Mbit/s,
aggregation to 416.67 requests/s and 1.95 Mbit/s. Aggregation's gain in request rate shrinks as T1
shrinks; so does its cost in body growth.

#### Summary

- The service map does two jobs: routing converts a path to a single service, aggregation spreads
  one request across several services and merges the responses.
- Routing is only a binding decision: client requests stay at 2, bytes at 206, and fields at 12,
  while addresses the client knows drop from 2 to 1 and gateway-service requests rise from 0 to 2.
- Aggregation cuts client requests from 2 to 1, fields carried from 12 to 11, and gateway rounds
  from 2 to 1; the request count between the gateway and the services stays at 2.
- The body aggregation returns merges the state of more than one source; how a missing piece is
  reported is a contract decision — in the example it came back as `null`, with a 116-byte body
  and 6 fields.
- Measured ratios: the fee body is 0.675 times the state body, the combined body 1.545 times;
  byte counts are independent of the machine.
- Back to K01: the 416.67 requests/s and 480-byte, 1.60 Mbit/s egress becomes 833.34 requests/s
  and 2.68 Mbit/s with separate calls, 416.67 requests/s and 2.47 Mbit/s with aggregation —
  requests ×0.50, egress ×0.92.

#### Next Step

Aggregation was the first job that touched the body, and it required a contract decision. Most of
the work that can move to the edge never touches the body at all: validating a token, applying a
rate limit, writing an access log entry. This work can either repeat at every service or gather in
one place; the first lesson compared the two by line count but did not turn which work is
movable into a rule. The next lesson builds that rule: once the inputs a check reads are written
down, the work that can move to the edge separates from the work that must stay at the service by
mechanical means — and it measures what having validation done in a single place costs the
application.

### Gateway Offloading

Source: https://academia.sh/en/courses/traffic-layer/gateway-offloading

Turning the move of common work to the edge into a mechanical rule: separating what is movable once a check's inputs are written down, measuring the repeated line count, the cost of the trust boundary shifting, and reading which rate a check moved to the edge runs at from the K01 calculation.

Aggregation was the gateway's job that touched the body. Most work that can move to the edge
never touches the body at all: verifying a token's signature, applying a rate limit, writing an
access log entry. **Gateway offloading** takes this work off the services and gathers it in one
place at the edge.

The first lesson compared this layout by line count but did not turn movability into a rule, and
left "work that looks at the request's metadata belongs at the edge" as a statement, not a rule.
This lesson makes that criterion mechanical: every check's inputs get written down, and the
inputs' class separates the check on its own. Token validation was built in the Authentication
and Authorization course, rate limiting in the Web API Design course, TLS termination in this
course's own Role of the Load Balancer lesson; none of it is retold here.

#### The Portability Rule

A check can read two kinds of input. **Metadata** comes from the request itself: headers, path,
source address, content length. **Domain input** comes from the system's data: a shipment record,
a contract record, a daily counter. The rule is one sentence: a check that reads only metadata can
move to the edge; a check that reads a domain input stays at the service.

The rule's reasoning is a coupling count. When a check reading domain input moves to the edge, the
gateway is forced to reach that data; as the first lesson said, the edge starts knowing both
contexts' domain rules at once, and every change to either context forces a redeploy. The rule is
the measurable form of refusing to build that coupling.

```js
// edge/check.mjs — six checks, the inputs they read, and the portability rule
export const METADATA = ["headers", "path", "source address", "content length"];

// reads: the decision's required inputs; optional: input requested for the log but not decision-changing
export const CHECK = {
  "token signature": { reads: ["headers"], optional: [], lines: 12 },
  "rate limit": { reads: ["source address", "headers"], optional: [], lines: 9 },
  "body size": { reads: ["content length"], optional: [], lines: 5 },
  "access log": { reads: ["path", "headers"], optional: ["shipment record"], lines: 6 },
  "shipment ownership": { reads: ["headers", "shipment record"], optional: [], lines: 14 },
  "contract quota": { reads: ["contract record", "daily counter"], optional: [], lines: 11 },
};

export const isMetadata = (g) => METADATA.includes(g);
export const movable = (d) => d.reads.every(isMetadata);
export const missingFields = (d) => (movable(d) ? d.optional.filter((g) => !isMetadata(g)).length : 0);
```

The line counts are the model's input and belong to the **assumption** class; what is read is not
a single implementation's length but the difference the same lines repeating across three
services produces. The rule, in turn, follows from the class of the inputs and is independent of
the line counts.

```js
// edge/offload.mjs — applies the rule to six checks, counts lines, and ties them to the K01 calculation
import { CHECK, movable, missingFields, isMetadata } from "./check.mjs";

const SERVICE_COUNT = 3;                            // tracking, event, fee
const name = Object.keys(CHECK);
const edge = name.filter((a) => movable(CHECK[a]));
const service = name.filter((a) => !movable(CHECK[a]));

console.log(`${"check".padEnd(19)}${"domain input".padStart(13)}  decision`);
for (const a of name) {
  const fields = CHECK[a].reads.filter((g) => !isMetadata(g));
  console.log(`${a.padEnd(19)}${String(fields.length).padStart(13)}  ` +
    `${movable(CHECK[a]) ? "moves to edge" : `stays at service (${fields.join(", ")})`}`);
}
const missing = edge.reduce((t, a) => t + missingFields(CHECK[a]), 0);
console.log(`\nmoved to edge = ${edge.length}/${name.length}, stays at service = ${service.length}/${name.length}`);
console.log(`record fields missing at edge = ${missing}`);

const lines = (list) => list.reduce((t, a) => t + CHECK[a].lines, 0);
const LAYOUT = [
  ["at each service", [], name],
  ["offloaded", edge, service],
];
console.log(`\n${"layout".padEnd(14)}${"edge lines".padStart(14)}${"service lines".padStart(15)}${"total".padStart(9)}`);
for (const [y, k, s] of LAYOUT) {
  console.log(`${y.padEnd(14)}${String(lines(k)).padStart(14)}` +
    `${String(SERVICE_COUNT * lines(s)).padStart(15)}${String(lines(k) + SERVICE_COUNT * lines(s)).padStart(9)}`);
}

// K01 Back-of-the-Envelope Estimation: peak edge 513.89 requests/s (computed-value class)
const EDGE_PEAK = 513.89;
const TRUST = [
  ["no gateway", SERVICE_COUNT, 1, 0],
  ["gateway validates, service trusts", 1, 1, SERVICE_COUNT],
  ["gateway and service validate", 1 + SERVICE_COUNT, 2, 0],
];
console.log(`\n${"token validation".padEnd(34)}${"units".padStart(7)}${"validations/s".padStart(15)}${"unprotected service".padStart(20)}`);
for (const [y, units, times, open] of TRUST) {
  console.log(`${y.padEnd(34)}${String(units).padStart(7)}` +
    `${(EDGE_PEAK * times).toFixed(2).padStart(15)}${`${open}/${SERVICE_COUNT}`.padStart(20)}`);
}

// The rate a check runs at: K01's three-flow calculation
const RATE = [
  [`edge checks (${edge.length})`, 513.89, "requests/s", "peak at the edge"],
  ["shipment ownership", 416.67, "requests/s", "read peak"],
  ["contract quota", 833.33, "records/s", "batch job scan rate"],
];
console.log("");
for (const [a, v, u, k] of RATE) console.log(`${a.padEnd(23)}${v.toFixed(2).padStart(9)} ${u.padEnd(11)} ${k}`);
```

```
check               domain input  decision
token signature                0  moves to edge
rate limit                     0  moves to edge
body size                      0  moves to edge
access log                     0  moves to edge
shipment ownership             1  stays at service (shipment record)
contract quota                 2  stays at service (contract record, daily counter)

moved to edge = 4/6, stays at service = 2/6
record fields missing at edge = 1

layout            edge lines  service lines    total
at each service             0            171      171
offloaded                 32             75      107

token validation                    units  validations/s unprotected service
no gateway                              3         513.89                 0/3
gateway validates, service trusts       1         513.89                 3/3
gateway and service validate            4        1027.78                 0/3

edge checks (4)           513.89 requests/s  peak at the edge
shipment ownership        416.67 requests/s  read peak
contract quota            833.33 records/s   batch job scan rate
```

#### The Distinction the Rule Produces

Four of the six checks move to the edge, two stay at the service, and what makes the distinction
is not a judgment call but a list. `shipment ownership` asks whether a shipment belongs to the
identity making the request; answering requires reading the shipment record, so it cannot be
given at the edge. This is the edge counterpart of object-level authorization from the
Authentication and Authorization course: verifying identity can be done at the edge, deciding
whether that identity can access a specific object cannot. `contract quota` stays at the service
even more firmly, since it reads two domain inputs.

The `access log` row shows the rule's boundary. The check moves to the edge because it only reads
metadata, but its optional input is a domain input: the record written at the edge knows which
path was called, not which shipment the call returned. The counter writes this as `record fields
missing at edge = 1`. Offloading here does not move the work — it drops one of the work's fields;
the decision's written form should read "the access log moved to the edge, the record is missing
one field."

The line table gives the gain: the six checks take 171 lines across three services, while the
offloaded layout takes 32 lines at the edge and 75 at the services, 107 total. The total falls to
0.63 times. The drop does not reach a third because the two checks that stay at the service still
repeat across three services — and those two are the longest two.

#### Where the Trust Boundary Shifts

Offloading's real cost is not in the lines, it is in the second table. Without a gateway, all
three services do their own token validation: code sits in three units, validation runs 513.89
times a second, unprotected service is 0/3. Once validation moves to the edge and the services
trust the gateway, code drops to one unit and the validation count does not change, but
unprotected service becomes 3/3: a request bypassing the gateway is validated by none of the
three services. The gain in code lines was paid for with a trust assumption, and the decision is
incomplete until that assumption is written down.

The third row removes the assumption: validating at both the gateway and the service returns
unprotected service to 0/3, but code rises to four units and validation runs 1027.78 times a
second — the measure of doing the same work twice. None of the three rows leads on every measure;
the choice is written down together with whether the gateway can be bypassed.

#### Back to the Calculation

The last table uses the course's rule in both directions. The four checks moved to the edge run
at K01's peak edge rate: 513.89 requests a second, the system's highest request rate. A check
gathered in one place at the edge costs that check multiplied by 513.89, and offloading does not
lower this multiplier — it only gathers it in one place.

The two checks that stay at the service run at different rates. `shipment ownership` belongs to
the read flow and runs at K01's peak read rate, 416.67 requests a second. `contract quota` belongs
to the batch flow and runs at K01's end-of-day scan rate, 833.33 records a second. The result is
the opposite of what might be expected: a check that does not move to the edge can run faster than
one that does. Offloading is not a performance decision, it is a placement decision; the most
expensive work does not have to be at the edge.

#### Summary

- A check that reads only metadata can move to the edge; one that reads a domain input stays at
  the service — the rule follows mechanically from the list of inputs.
- Four of six checks move; two stay: `shipment ownership` reads the shipment record, `contract
  quota` reads the contract record and daily counter.
- Offloading can drop one of a check's fields: the access log written at the edge is missing one
  field (`record fields missing at edge = 1`).
- Line count falls from 171 to 107, 0.63 times; the limit is that the two checks staying at the
  service still repeat across three services.
- The trust boundary shifts: moving validation to the edge drops code from 3 units to 1 but raises
  unprotected service from 0/3 to 3/3; validating in both places returns it to 0/3, at a cost of 4
  units and validation rising from 513.89 to 1027.78 times a second.
- Back to K01: edge checks run at the peak edge rate (513.89 requests/s), while the two checks
  staying at the service run at 416.67 requests/s and 833.33 records/s — the edge is not where the
  most expensive work has to be.

#### Next Step

The three lessons so far treated the gateway as a single edge: every client arrives at the same
address, passes the same checks, and receives the same merged body when aggregation runs. But the
clients asking for tracking information are not the same. A mobile tracking card shows the state
and zone, a web tracking page also opens the route steps, a seller panel wants the fee line
items. When one merged body serves all three, every client also carries fields it does not use.
The next lesson counts these fields and measures how many bytes splitting the edge by client type
cuts from the body, and how many edge units it adds in exchange.

### Backends for Frontends

Source: https://academia.sh/en/courses/traffic-layer/backends-for-frontends

Splitting the edge by client type: separating three clients' field sets, measuring the unused fields and request count a single merged body carries, the batch-endpoint requirement separate edge units impose, and the client mix's effect on K01's egress calculation.

The previous three lessons treated the edge as a single surface: every client arrives at the same
address, passes the same checks, and receives the same merged body when aggregation runs. But the
clients asking for tracking information do not want the same thing. A mobile tracking card shows
only the state and update time; a web tracking page also opens the zone and route steps; a
seller panel wants the shipment list along with the fee line items.

**Backends for frontends** splits the edge into a separate unit per client type: each client gets
its own edge unit, and that unit returns only the fields that client wants, in the shape that
client wants. This lesson compares two layouts and looks for the problem's measure in the unused
field count and the request count. Over-fetching was built in the Web API Design course and
measured there over interface styles; here the same phenomenon is treated as an **edge layout**
decision.

#### Three Clients, Three Field Sets

The model is in-process: there is no real client, browser, or separate process. The merged body
is the body the previous lesson measured; field sets are listed per client type. The `shipments`
field says how many records a view asks for, the `share` field says what portion of tracking
queries comes from that client.

```js
// edge/client.mjs — three client types, the fields they want, and which service each field lives in
export const RECORD = {
  trackingNo: "TR-4821", state: "out-for-delivery", zone: "35",
  updatedAt: "2026-03-11T08:24:00Z", route: ["34", "41", "35"],
  fee: { tariff: 9600, discount: 1440, net: 8160, contractNo: "S7" },
};

export const SERVICE_FIELDS = {
  "delivery-ops": ["trackingNo", "state", "zone", "updatedAt", "route"],
  billing: ["fee"],
};

// shipments: how many records a view asks for; share: what portion of tracking queries (T2)
export const CLIENT = {
  "mobile card": { fields: ["trackingNo", "state", "updatedAt"], shipments: 1, share: 0.6 },
  "web page": { fields: ["trackingNo", "state", "zone", "updatedAt", "route"], shipments: 1, share: 0.3 },
  "seller panel": { fields: ["trackingNo", "state", "fee"], shipments: 5, share: 0.1 },
};

export const leaf = (d) => (typeof d !== "object" || d === null ? 1
  : Object.values(d).reduce((t, v) => t + leaf(v), 0));
export const pick = (record, fields) => Object.fromEntries(fields.map((a) => [a, record[a]]));
export const bytes = (d) => Buffer.byteLength(JSON.stringify(d));
export const serviceCount = (fields) => Object.values(SERVICE_FIELDS)
  .filter((list) => fields.some((a) => list.includes(a))).length;
```

The `share` values are **this course's assumption** (T2): sixty percent of tracking queries come
from the mobile card, thirty percent from the web page, ten percent from the seller panel. It
is not added to K01's table, because K01 did not separate client types. The reasoning is that
most tracking queries are made by a recipient for a single shipment; its sensitivity is calculated
below.

```js
// edge/measure.mjs — measures the single-edge and backend-for-frontend layouts and ties them to K01's egress
import { RECORD, CLIENT, leaf, pick, bytes, serviceCount } from "./client.mjs";

const FULL = leaf(RECORD);                    // leaf field count the merged body carries

function singleEdge(i) {                      // every client gets the same merged body
  const used = leaf(pick(RECORD, i.fields));
  return { requests: i.shipments, bytes: i.shipments * bytes(RECORD), carried: i.shipments * FULL,
    unused: i.shipments * (FULL - used), internalRequests: 2 * i.shipments };
}

function backendForFrontend(i) {              // a separate edge unit per client type
  const body = i.shipments === 1 ? pick(RECORD, i.fields)
    : Array.from({ length: i.shipments }, () => pick(RECORD, i.fields));
  return { requests: 1, bytes: bytes(body), carried: leaf(body), unused: 0,
    internalRequests: serviceCount(i.fields) };
}

const FIELD = ["requests", "bytes", "carried", "unused", "internalRequests"];
for (const [name, measure] of [["single edge", singleEdge], ["backend for frontend", backendForFrontend]]) {
  console.log(`\n-- ${name} --`);
  console.log(`${"client".padEnd(15)}${FIELD.map((a) => a.padStart(17)).join("")}`);
  let t = Object.fromEntries(FIELD.map((a) => [a, 0]));
  for (const [i, def] of Object.entries(CLIENT)) {
    const r = measure(def);
    FIELD.forEach((a) => { t[a] += r[a]; });
    console.log(`${i.padEnd(15)}${FIELD.map((a) => String(r[a]).padStart(17)).join("")}`);
  }
  console.log(`${"total".padEnd(15)}${FIELD.map((a) => String(t[a]).padStart(17)).join("")}`);
  console.log(`edge units = ${name === "single edge" ? 1 : Object.keys(CLIENT).length}, ` +
    `clients affected by one added field = ${name === "single edge" ? Object.keys(CLIENT).length : 1}, ` +
    `batch endpoints the service must offer = ${name === "single edge" ? 0 : 2}`);
}

// K01 Back-of-the-Envelope Estimation: V5 = 480 bytes (assumption), read peak 416.67 requests/s (computed value).
// Scale: the model's state body is 115 bytes, K01's tracking response is 480 bytes.
const V5 = 480, READ_PEAK = 416.67, STATE_BYTES = bytes(pick(RECORD, ["trackingNo", "state", "zone",
  "updatedAt", "route"]));
const SCALE = V5 / STATE_BYTES;
const mbit = (requests, bytes) => (requests * bytes * 8) / 1e6;
console.log(`\nstate body = ${STATE_BYTES} bytes, K01 scale = ${SCALE.toFixed(4)} (480 / ${STATE_BYTES})`);

console.log(`${"layout".padEnd(21)}${"requests/s".padStart(11)}${"bytes/request".padStart(15)}${"Mbit/s".padStart(9)}`);
for (const [name, measure] of [["single edge", singleEdge], ["backend for frontend", backendForFrontend]]) {
  let requests = 0, totalBytes = 0;
  for (const def of Object.values(CLIENT)) {
    const r = measure(def);
    requests += READ_PEAK * def.share * r.requests;
    totalBytes += READ_PEAK * def.share * r.bytes * SCALE;
  }
  console.log(`${name.padEnd(21)}${requests.toFixed(2).padStart(11)}` +
    `${(totalBytes / requests).toFixed(0).padStart(15)}${((totalBytes * 8) / 1e6).toFixed(2).padStart(9)}`);
}
console.log(`K01 (single client type)${READ_PEAK.toFixed(2).padStart(7)}${String(V5).padStart(15)}` +
  `${mbit(READ_PEAK, V5).toFixed(2).padStart(9)}`);

// T2 sensitivity: mobile share 0.30 instead of 0.60, the difference moving to the web page
const T2 = { "mobile card": 0.3, "web page": 0.6, "seller panel": 0.1 };
for (const [name, measure] of [["single edge", singleEdge], ["backend for frontend", backendForFrontend]]) {
  let requests = 0, totalBytes = 0;
  for (const [i, def] of Object.entries(CLIENT)) {
    const r = measure(def);
    requests += READ_PEAK * T2[i] * r.requests;
    totalBytes += READ_PEAK * T2[i] * r.bytes * SCALE;
  }
  console.log(`T2 mobile 0.30 -> ${name.padEnd(21)}${requests.toFixed(2).padStart(9)} requests/s` +
    `${((totalBytes * 8) / 1e6).toFixed(2).padStart(7)} Mbit/s`);
}
```

```
-- single edge --
client                  requests            bytes          carried           unused internalRequests
mobile card                    1              190               11                8                2
web page                       1              190               11                4                2
seller panel                   5              950               55               25               10
total                          7             1330               77               37               14
edge units = 1, clients affected by one added field = 3, batch endpoints the service must offer = 0

-- backend for frontend --
client                  requests            bytes          carried           unused internalRequests
mobile card                    1               86                3                0                1
web page                       1              123                7                0                1
seller panel                   1              596               30                0                2
total                          3              805               40                0                4
edge units = 3, clients affected by one added field = 1, batch endpoints the service must offer = 2

state body = 123 bytes, K01 scale = 3.9024 (480 / 123)
layout                requests/s  bytes/request   Mbit/s
single edge               583.34            741     3.46
backend for frontend      416.67            578     1.93
K01 (single client type) 416.67            480     1.60
T2 mobile 0.30 -> single edge             583.34 requests/s   3.46 Mbit/s
T2 mobile 0.30 -> backend for frontend    416.67 requests/s   2.07 Mbit/s
```

#### Unused Fields and Request Count

Under a single edge, the three clients' 7 requests together carry 77 fields, and 37 of those go
unused — forty-eight percent of the fields carried. The split is not even: the mobile card does
not use 8 of 11 fields, the web page 4, the seller panel 25 across five records. The smallest
client carries the most excess, because the merged body is shaped to the widest client's need.

Under backends for frontends this number drops to zero; what is interesting is the request count.
Total requests fall from 7 to 3, and the entire drop comes from the seller panel: the panel made
five requests for five shipments, now it gets the list in one request. This is the edge form of
the N+1 pattern measured in the Web API Design course — a layout that fetches a list and then
makes a separate request per row. Backends for frontends does not eliminate it, it moves it behind
the edge: the `internalRequests` column brings the panel from 10 down to 2, not down to zero.

Body bytes also fall, from 1330 to 805, 0.61 times. This number moves in the same direction as
fields carried falling from 77 to 40, but not at the same ratio; the field count falls to 0.52
times while bytes fall to 0.61 times, because short field names and envelope characters hold a
fixed share of every body.

#### Numbers Paid

Three numbers grow. First, the edge unit count: 1 to 3. Every unit repeats the aggregation code
within itself, meaning the "repeated lines" problem the first lesson measured is reborn inside the
edge. Second, per-client redeployment: under a single edge, adding a field affects 3 clients;
under backends for frontends, 1. These two are the same tradeoff's two halves — raising the unit
count is the price of narrowing a change's blast radius.

Third is less visible and the most binding: `batch endpoints the service must offer` rises from 0
to 2. Meeting the panel's list in one request requires the gateway to be able to make a batch
query against both services; this is a requirement a decision made at the edge writes into the
service contracts. Backends for frontends is not a decision that only changes the edge.

#### Back to the Calculation

K01's `read egress Mbit/s` calculation assumed a single client type: 416.67 requests/s and a
480-byte response, 1.60 Mbit/s. The model's bodies are converted to K01's scale — the model's
state body is 123 bytes, K01's tracking response is 480 bytes, the scale is 3.9024 — and T2's
shares are applied.

Under a single edge the edge rate is 583.34 requests/s, the body per request 741 bytes, egress
3.46 Mbit/s: 2.16 times K01's number. The rate exceeding 416.67 comes from the seller panel's
five requests; the body growing comes from the merged body going to everyone. Under backends for
frontends the rate drops to 416.67, the body to 578 bytes, and egress to 1.93 Mbit/s — 1.21 times
K01's 1.60. The gap is permanent and comes from the requirement: fee information was not in K01's
480 bytes.

Sensitivity separates the two layouts. When the mobile share drops from 0.60 to 0.30 and the
difference moves to the web page, the single edge's numbers do not move at all: 583.34 requests/s
and 3.46 Mbit/s. Under backends for frontends, egress rises from 1.93 to 2.07 Mbit/s. This says
the single edge is **insensitive** to client mix — because everyone gets the same body, bandwidth
does not change even when the mix does. Insensitivity here is not an advantage: the number is
locked to the widest client's need and does not shrink no matter how far the mobile client's share
rises.

#### Summary

- Backends for frontends splits the edge into a separate unit per client type; each unit returns
  only that client's fields.
- Under a single edge, three clients' 7 requests carry 77 fields and 37 go unused (forty-eight
  percent); the smallest client carries the most excess.
- Backends for frontends drops unused fields to 0, requests from 7 to 3, and the body from 1330 to
  805 bytes; the entire request drop comes from the seller panel's list request.
- Numbers paid: edge units rise from 1 to 3, batch endpoints the service must offer from 0 to 2;
  in exchange, clients affected by one added field drop from 3 to 1.
- Back to K01: the 416.67 requests/s and 480-byte, 1.60 Mbit/s egress becomes 583.34 requests/s
  and 3.46 Mbit/s under a single edge, 416.67 requests/s and 1.93 Mbit/s under backends for
  frontends.
- T2 sensitivity separates the two layouts: when the mobile share is halved, the single edge stays
  fixed at 3.46 Mbit/s, backends for frontends rises from 1.93 to 2.07 — the single edge's number
  is locked to the widest client.

#### Next Step

The four lessons so far treated the edge as one component and wrote every decision inside it:
routing, aggregation, offloaded checks, client-specific bodies. One of this lesson's paid numbers
shows this approach's limit — as the edge unit count grows, the same common code repeats in every
unit. One way to share common code is to embed it as a library, but then every unit gets
redeployed when its version changes. The next lesson takes up another way: handing common work to
a separate process running in the same deployment unit. The ambassador and sidecar patterns name
this layout, and what gets measured is the redeployed unit count and the process count per
deployment unit.

### Ambassador and Sidecar Patterns

Source: https://academia.sh/en/courses/traffic-layer/ambassador-and-sidecar-patterns

Handing common work to a separate helper process: measuring how the ambassador separates the external contract from service code across two carrier versions, the sidecar's effect on runtime independence and the number of units republished, and the local hop the added process count costs.

The previous lesson raised the edge unit count from 1 to 3 and left a problem: the same common
code repeats in every unit. The known way to share common code is to pull it into a library, but a
library imposes two things — every unit must be on the same runtime, and every unit must be
rebuilt and republished when the library's version changes.

Another way is to hand the common work to a **separate process**. A **sidecar** is a helper
process that runs in the same deployment unit as the main process and takes on its common work. An
**ambassador** is the same layout applied to outgoing calls: the service calls a local address,
the ambassador finds the external destination and translates it to the external contract. Neither
is new as a pattern — the Ports and Adapters lesson in the Domain-Driven Design course built the
same distinction at the code level. What gets treated here is not the pattern itself, but its
**deployment-unit-level** counterpart and the numbers that counterpart costs.

#### Ambassador: Separating the External Contract from Service Code

The delivery operations context gets a shipment's state from an external carrier. The carrier
offers the same information under two separate contracts; the mechanism runs both as real
processes.

```js
// ambassador/carrier.mjs — external carrier service 8481: offers the same information under two separate contracts
import { createServer } from "node:http";

const RECORD = { "TR-4821": { code: "OFD", zone: "35", time: "2026-03-11T08:24:00Z" } };

createServer((request, response) => {
  const [version, , tail] = request.url.split("/").filter(Boolean);
  const no = version === "v1" ? new URL(request.url, "http://y").searchParams.get("no") : tail;
  const record = RECORD[no];
  const body = !record ? { error: "not_found" }
    : version === "v1" ? { code: record.code, zone: record.zone, ts: Date.parse(record.time) / 1000 }
      : { state: "out_for_delivery", region: { code: record.zone }, updatedAt: record.time };
  response.writeHead(record ? 200 : 404, { "Content-Type": "application/json" });
  response.end(JSON.stringify(body));
}).listen(8481, "127.0.0.1", () => console.log("carrier 127.0.0.1:8481"));
```

The ambassador is a separate process, and it takes which contract to speak from a startup
argument. Its job has three parts: building the external path, translating the external body to
the internal shape, and measuring the external call.

```js
// ambassador/ambassador.mjs — ambassador process: translates a local address into the external contract. Usage: node ambassador/ambassador.mjs v1 8480
import { createServer } from "node:http";

const [version, port] = [process.argv[2], Number(process.argv[3])];
const STATE_NAME = { OFD: "out-for-delivery", out_for_delivery: "out-for-delivery" };
let last = { path: "-", bytes: 0 };                // the most recent external call's path and body bytes

const externalPath = (no) => (version === "v1" ? `/v1/status?no=${no}` : `/v2/status/${no}`);
const translate = (d) => (version === "v1"
  ? { state: STATE_NAME[d.code], zone: d.zone, updatedAt: new Date(d.ts * 1000).toISOString() }
  : { state: STATE_NAME[d.state], zone: d.region.code,
    updatedAt: new Date(d.updatedAt).toISOString() });

createServer(async (request, response) => {
  const [root, no] = request.url.split("/").filter(Boolean);
  if (root === "counter") {
    response.writeHead(200, { "Content-Type": "application/json" });
    return response.end(JSON.stringify({ version, ...last }));
  }
  const path = externalPath(no);
  const y = await fetch(`http://127.0.0.1:8481${path}`);
  const text = await y.text();
  last = { path, bytes: Buffer.byteLength(text) };
  const body = y.ok ? translate(JSON.parse(text)) : { error: "not_found" };
  response.writeHead(y.ok ? 200 : 404, { "Content-Type": "application/json" });
  response.end(JSON.stringify({ trackingNo: no, ...body }));
}).listen(port, "127.0.0.1", () => console.log(`ambassador-${version} 127.0.0.1:${port}`));
```

The measurement script stands in for the service code: it gives both ambassadors the same local
address, the same path, and the same request. The entire service code is one line, and it contains
no external address, external path, or external domain name.

```js
// ambassador/measure.mjs — service code makes the same request to both ambassadors; what goes out differs by contract
const get = async (url) => {
  const y = await fetch(url);
  const text = await y.text();
  return { bytes: Buffer.byteLength(text), text };
};

// The entire service code: makes a request to a local address with a fixed path.
const serviceRequest = (ambassadorPort) => `http://127.0.0.1:${ambassadorPort}/state/TR-4821`;

const LAYOUT = [["v1", 8480], ["v2", 8489]];
const result = [];
for (const [version, port] of LAYOUT) {
  const reply = await get(serviceRequest(port));
  const counter = JSON.parse((await get(`http://127.0.0.1:${port}/counter`)).text);
  result.push({ version, request: "/state/TR-4821", replyBytes: reply.bytes, reply: reply.text,
    externalPath: counter.path, externalBytes: counter.bytes });
}

console.log(`${"ambassador".padEnd(12)}${"service request".padEnd(19)}${"reply B".padStart(8)}` +
  `${"ext bytes".padStart(11)}  external path`);
for (const s of result) {
  console.log(`${s.version.padEnd(12)}${s.request.padEnd(19)}${String(s.replyBytes).padStart(8)}` +
    `${String(s.externalBytes).padStart(11)}  ${s.externalPath}`);
}
const unique = new Set(result.map((s) => s.reply));
console.log(`\nbody returned to service (v1): ${result[0].reply}`);
console.log(`body returned to service (v2): ${result[1].reply}`);
console.log(`contracts the service sees = ${unique.size}, external contracts = ${result.length}`);
console.log(`external addresses in service code = 0, service files edited when the external contract changes = 0`);
```

```bash
node ambassador/carrier.mjs & t=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null "http://127.0.0.1:8481/v1/status?no=TR-4821"
node ambassador/ambassador.mjs v1 8480 & a=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8480/counter
node ambassador/ambassador.mjs v2 8489 & b=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8489/counter
node ambassador/measure.mjs
kill $t $a $b
```

```
carrier 127.0.0.1:8481
ambassador-v1 127.0.0.1:8480
ambassador-v2 127.0.0.1:8489
ambassador  service request     reply B  ext bytes  external path
v1          /state/TR-4821          102         42  /v1/status?no=TR-4821
v2          /state/TR-4821          102         86  /v2/status/TR-4821

body returned to service (v1): {"trackingNo":"TR-4821","state":"out-for-delivery","zone":"35","updatedAt":"2026-03-11T08:24:00.000Z"}
body returned to service (v2): {"trackingNo":"TR-4821","state":"out-for-delivery","zone":"35","updatedAt":"2026-03-11T08:24:00.000Z"}
contracts the service sees = 1, external contracts = 2
external addresses in service code = 0, service files edited when the external contract changes = 0
```

The number the measurement carries sits in the last lines: external contracts 2, contracts the
service sees 1. Both ambassadors received a request to the same path, and the body returned by
both ambassadors is identically 102 bytes. On the external side, both the paths and the bytes
differ: the `v1` call carries 42 bytes, the `v2` call 86 bytes — for the same three pieces of
information, the second contract uses 2.05 times the body.

The counter also works as a check tool. If the translation were left incomplete — for example, if
a timestamp were left in one contract's original shape — it would turn `contracts the service
sees` into 2 instead of 1. The ambassador's job is not "forwarding the request," it is keeping the
internal contract **single**, and this number is its measure.

#### Sidecar: Binding a Shared Capability to the Deployment Unit

The second measurement is an in-process model: there is no real deployment tool, container, or
publishing step. The model counts the six deployment units that exist by this point in the
course — three services (tracking, event, fee) and the three edge units the previous lesson
added — and assumes two of them sit on two separate runtimes.

```js
// ambassador/unit.mjs — two layouts of a shared capability; in-process model at the deployment-unit level
const UNIT = {                                   // the six deployment units that exist in this course
  tracking: "A", event: "A", fee: "B",           // A and B: two separate runtimes
  "edge-mobile": "A", "edge-web": "A", "edge-panel": "B",
};
const LAYOUT = {
  library: { separateProcess: false, runtime: ["A"] },
  sidecar: { separateProcess: true, runtime: ["A", "B"] },
};

const measure = (y) => {
  const unit = Object.entries(UNIT);
  const canUse = unit.filter(([, z]) => y.runtime.includes(z)).length;
  return {
    "units that can use the shared capability": `${canUse}/${unit.length}`,
    "units rebuilt when the version changes": y.separateProcess ? 1 : canUse,
    "service code republished": y.separateProcess ? 0 : canUse,
    "processes per deployment unit": y.separateProcess ? 2 : 1,
    "total processes": unit.length * (y.separateProcess ? 2 : 1),
    "local hop inside the edge unit": y.separateProcess ? 2 : 1,
  };
};

const FIELD = Object.keys(measure(LAYOUT.sidecar));
const measurement = Object.entries(LAYOUT).map(([name, y]) => ({ name, ...measure(y) }));
console.log(`deployment units = ${Object.keys(UNIT).length} ` +
  `(runtime A: ${Object.values(UNIT).filter((z) => z === "A").length}, ` +
  `B: ${Object.values(UNIT).filter((z) => z === "B").length})`);
console.log(`${"measure".padEnd(41)}${measurement.map((o) => o.name.padStart(9)).join("")}`);
for (const f of FIELD) console.log(`${f.padEnd(41)}${measurement.map((o) => String(o[f]).padStart(9)).join("")}`);

// K01 Back-of-the-Envelope Estimation: peak edge 513.89 requests/s (computed-value class)
const EDGE_PEAK = 513.89;
console.log("");
for (const o of measurement) {
  const hop = 1 + o["local hop inside the edge unit"];      // gateway hop + edge hops
  console.log(`${o.name.padEnd(10)} hop ${hop} -> hop-requests/s = ${(EDGE_PEAK * hop).toFixed(2)}`);
}
```

```
deployment units = 6 (runtime A: 4, B: 2)
measure                                    library  sidecar
units that can use the shared capability       4/6      6/6
units rebuilt when the version changes           4        1
service code republished                         4        0
processes per deployment unit                    1        2
total processes                                  6       12
local hop inside the edge unit                   1        2

library    hop 2 -> hop-requests/s = 1027.78
sidecar    hop 3 -> hop-requests/s = 1541.67
```

#### Reading the Numbers

The first row shows the library layout's silent constraint: only four of the six units can use the
shared capability, because two of them sit on a different runtime. These units either rewrite the
capability themselves or go without it; in both cases the label "shared capability" is wrong.
Under the sidecar layout the number becomes 6/6, because the link between the two processes is a
local call, not a function call.

The second and third rows separate out the publishing cost. When the shared capability's version
changes, the library layout rebuilds and republishes four units, and four service artifacts get
republished. Under the sidecar layout, units rebuilt is 1, service code republished is 0: the
services' artifacts do not change. This is the deployment-level counterpart of the previous
lessons' "repeated lines" measure — there it counted how many files the same code sat in, here how
many artifacts the same change forces to rebuild.

The last three rows give the cost, and the cost is not cheap. Processes per deployment unit rise
from 1 to 2, total processes from 6 to 12. Every edge unit gains one more local hop, and this grows
the first lesson's `hop-requests/s` measure.

#### Back to the Calculation

The first lesson took K01's peak edge rate (513.89 requests/s) and computed that with the gateway,
every request touches two hops, raising the hop-request rate to 1027.78. The sidecar adds a third
hop: 513.89 × 3 = 1541.67 hop-requests/s. The number changes none of K01's assumptions — daily
query count, body size, and peak multiplier all stay the same — but it triples how many places in
the system process the same request rate.

This is not a duration measure; no latency has been measured on this machine, and none is written.
What it says is this: a helper-process decision makes K01's peak rate visible in three separate
places in the system at once, without changing a single assumption. The decision's defense
therefore cannot be written as "it separates concerns"; the numbers 6/6, 1, 0, 12, and 1541.67 get
written next to it.

#### Summary

- A sidecar is a helper process that runs in the same deployment unit as the main process; an
  ambassador is the same layout applied to outgoing calls, taking on the external destination and
  external contract.
- The ambassador measurement came out to external contracts 2, contracts the service sees 1: both
  ambassadors returned the identical 102-byte body, while 42 and 86 bytes moved externally.
- External addresses in the service code are 0, and service files edited when the external
  contract changes are 0; an incomplete translation reveals itself by turning `contracts the
  service sees` from 1 into 2.
- Under the library layout, four of six units can use the shared capability, and four service
  codes get republished when the version changes; under the sidecar, 6/6 can use it, units
  rebuilt is 1, service code republished is 0.
- Numbers paid: processes per deployment unit rise from 1 to 2, total processes from 6 to 12.
- Back to K01: the 513.89 requests/s at the edge rises to 1541.67 hop-requests/s with the third
  hop; no assumption changes, the same rate is processed in three places.

#### Next Step

Across five lessons, every piece of work placed at the edge was written down with a gain and a
cost, but all of them shared one assumption: a request reaching the edge is a request worth
processing. Yet part of the peak edge rate is made of requests that will never be answered —
bodies with missing fields, unrecognized paths, oversized bodies, malformed tokens. These requests
spend the application's resources if they reach it, and the edge's resources if they are stopped
before that. The last lesson takes up the gatekeeper pattern, which isolates validation into a
separate layer, and measures the rate at which invalid requests reach the application, applying it
to K01's peak edge rate.

### Gatekeeper Pattern

Source: https://academia.sh/en/courses/traffic-layer/gatekeeper-pattern

Isolating validation into a separate layer: separating checks that can be given at the gate from those that must stay at the application, measuring the rate and bytes at which invalid requests reach the application, reducing the number of units touching untrusted input, and applying the result to K01's peak edge rate.

Across five lessons, every piece of work placed at the edge was written down with a gain and a
cost, but all of them shared one assumption: a request reaching the edge is a request worth
processing. Part of the peak edge rate, though, is made of requests that will never be
answered — unrecognized paths, bodies with a required field missing, oversized bodies, malformed
tokens.

The **gatekeeper** is a layout that isolates validation into a separate layer: only this layer
sees untrusted input, and the application receives only a validated, normalized request. What
separates it from the gateway is the scope of responsibility; the gateway routes, aggregates, and
does offloaded work, while the gatekeeper asks a single question: should this request reach the
application. This lesson measures how many requests get "no" as the answer to that question.

#### Validation the Gate Can Give

The third lesson's portability rule applies here too: a check that looks only at the request
itself can be given at the gate, one that looks at domain data cannot. The model separates this
into two check sets and runs it over a deterministic set of sixty requests. The set is
constructed: each of six flaw types occurs five times, because what is meant to be measured is not
a realistic error rate but **which of the flaws can be held at the gate**.

```js
// gate/request.mjs — deterministic request set and two validation layers; in-process model
export const SHIPMENT = { "TR-4821": { owner: "M7" }, "TR-9007": { owner: "M3" } };
const BASE = { path: "/tracking", trackingNo: "TR-4821", token: "b.M7.sig", bodyBytes: 120 };

// One flaw in every twelfth request; the set is 60 requests, six flaw types each occur five times.
export function set(n = 60) {
  const FLAW = {
    6: { path: "/unknown" },               // unrecognized path
    7: { trackingNo: undefined },          // schema: required field missing
    8: { bodyBytes: 9000 },                // body size exceeded
    9: { token: "broken" },                // malformed token
    10: { trackingNo: "TR-0001" },         // unregistered shipment
    11: { token: "b.M3.sig" },             // someone else's shipment
  };
  return Array.from({ length: n }, (_, i) => ({ ...BASE, ...(FLAW[i % 12] ?? {}) }));
}

// Gatekeeper: looks only at the request itself (path, schema, size, token shape).
export const GATE = {
  path: (r) => r.path === "/tracking",
  schema: (r) => typeof r.trackingNo === "string" && /^TR-\d{4}$/.test(r.trackingNo),
  size: (r) => r.bodyBytes <= 2048,
  token: (r) => /^b\.M\d+\.sig$/.test(r.token),
};

// Application: two checks that look at domain data; cannot be given at the gate.
export const APPLICATION = {
  exists: (r) => SHIPMENT[r.trackingNo] !== undefined,
  authorization: (r) => SHIPMENT[r.trackingNo]?.owner === r.token.split(".")[1],
};

export const passes = (checks, r) => Object.values(checks).every((d) => d(r));
```

Token validation was built in the Authentication and Authorization course; the `token` check here
does not verify the signature, it only checks the shape — that is what can be given at the gate.

```js
// gate/measure.mjs — measures the layout with and without a gatekeeper, then ties it to the K01 calculation
import { set, GATE, APPLICATION, passes } from "./request.mjs";

const requests = set();
const invalid = requests.filter((r) => !(passes(GATE, r) && passes(APPLICATION, r)));
const heldAtGate = invalid.filter((r) => !passes(GATE, r));

const bytes = (list) => list.reduce((t, r) => t + r.bodyBytes, 0);
const reaching = (hasGate) => (hasGate ? requests.filter((r) => passes(GATE, r)) : requests);

const FIELD = ["requests reaching the application", "invalid requests reaching the application",
  "body bytes reaching the application", "units touching untrusted input",
  "application's externally exposed address", "units carrying validation code"];
const measure = (hasGate) => {
  const u = reaching(hasGate);
  return {
    "requests reaching the application": `${u.length}/${requests.length}`,
    "invalid requests reaching the application": u.filter((r) => !passes(APPLICATION, r) || !passes(GATE, r)).length,
    "body bytes reaching the application": bytes(u),
    "units touching untrusted input": hasGate ? 1 : 3,
    "application's externally exposed address": hasGate ? 0 : 1,
    "units carrying validation code": hasGate ? 1 + 3 : 3,
  };
};

const measurement = [["no gate", measure(false)], ["gatekeeper", measure(true)]];
console.log(`${"measure".padEnd(42)}${measurement.map(([a]) => a.padStart(14)).join("")}`);
for (const a of FIELD) console.log(`${a.padEnd(42)}${measurement.map(([, o]) => String(o[a]).padStart(14)).join("")}`);

const ratio = heldAtGate.length / invalid.length;
console.log(`\ninvalid requests = ${invalid.length}/${requests.length}, ` +
  `held at gate = ${heldAtGate.length}, not held at gate = ${invalid.length - heldAtGate.length}`);
console.log(`ratio of invalid held at the gate = ${ratio.toFixed(3)} (${Object.keys(GATE).length} gate ` +
  `checks, ${Object.keys(APPLICATION).length} application checks)`);

// K01 Back-of-the-Envelope Estimation: peak edge 513.89, peak read 416.67 requests/s (computed-value class)
const EDGE_PEAK = 513.89, READ_PEAK = 416.67, BEHIND_CACHE = 41.67;
console.log(`\n${"T3".padEnd(6)}${"requests/s behind gate".padStart(25)}${"reads/s".padStart(11)}` +
  `${"behind cache/s".padStart(17)}`);
for (const T3 of [0.08, 0.16]) {                       // T3: the invalid share of edge requests
  const remaining = 1 - T3 * ratio;
  console.log(`${T3.toFixed(2).padEnd(6)}${(EDGE_PEAK * remaining).toFixed(2).padStart(25)}` +
    `${(READ_PEAK * remaining).toFixed(2).padStart(11)}${(BEHIND_CACHE * remaining).toFixed(2).padStart(17)}`);
}
console.log(`no gate (even for T3 = 0.08) -> ${EDGE_PEAK.toFixed(2)} requests/s, ` +
  `${READ_PEAK.toFixed(2)} reads/s, ${BEHIND_CACHE.toFixed(2)} behind cache/s`);
```

```
measure                                          no gate    gatekeeper
requests reaching the application                  60/60         40/60
invalid requests reaching the application             30            10
body bytes reaching the application                51600          4800
units touching untrusted input                         3             1
application's externally exposed address               1             0
units carrying validation code                         3             4

invalid requests = 30/60, held at gate = 20, not held at gate = 10
ratio of invalid held at the gate = 0.667 (4 gate checks, 2 application checks)

T3       requests/s behind gate    reads/s   behind cache/s
0.08                     486.48     394.45            39.45
0.16                     459.08     372.23            37.23
no gate (even for T3 = 0.08) -> 513.89 requests/s, 416.67 reads/s, 41.67 behind cache/s
```

#### Reading the Numbers

Twenty of the thirty invalid requests are held at the gate, ten are not: the ratio is 0.667. The
ones not held are requests asking about an unregistered shipment and requests for someone else's
shipment; neither can be told apart without reading the shipment record. The gatekeeper therefore
does not **remove** validation, it splits it in two, and `units carrying validation code` rises
from 3 to 4. The pattern's name can be misleading: a gate does not stand in for the application's
own validation.

The byte row gives the biggest difference: body bytes reaching the application fall from 51,600 to
4,800 bytes, 0.093 times. Nearly all of the drop comes from the five oversized requests. A request
that never reaches the application is not just a saved operation, it is an unparsed body.

The last two rows carry the pattern's real purpose. `units touching untrusted input` falls from 3
to 1, `application's externally exposed address` from 1 to 0. Code that parses the raw body,
checks its shape, and rejects what exceeds the limit gathers into a single unit; application units
see only a request that has already passed the gate and whose shape is known. This is the same
trust-boundary question from the third lesson, and it carries the same cost: if the gate can be
bypassed, the 0/3 protection assumption collapses.

#### Back to the Calculation

The model's invalid ratio (30/60) is not realistic and is not presented as such. The real ratio is
**this course's assumption** (T3 = 0.08: eight percent of edge requests are formally invalid). The
reasoning is that stale client versions, expired tokens, and browser retries produce a steady
baseline; it is not added to K01's table, because K01 only counted answered requests. The 0.667
coming from the model, by contrast, is not an assumption but a ratio that follows from the check
lists.

Combined, the two move three of K01's numbers at once. The peak edge rate, 513.89 requests/s,
passes to 486.48 requests/s behind the gate; peak reads fall from 416.67 to 394.45, `reads behind
cache/s` from 41.67 to 39.45. Without a gate, none of the three change. When T3 doubles — sixteen
percent invalid — the numbers become 459.08, 372.23, and 37.23: the gain grows in direct
proportion to T3, because the share the gate holds stays fixed.

The size of the numbers is also worth reading: when two-thirds of the eight-percent-invalid
requests are held, the edge rate falls to only 0.947 times. A gatekeeper is not a scaling tool;
the real gain it produces sits in the table above — units touching untrusted input and the
application's externally exposed address.

#### Summary

- The gatekeeper isolates validation into a separate layer: only that layer sees untrusted input,
  the application receives a validated request.
- A check that can be given at the gate is one that looks at the request itself; in the
  sixty-request set, twenty of thirty invalid requests were held at the gate, ten were not (ratio
  0.667).
- Validation is not removed, it is split: units carrying validation code rise from 3 to 4.
- Requests reaching the application fall from 60/60 to 40/60, the body from 51,600 to 4,800 bytes
  (0.093 times); nearly all of the drop comes from oversized requests.
- The pattern's real gain is isolation: units touching untrusted input fall from 3 to 1, the
  application's externally exposed address from 1 to 0.
- Back to K01 (with the T3 = 0.08 assumption): the 513.89 requests/s at the edge falls to 486.48
  behind the gate, peak reads from 416.67 to 394.45, reads behind cache from 41.67 to 39.45; at
  T3 = 0.16, 459.08, 372.23, and 37.23 respectively.

#### Course Wrap-Up

The course designed the request's path from the user to the application stop by stop, and at
every stop it asked the same three questions: what decision does this stop make, which number in
the introductory course's calculation does it move, and what number grows in exchange. The
measures the lessons left behind are gathered in the table below.

| Lesson | The stop's decision | K01 number it moves | What grows in exchange |
|---|---|---|---|
| Domain Name System Design | routing policy | 1.171 distance units for latency-based (geolocation 1.710, weighted 2.144) | failover window 39–905 s, 0.50%–14.28% of the monthly outage budget |
| Content Delivery Networks | cache location | reads reaching the application 416.67 → 41.76 requests/s | 480-byte body takes 649 bytes on the wire (1.352); requests reaching the store stayed at 138.89 |
| Push and Pull Based Distribution | content propagation model | pull reaches the origin at 42.34 requests/s — K01's 41.67 preserved | push peak 1,166.64 pushes/s, 4.48 Mbit/s and 6.91 GB held at the edges |
| Static Content Hosting | content path separation | application process 3,291.67 → 513.89 requests/s, 241.60 → 1.60 Mbit/s | 2,777.78 requests/s and 240.00 Mbit/s moved to the content process |
| Role of the Load Balancer | distribution + health check | 513.89 requests/s needs at least 3 replicas; dropped requests per failure 102,778 → 34.26 | the check's own load 15 requests/s, 2.9% of the edge load |
| Layer 4 Balancing | connection-level distribution | decision 51.39 decisions/s (6 for 63 requests) | request skew 4.00; holding the target needs 5 replicas instead of 3 |
| Layer 7 Balancing | content-aware routing | request skew 4.00 → 1.07, streams in separate pools | decision 51.39 → 513.89 decisions/s; 4 replicas instead of 3 |
| Balancing Algorithms | distribution algorithm | displaced key 74.85% → 26.48% | warmup store-reaching 138.89 → 238.18 requests/s (419.57 with modulo hashing) |
| Load Balancer and Reverse Proxy | role separation | when the replica-list process dropped, content path answered 6/6 | hop 2 → 3; single-copy-hop monthly outage 215.6 → 258.6 min |
| Session Stickiness | state bound to a replica | replicas needed for the 0.50 utilization target 3 → 4 | imbalance 1.000 → 1.530 (sticky-modulo), 1.411 (sticky-consistent); when a replica drops, sticky-consistent loses 30/100 sessions and 21.78% of requests |
| API Gateway | edge responsibility placement | repeated lines 135 → 45, units per responsibility 3 → 1 | 513.89 requests/s at the edge collects into one component; hop-requests/s 513.89 → 1027.78; flows answered when the gateway stops 0/3 |
| Gateway Routing and Aggregation | multiple calls in one request | edge requests 833.34 → 416.67 requests/s (×0.50) | body 402 → 741 bytes; egress 1.60 → 2.47 Mbit/s |
| Gateway Offloading | check moved to the edge | lines 171 → 107; 4/6 checks at the edge, running at 513.89 requests/s | unprotected service 0/3 → 3/3, or validation 513.89 → 1027.78 times/s |
| Backends for Frontends | client-specific body | unused fields 37 → 0; 583.34 → 416.67 requests/s, 3.46 → 1.93 Mbit/s | edge units 1 → 3; batch endpoints the service must offer 0 → 2 |
| Ambassador and Sidecar Patterns | common work handed to a separate process | contracts the service sees 1 (external 2); units that can use it 4/6 → 6/6 | total processes 6 → 12; hop-requests/s 1027.78 → 1541.67 |
| Gatekeeper Pattern | validation isolated | behind the gate 513.89 → 486.48 requests/s; reads 416.67 → 394.45 | units carrying validation code 3 → 4; invalid not held at the gate 10/30 |

The table's rule deserves to be named: **every stop was tied back to a calculation that comes out
of the introductory course's assumption table.** A decision made at the edge is measured by one of
three numbers — requests reaching the application, bytes crossing the boundary, and the
answered-request ratio when a unit drops. An edge decision that ties to none of these three is not
a defensible decision; the right column never being empty in any row is the other half of that
rule. The three assumptions the course added (the tracking page also asking for the fee, the
client mix, the invalid-request ratio) were not mixed into K01's table; they were written down
under their own names, reasoning, and sensitivities.

The question the course leaves behind sits one layer forward. The request has now reached the
application: its name was resolved, it passed through the edge cache, it landed on a replica, it
passed through the gateway and the gate. But the application's own internal service interaction
was not designed — whether the services are stateless, how they find each other's addresses, how
long they wait and how many times they retry when a call does not answer, and whether a workflow
spread across several services is built with choreography or orchestration. This course's gateway,
while calling two services in parallel and merging their responses, silently assumed an answer to
every one of these questions. The next course, **The Application Layer and Service Interaction**,
opens those assumptions.

