---
title: 'Cache Server'
source: 'https://academia.sh/en/courses/servers-and-hardening/cache-server'
course: 'Servers, Networking and Hardening'
language: en
updated: '2026-09-13T05:08:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures the hit distribution across edge, origin, and neither in a two-layer cache'
    - 'Counts the effect of splitting the edge into nodes on the number of requests reaching the app, and how the origin cache absorbs it'
    - "Measures invalidation's propagation window in seconds and shows the freshness setting's effect on the window"
    - 'Measures the surface opened when a personal response lands in the shared cache, by leaked-request and leaked-subscriber counts'
---

# Cache Server

How the hit rate splits between the edge and the origin in a two-layer cache: splitting the edge into three nodes raising the requests reaching the app from fifty to one hundred forty-three and the origin cache bringing that back down to ten, invalidation's propagation window dropping from three hundred sixty seconds to eleven seconds, the freshness setting's effect on that window, and the shared-key surface opened when a personal response lands in the shared cache.

The last three lessons built and encrypted the request's path: who arrived was relayed,
encryption terminated somewhere, the certificate refreshes itself. The whole path runs start to
finish for every request.

The measurement network's dashboard asks for the same regional map, the same meter list, and the
same nightly summary file every time it opens. Most of these requests produce the same response.
The response can be held somewhere — but "somewhere" is two separate places. **The edge cache**
sits close to the client and never sends the request across the network at all. **The origin
cache** sits in front of the app and keeps the app from generating the same response twice.

What a cache is, how the hit rate is calculated, and what freshness means were measured before;
this lesson does not repeat that mechanics. What is measured here is three things: how the hit
rate **splits** between the two layers, how many more seconds the old response stays at the edge
after a piece of content changes, and the surface a wrong cache key opens.

- **WS25.** The request stream is produced by our own generator; the seed `20260` is visible and
  gives the same sequence run to run. Two thousand requests are spread across six hundred
  seconds.
- **WS26.** There are five pieces of content and their weights are fixed; one (`/invoice/summary`)
  is personal, meaning its response varies by the subscriber making the request.
- **WS27.** A subscriber always lands on the same edge node: the node is the remainder of the
  subscriber's ID divided by the node count.
- **WS28.** The default cache key is **the path alone**; identity does not enter the key. Two
  more options are measured: adding identity to the key, and never storing personal content at
  all.
- **WS29.** The time for the invalidation broadcast to reach the three edge nodes is fictional:
  two, five, and eleven seconds. The window is computed against the slowest node.
- **WS30.** The model does not measure time; the second values are derived from the freshness
  settings and the fictional delays, and the count of requests receiving a stale response is
  computed from the changed content's share of traffic.

```js
// cache/layer.mjs — model: a two-layer cache (edge + origin). The fictional measurement network's
// request stream comes from our own generator; the seed is visible. The cache mechanism is not
// redefined; what is measured is the hit split between the two layers and the invalidation window.
const PERIOD = 600, REQUESTS = 2000, SUBSCRIBERS = 6, SEED = 20260;
// [path, weight, type]
const CONTENT = [
  ["/region/map", 40, "shared"], ["/meter/list", 25, "shared"],
  ["/nightly/summary", 15, "shared"], ["/asset/logo", 10, "shared"],
  ["/invoice/summary", 10, "personal"],
];
const TOTAL = CONTENT.reduce((a, x) => a + x[1], 0);
const rng = (seed) => { let s = seed >>> 0; return () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296; };
const pick = (u) => { let e = u * TOTAL; return CONTENT.find((x) => (e -= x[1]) < 0) ?? CONTENT[0]; };

function simulate({ node, hasOrigin, edgeFreshness, originFreshness, key }) {
  const r = rng(SEED);
  const edge = Array.from({ length: node }, () => new Map());
  const origin = new Map();
  const s = { edgeHits: 0, originHits: 0, app: 0, leakedRequests: 0 };
  const leakedSubscribers = new Set();
  for (let i = 0; i < REQUESTS; i += 1) {
    const t = Math.floor((i * PERIOD) / REQUESTS);
    const [path, , type] = pick(r());
    const subscriber = Math.floor(r() * SUBSCRIBERS);
    const personal = type === "personal";
    if (personal && key === "personal-bypass") { s.app += 1; continue; }   // personal content is never cached
    const cacheKey = key === "path+id" && personal ? `${path}#${subscriber}` : path;
    const edgeNode = edge[subscriber % node];
    const record = (entry) => {                         // whose response answered the hit
      if (personal && entry.subscriber !== subscriber) { s.leakedRequests += 1; leakedSubscribers.add(entry.subscriber); }
    };
    const hit = edgeNode.get(cacheKey);
    if (hit && t - hit.t < edgeFreshness) { s.edgeHits += 1; record(hit); continue; }
    const originHit = hasOrigin ? origin.get(cacheKey) : undefined;
    if (originHit && t - originHit.t < originFreshness) {
      s.originHits += 1; record(originHit); edgeNode.set(cacheKey, { t, subscriber: originHit.subscriber }); continue;
    }
    s.app += 1;                                          // neither layer hits: the app generates it
    if (hasOrigin) origin.set(cacheKey, { t, subscriber });
    edgeNode.set(cacheKey, { t, subscriber });
  }
  return { ...s, leakedSubscribers: leakedSubscribers.size };
}

const E = [8, 9, 18, 12, 14, 14, 13, 13];
const write = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](E[i])).join("");
console.log(write(["node", "origin", "key", "edge hits", "origin hits", "reaches app",
  "leaked req", "leaked sub"]));
const SETTING = [
  [1, false, "path"], [3, false, "path"], [3, true, "path"],
  [3, true, "path+id"], [3, true, "personal-bypass"],
];
for (const [node, hasOrigin, key] of SETTING) {
  const o = simulate({ node, hasOrigin, edgeFreshness: 60, originFreshness: 300, key });
  console.log(write([node, hasOrigin ? "yes" : "no", key, o.edgeHits, o.originHits,
    o.app, o.leakedRequests, o.leakedSubscribers]));
}

// --- Invalidation's propagation window -------------------------------------
// Content at the origin changed at t=0. The window is how many seconds the edge keeps the old
// content. Broadcast delay is fictional: it reaches three edge nodes at these seconds, in order.
const DELAY = [2, 5, 11];
const CHANGED = CONTENT[0];                             // changed content: /region/map
const RATE = (REQUESTS / PERIOD) * (CHANGED[1] / TOTAL);   // req/second arriving for that content
function window(method, edgeFreshness, originFreshness) {
  if (method === "none") return edgeFreshness + originFreshness;      // the two freshnesses stack
  if (method === "at-origin") return edgeFreshness;                    // the edge waits out its own duration
  if (method === "every-layer") return Math.max(...DELAY);
  return edgeFreshness;                                                // one node missed the broadcast
}
const P = [18, 16, 18, 12, 24];
const write2 = (h) => h.map((d, i) => String(d)[i ? "padStart" : "padEnd"](P[i])).join("");
console.log("\n" + write2(["invalidation", "edge freshness", "origin freshness", "window(s)",
  "stale responses served"]));
for (const [y, ef, of] of [["none", 60, 300], ["none", 10, 60], ["at-origin", 60, 300],
  ["at-origin", 10, 300], ["every-layer", 60, 300], ["one-node-missed", 60, 300]]) {
  const p = window(y, ef, of);
  console.log(write2([y, ef, of, p, Math.round(p * RATE)]));
}
```

```
node       origin               key   edge hits   origin hits   reaches app   leaked req   leaked sub
1              no              path        1950             0            50          169            5
3              no              path        1857             0           143           95            6
3             yes              path        1857           133            10          179            2
3             yes           path+id        1837           143            20            0            0
3             yes   personal-bypass        1664           107           229            0            0

invalidation        edge freshness  origin freshness   window(s)  stale responses served
none                            60               300         360                     480
none                            10                60          70                      93
at-origin                       60               300          60                      80
at-origin                       10               300          10                      13
every-layer                     60               300          11                      15
one-node-missed                 60               300          60                      80
```

## The Cost of Splitting the Edge

The first two rows pass the same two thousand requests through the same freshness settings. The
only thing that changes is the edge node count.

With a single node, the edge hit count is nineteen hundred fifty, and requests reaching the app
are fifty. When the edge splits into three nodes, hits drop to eighteen hundred fifty-seven, and
requests reaching the app climb from **fifty to one hundred forty-three**. Roughly triple.

The reason is direct: every node has its own cache and requests the same content three times,
separately. When a piece of content refreshes, it is no longer one request but three that reach
the app. The edge node count is a scaling decision — more nodes means a response closer to the
client — and it **multiplies the load reaching the app by the node count**.

This is the two-layer cache's reason for existing. The third row adds the origin cache: the edge
hit count does not change (eighteen hundred fifty-seven), one hundred thirty-three requests are
met at the origin cache, and requests reaching the app drop to **ten**. The origin cache absorbs
nearly all of the request the three nodes multiplied.

Read together, the numbers make the origin cache's job visible: it does not reduce the request
coming from the edge itself, it keeps that request from **reaching the app**. The third row's app
load is even lower than the single-node first row's — the origin cache absorbs not just the node
multiplication but a single node's own freshness refills too.

## The Propagation Window

The second table measures another question. The regional map changed; when does the new map
become visible?

The first row is the default state: no invalidation, both layers wait out their own freshness
duration. The window is sixty plus three hundred, that is, **three hundred sixty seconds**. The
two freshnesses add up because they stack: when the edge pulls a copy from the origin, that copy
may have already waited up to three hundred seconds at the origin, and the edge holds it for
sixty seconds more. Over the window, four hundred eighty requests for the changed content get the
old map.

The second row shortens the freshness settings: ten and sixty instead of sixty and three hundred.
The window drops to seventy seconds, stale responses served drop to ninety-three. The cost does
not show up in this table, but it can be read from the first table: as freshness shortens, the
hit rate drops and requests reaching the app rise. Shortening freshness is the **expensive** way
to close the window.

The third row sends invalidation to the origin. The origin cache clears immediately, the edge
waits out its own sixty seconds: the window is sixty seconds, stale responses served are eighty.
A gain from three hundred sixty down to sixty is achieved with a single message sent to the
origin.

The fourth row sends invalidation to the origin while also dropping the edge freshness to ten:
the window is ten seconds. The fifth row broadcasts invalidation to every layer: the window drops
to the slowest edge node's delay, **eleven seconds**, and edge freshness no longer determines the
window at all.

The order matters here. Shortening the freshness setting closes the window and drops the hit
rate; broadcasting invalidation closes the window and does not touch the hit rate. The fifth row
runs with a sixty-second edge freshness — that is, the first row's hit rate — and its window is
eleven seconds.

## Where the Broadcast Misses

The sixth row shows what the fifth row cannot close. Invalidation broadcasts to every layer, but
one edge node does not get the message: a network outage, the node restarting, a queue
overflowing. On that node, the window falls back to the edge freshness: **sixty seconds**.

This is the new surface invalidation introduces. The freshness duration provides an **upper
bound**, and it holds even if nothing else is done. The invalidation broadcast does not provide
an upper bound; it provides **a hope**, and when the hope does not hold, the system silently
falls back to the upper bound. The system's state is eleven seconds on two nodes, sixty seconds
on one node, and no one watching from outside knows which node they landed on.

The conclusion is that freshness must be **kept** alongside the invalidation broadcast. The
broadcast provides the fast path, freshness provides the fallback bound. Stretching freshness to
infinity and pinning everything on the broadcast turns a single missed message into indefinitely
stale content.

## The Shared-Key Surface

The last columns are the lesson's surface measurement. The default cache key is the path alone,
and `/invoice/summary` is personal: every subscriber gets their own invoice summary.

In the first row, one hundred sixty-nine requests received **another subscriber's** invoice
summary; five subscribers' data went to someone else. In the three-node second row, leaked
requests drop to ninety-five, but the leaked-subscriber count climbs to six — splitting does not
reduce the leak, it spreads it.

The third row is the sharpest. When the origin cache is added, leaked requests climb to one
hundred seventy-nine, and the leaked-subscriber count drops to **two**. A single shared origin
entry distributes two subscribers' invoices to all three edge nodes at once. The origin cache
concentrates the leak: fewer people's data, to more people.

The name of this surface is **the shared-key surface**. Opening it does not require a bug; it is
enough for the default key to include the path and not the identity. The knowledge that a
response is personal lives in the app, not in the cache, and neither layer asks for that
knowledge.

The fourth row adds identity to the key: leaked requests zero, leaked subscribers zero. Requests
reaching the app climb from ten to twenty — since six separate entries are held for six separate
subscribers, freshness refills multiply. For a cost of ten requests, one hundred seventy-nine
leaks closed.

The fifth row never stores personal content at all. The leak is zero again, but requests reaching
the app climb to **two hundred twenty-nine**. There is a twentyfold difference between the two
safe options, and both close the same surface. Setting up the key correctly is twenty times
cheaper than turning caching off.

## The Cache's Own Surface

The two layers dropped the app's load from fifty to ten, and in doing so introduced four new
items.

The first is **the stored response itself**. Already-generated responses now sit at the edge and
the origin; they live somewhere on disk or in memory, and anyone able to read that place reads
the app's responses without authentication. The second is **the invalidation channel**: an
endpoint that can send a message and delete an entry adds two items to the management-endpoint
list the previous lessons counted — one at the edge, one at the origin. If this endpoint has no
identity, an invalidation stream sent from outside can flush the entire cache, and requests
reaching the app climb from ten to two thousand.

The third is **the key rule itself**. As the fifth row showed, the rule is a configuration line,
and getting it wrong throws no error; it just returns another subscriber's response. The fourth
is **the freshness setting being split in two**: the window's real value comes out of two
separate numbers on two separate layers, and no single layer knows that value by itself.

None of the four items show up in the sentence "the cache is on." The course's rule holds here
too: a decision cannot be defended unless the load it closes is written down alongside the
surface it opens. If a step that reduces the requests reaching the app by forty items adds four
items of open surface, both numbers belong on the same line of the report.

## Summary

- Splitting the edge into three nodes dropped edge hits from nineteen hundred fifty to eighteen
  hundred fifty-seven and raised requests reaching the app from fifty to one hundred forty-three;
  the node count multiplies the app's load.
- The origin cache met one hundred thirty-three requests and dropped requests reaching the app to
  ten — lower even than the single-node arrangement; the origin layer's job is absorbing the
  request the edge multiplied.
- With no invalidation, the window is the sum of the two freshnesses: three hundred sixty seconds
  and four hundred eighty stale responses. Invalidation to the origin dropped the window to
  sixty; broadcasting to every layer dropped it to eleven seconds.
- Shortening freshness closes the window and drops the hit rate; broadcasting invalidation closes
  the window and does not touch the hit rate — but on a node that misses the broadcast, the
  window silently falls back to the freshness duration.
- Because the default key contained only the path, one hundred seventy-nine requests received
  another subscriber's invoice summary; the origin cache concentrated the leak — two subscribers'
  data went out in one hundred seventy-nine requests.
- Adding identity to the key zeroed the leak and raised the app load to twenty, not ten; never
  storing personal content also zeroed the leak but raised the load to two hundred twenty-nine.

## Next Step

This lesson set two rules on traffic at the edge and the origin: what gets stored and for how
long it stays fresh. Both rules sat **in the request's path** and applied to traffic coming from
outside.

Services also send requests to each other. The verifier connects to billing, billing to work
orders, work orders to the notification gateway, and none of that traffic passes through the edge
proxy. Timeout, retry, which service can call which — today these rules sit in each service's own
code. The same rules can be moved to a proxy placed next to each service. The next lesson compares
the two arrangements on the same rule change: how many services get touched when a rule changes,
how many hops and how many milliseconds get added to every request, what the sidecar proxy itself
consumes, and what new surface it introduces. In a three-service system, the point where the cost
outweighs the gain is shown with numbers.
