Lesson 5 put the cache next to the app. This lesson puts it next to the user β€” and the reason isn't an architecture decision, it's a physical limit no amount of code can get around.

This lesson's lab builds a real edge layer with nginx and measures 25x throughput, with the origin only having to handle 4 requests out of 208,173. But the more valuable finding is the second measurement: a very common cache-key misconfiguration makes origin load jump 251x while every client-side metric stays unchanged β€” same throughput, same p50, same p99. It's completely invisible from the outside.

ℹ️ The measurement machine, and one important distinction
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Native arm64 containers (Node v22.23.2), nginx 1.27-alpine, three app replicas at 1 CPU / 256 MB each, the load generator gets 2 CPUs. The edge layer's cache lives on tmpfs β€” deliberately, so the measurement doesn't pick up Docker Desktop's bind-mount overhead, the exact trap hit in Lesson 4 section 4.3.

A distinction worth making: section 6.1 is about geographic distance, and a lab on one computer cannot measure that. Every number in section 6.1 is computed from the speed of light in fiber, with the calculation shown, not measured. From section 6.2 onward, every number is a real lab measurement.

6.1 The limit no code can beat

In optical fiber, a signal travels at roughly $200,000$ km/s β€” slower than the speed of light in a vacuum because of the glass's refractive index. Converted into a more useful unit: 200 km per millisecond. A request has to go there and back, so the minimum round-trip time for a distance of $d$ (km) is:

$$RTT_{min} = \frac{2d}{200} = \frac{d}{100} \text{ ms}$$

That's 1 ms for every 100 km round trip, and that's the number for a straight line of fiber, no intermediate equipment, no queueing. Reality is always 2–3x worse, because cables don't run in straight lines, because every router on the path adds a bit, and because the link carries load.

Route Approx. distance Minimum RTT (computed) RTT typically observed
Hanoi β†’ domestic edge ~20 km 0.2 ms 5–15 ms
Hanoi β†’ Singapore ~2,500 km 25 ms 40–60 ms
Hanoi β†’ Frankfurt ~9,000 km 90 ms 180–250 ms
Hanoi β†’ Virginia (US) ~13,500 km 135 ms 220–280 ms

Now combine this with what Lesson 4 section 4.2 measured: a new HTTPS connection needs 1 RTT for the TCP handshake, plus 1 more RTT for the TLS 1.3 handshake. With the origin in Virginia and a 250 ms RTT, a user in Hanoi spends roughly 500 ms before the first request is even sent. Your backend hasn't received anything yet.

⚠️ Pitfall: optimizing the wrong end of the problem
Optimizing the backend from 50 ms down to 20 ms is a real win β€” but it improves 30 ms out of the 780 ms total a user on the other side of the planet is paying (500 ms handshake + 250 ms for the request + 30 ms processing). Under 4%.

For the same effort, moving content to an edge 20 km from the user turns 250 ms into 10 ms for every round trip. That's why this section opens the lesson: the right question isn't "how do I make the server faster" but "how do I get the data closer". Before optimizing, know where your users are β€” that number changes the priority of everything else.
Same user, two distances β€” 1 ms per 100 km round trip πŸ‘€ Hanoi DOMESTIC EDGE ~20 km 0.2 ms ORIGIN Β· Virginia ~13,500 km 135 ms theoretical Β· 220–280 ms real-world router undersea cable router And every new HTTPS connection needs 2 RTTs before the request is sent (Lesson 4 section 4.2) Total time to first byte, same backend processing 30 ms: via origin: 250 (TCP) + 250 (TLS) + 250 (request) + 30 = ~780 ms via edge: 10 + 10 + 10 + 3 (cache hit) = ~33 ms Optimizing the backend 50 β†’ 20 ms improves 30 ms out of 780 β€” under 4%. Distance is now the dominant variable.
The RTT figures here are computed from the speed of light in fiber, not lab measurements. The point to remember: distance is a constant you cannot fix with code β€” you can only avoid it.

6.2 CDN architecture, and how much the edge layer actually measures

A CDN is a network of many PoPs (points of presence) spread out geographically, each PoP a cache layer sitting in front of your origin. Users always talk to the nearest PoP, and "nearest" is decided by anycast: the same IP address is advertised from many locations, and BGP (Border Gateway Protocol β€” the protocol large networks on the internet use to exchange which routes reach where) routing automatically sends packets to the nearest one on the network, with no extra configuration from you.

Component Role Why it's needed
PoP / edge The cache layer closest to the user Removes most of the geographic distance β€” turns 250 ms into 10 ms
Mid-tier cache A cache layer between the PoPs and the origin With 200 PoPs, every time content expires that's 200 requests to the origin. The mid tier collapses those down to a handful
Origin shield One designated PoP acts as the sole door to the origin The origin only sees traffic from one place β€” easy to rate limit, easy to monitor, and it sharply reduces traffic spikes
Origin Your actual system The only place with the source data; the goal is for it to receive as few requests as possible

The lab builds exactly the first tier: a second nginx, but very different from lb.conf and gw.conf β€” it stores responses instead of just forwarding them. Compared at 32 connections, 10 seconds, origin processing latency 30 ms:

Path Throughput p50 p99 Origin had to process
Calling the app directly 826 rps 38.55 ms 48.89 ms 10,756 / 8,257 requests
Through the cached edge layer 20,815 rps 1.44 ms 3.10 ms 4 / 208,173 requests

Throughput is 25x higher, p50 drops 27x. But the last column is the one worth staring at: the origin processed 4 requests out of 208,173 β€” 0.002%. Everything else was returned straight from nginx's own cache, and the app has no idea any of this is happening.

πŸ’‘ A CDN isn't just for serving images
"CDNs are for static files" is a ten-year-old idea. Read-heavy API responses, pre-rendered HTML, popular search results, even partially personalized pages β€” all of it can be cached at the edge, as long as the headers are right (section 6.3) and the cache key is right (section 6.4).

A useful way to think about it: any response that's identical for many users is a candidate. The question isn't "is this a static file" β€” it's "what does this response actually depend on".

6.3 Getting the caching headers right

The edge layer can't guess on its own how long content should be cached β€” it follows the headers the origin sends. This is the entire API surface between your app and the CDN, and it's smaller than most people think.

Directive What it actually means Easy to misread as
max-age=N Fresh for N seconds, for every cache tier including the browser The browser obeys it too β€” set it too long and there's no way to pull it back
s-maxage=N Only for shared caches (CDN, proxies), overrides max-age This is how you set a long TTL at the CDN but a short one in the browser β€” very useful
public Shared caches are allowed to store it Needed when there's an Authorization header, since the default there is no caching
private Only the browser may store it; the CDN may not Correct for personalized content β€” and the lab confirmed it: always a MISS at the edge
no-cache Still stored, but must be revalidated before every use The most misleading name in HTTP. It does not mean "don't cache"
no-store May not be stored anywhere This is the actual "don't cache". Use it for genuinely sensitive data
immutable Content will never change, don't revalidate Only safe when the URL has the content hash in its name (section 6.5)
⚠️ Pitfall: no-cache does not mean "don't cache"
no-cache means "store it, but ask the origin whether it's still valid before every use". The content still lives in the CDN's cache and the browser's cache.

If you put no-cache on a response with sensitive data and think you're safe, that copy is still sitting on the disk of some proxy somewhere. What you need is no-store. This is exactly the kind of bug that never shows up in a test, because the system keeps behaving correctly.

Revalidation: ETag and 304

When the TTL expires, the cache layer doesn't necessarily have to re-download everything. ETag is an identifier string for one specific version of the content β€” in the lab it's a hash of the body itself, so unchanged content means an unchanged ETag. When the TTL expires, the cache layer resends If-None-Match with the ETag it already has; if that ETag still matches (meaning the content hasn't changed), the origin returns 304 Not Modified with no body. Measured for real in the lab:

revalidation.sh
E=http://localhost:8082

etag=$(curl -s -D - -o /dev/null "$E/cacheable?key=b&ttl=60" \
  | grep -i '^etag:' | cut -d' ' -f2)
echo "ETag = $etag"                    # ETag = "7e9134f4"

curl -s -o /dev/null -w '%{http_code} %{size_download} byte\n' \
  "$E/cacheable?key=b&ttl=60"
# 200 62 byte                          ← downloads the full body again

curl -s -o /dev/null -H "If-None-Match: $etag" \
  -w '%{http_code} %{size_download} byte\n' "$E/cacheable?key=b&ttl=60"
# 304 0 byte                           ← only confirms "still valid", downloads nothing

For a 62-byte payload this saving is meaningless. For an 800 KB JavaScript file, every revalidation saves exactly 800 KB. That's why proxy_cache_revalidate on is in the lab's edge config: it turns "expired" from "have to re-download" into "just have to ask one question".

⚑ An ETag must be stable by content, not by moment in time
If an ETag is generated from the response timestamp (or a random number, or the hostname of the replica serving it), it changes on every single response β€” and 304 will never happen. The system still behaves correctly, it's just that the entire revalidation mechanism silently stops doing anything.

The lab's /cacheable endpoint computes the ETag as a hash of the body itself, so three different replicas still generate the same ETag for the same content. That's a hard requirement once you have multiple replicas β€” and it's an easy thing to get wrong if the ETag comes from a file's on-disk modification time, since three machines have three different clocks.
Four branches of the same request β€” the X-Cache value tells you which branch you're on client edge (nginx) origin (app) HIT Still in cache and still fresh β†’ returned immediately, origin NOT touched measured: 2.3–3.6 ms origin: idle MISS origin processes, edge STORES it measured: 36–44 ms 304 TTL expired β†’ edge sends If-None-Match, origin returns 304 with NO body measured: 0 bytes downloaded STALE βœ• origin is COMPLETELY DOWN TTL expired + origin not responding β†’ edge returns the OLD COPY, 200 OK measured: 200 in 2.4 ms β€” the user sees no incident But STALE only saves a URL that was ALREADY cached. A URL never in the cache: measured 504 after 6.03 seconds.
These four values are four very different states for origin load and for user experience. Without exposing X-Cache, you can't tell them apart.

Serving stale content when the origin is down β€” and its limit

The STALE branch deserves its own measurement, because it's what keeps the site alive. Experiment: warm the cache with a 2-second TTL, then stop all three apps, wait for the TTL to expire, and call again:

origin_down_edge_survives.sh
# 1) warm the cache with TTL 2s
curl -sI "$E/cacheable?key=stale&ttl=2"     # X-Cache=MISS  36.5 ms
curl -sI "$E/cacheable?key=stale&ttl=2"     # X-Cache=HIT    3.6 ms

# 2) stop the whole origin
docker compose stop app1 app2 app3
sleep 3                                      # let the TTL expire

# 3) TTL expired + origin down -> what does edge do?
curl -sI "$E/cacheable?key=stale&ttl=2"     # 200  X-Cache=STALE     2.4 ms
curl -sI "$E/cacheable?key=stale&ttl=2"     # 200  X-Cache=UPDATING  2.5 ms
curl -sI "$E/cacheable?key=stale&ttl=2"     # 200  X-Cache=UPDATING  2.5 ms

# 4) a URL that was NEVER cached, origin still down
curl -sI "$E/cacheable?key=never-cached"    # 504  X-Cache=MISS   6.03 SECONDS

The three lines at step 3 are the best possible outcome: the origin is completely dead and the user still gets a 200 OK in 2.4 ms. UPDATING means nginx is trying to refresh in the background while still serving the old copy to the user β€” exactly the point of proxy_cache_background_update.

But step 4 is the line to remember. A URL that was never in the cache gives the edge nothing to return: 504 after 6.03 seconds. And that 6-second figure isn't random β€” it's 3 upstreams Γ— proxy_connect_timeout 2s, the exact mechanism seen back in Lesson 3. A user waits 6 seconds and then gets an error, much worse than getting the error right away.

πŸ”¬ Design consequence: cache warmth is part of your incident plan
proxy_cache_use_stale only protects the part of the content currently in the cache. That means how well you tolerate an incident depends directly on how much of the cache was populated at the moment the incident happened β€” a variable almost nobody tracks.

Two practical consequences. First: don't flush the cache and then deploy β€” if the origin runs into trouble right after a deploy, you've just thrown away your only layer of protection. Second: for the most important URLs β€” the homepage, the login page, a handful of core APIs β€” pre-warm them into the cache after every deploy, and lower proxy_connect_timeout so that when there's no stale copy to fall back on, it fails fast instead of making the user wait 6 seconds.

6.4 Cache key and Vary β€” where hit ratio quietly goes to 0

The cache key decides whether two responses count as "the same thing" or not. It's made up of the URL (nginx's default is $scheme$proxy_host$request_uri, meaning the entire query string is included) plus whatever headers the origin declares in Vary.

These two mechanisms are the two most common ways to destroy hit ratio, and neither one produces any error β€” the config still looks like "caching is on".

The SAME content, four cache copies β€” two ways to fragment a cache 1 Β· TRACKING PARAMETER in the URL (raw key) /doc?utm_source=facebook /doc?utm_source=twitter /doc?fbclid=abc123 /doc?gclid=xyz789 β†’ 4 cache keys Β· measured 4/4 MISS content: identical identical identical 2 Β· NORMALIZED KEY (allowlist) proxy_cache_key "$uri|key=$arg_key" utm_source, fbclid, gclid are not part of the key β‡’ the four URLs above become the SAME key β†’ 1 cache key Β· measured 1 MISS + 3 HIT 3 Β· Vary: Cookie β€” the costliest pitfall Origin sends "Vary: Cookie" β‡’ the cache key includes the cookie value. And every user has their own cookie… Cookie: sid=user1 β†’ MISS sid=user2 β†’ MISS sid=user3 β†’ MISS sid=user1 2nd time β†’ HIT β‡’ EVERY USER GETS THEIR OWN CACHE COPY. Hit ratio drops to near 0 at scale. Drop "Vary", same three cookies: measured 1 MISS + 2 HIT. "Vary: Accept-Encoding" is reasonable (gzip and br really are different content). "Vary: User-Agent" fragments the cache into thousands of copies. "Vary: Cookie" fragments it into one-copy-per-user.
All three scenarios were verified with X-Cache in the lab. None of them produce an error β€” that's exactly what makes them dangerous.

Measuring the damage: 251x more origin load, and completely invisible

Verified with numbers at load scale. Same content, but the load generator attaches a utm_source parameter drawn randomly from 1,000 values β€” exactly like real traffic from ad campaigns. Comparing two cache-key configs, 32 connections, 10 seconds:

Cache-key config Throughput p50 p99 Origin had to process
Raw β€” includes the whole query string 20,866 rps 1.43 ms 3.10 ms 1,003 requests
Normalized β€” only the allowed parameters 20,552 rps 1.46 ms 3.15 ms 4 requests

Origin load differs by 251x. And the three prior columns β€” throughput, p50, p99 β€” are indistinguishable from each other. From the client's side, these two configurations are identical. No latency dashboard, no alert, no error tells you anything.

The number 1,003 is also worth reading closely: 1,000 distinct utm_source values produce exactly 1,000 distinct cache keys, one MISS per key. The cache is still "working" β€” it's just storing 1,000 copies of the same content, and none of them ever get reused.

⚠️ This only shows up in one place
Cache fragmentation doesn't show up in client-side latency. It only shows up in origin load and the HIT/MISS ratio at the edge tier. Meaning if you don't monitor those two numbers, there's no way to catch it.

Specifically you need: a log or metric of $upstream_cache_status at the edge, and the requests-per-second the origin actually receives. When traffic doubles, the origin's number tells you immediately whether the cache is actually shielding it or just pretending to. This is the exact same lesson as "judge a cache by hit ratio, not by whether it feels faster" from Lesson 5 section 5.1 β€” viewed from the edge tier.

The config that produces both behaviors above, one line apart:

nginx/edge.conf
location /cacheable {
    # RAW KEY: nginx's default, includes the ENTIRE query string.
    # ?utm_source=facebook => a separate cache entry for the exact same content.
    proxy_cache_key "$scheme$proxy_host$request_uri";
    proxy_pass http://edge_origin;
    include /etc/nginx/snippets/edge-common.conf;
}

location /norm/cacheable {
    # NORMALIZED KEY: only lists the parameters that ACTUALLY decide the content.
    # nginx has no loop, so this "allowlist" is spelling out every $arg_* we accept
    # by hand. Tracking params (utm_*, fbclid, gclid) are ignored simply because
    # they are NOT part of the key.
    proxy_cache_key "norm|$uri|key=$arg_key|ttl=$arg_ttl|vary=$arg_vary";
    proxy_pass http://edge_origin/cacheable$is_args$args;
    include /etc/nginx/snippets/edge-common.conf;
}

6.5 Purge, versioned URLs, and how to blank the page for every user

When content changes, there are two ways for users to see the new version: delete the old copy from the cache (purge), or change the URL so the new version lives at a different address. The second option is almost always better, for a very practical reason.

Purge Versioned URL
Mechanism Call the CDN's API to delete a URL from every PoP Put a content hash in the file name: app.a3f9c1.js
Time to take effect Seconds to minutes, varies by PoP Instant β€” the new URL was never cached anywhere
Can you verify it Hard: you don't know which PoP finished purging Yes: different URL means different content, nothing to take on faith
Rolling back Have to redeploy and purge again Just point back at the old URL β€” the old copy is still intact in the cache
TTL you can set Has to be short, since it still needs to be changeable max-age=31536000, immutable β€” one year

From that comes a very tidy rule for setting headers, broken down by resource type:

Resource type Cache-Control Why
JS/CSS with a hash in the name public, max-age=31536000, immutable Content never changes; changing content means changing the file name
Images, fonts public, max-age=2592000 Changes rarely; a month is a safe margin
HTML public, max-age=0, s-maxage=60, must-revalidate This is the file that points at other assets β€” must always be fresh, but the CDN is still allowed to hold it for 60 seconds
Read API shared by many users public, s-maxage=30, stale-while-revalidate=60 The edge serves the old copy while refreshing; nobody has to wait
Personalized API private, no-store Must never leak into a shared cache
⚠️ Pitfall: blanking the page for every single user
This scenario happens more often than it should. You deploy: the new asset is app.b7d2e4.js, and the old asset app.a3f9c1.js gets deleted from the origin. But the HTML is cached at the edge with a long TTL, so it's still pointing at app.a3f9c1.js β€” a file that no longer exists.

Result: the user gets the old HTML, that HTML requests a file that 404s, the JavaScript never loads, blank page. And a purge can't save you in time, because it needs a few minutes to take effect everywhere.

Two safeguards, do both: (1) HTML must always have a short TTL or be forced to revalidate β€” it's the routing file, not the content itself; (2) keep the previous deploy's assets around for at least a few hours instead of deleting them right away. Option (2) is cheap enough that there's no reason not to do it: a few hundred KB of disk in exchange for never hitting this scenario.

Reproducing the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile edge up -d
E=http://localhost:8082
purge() { docker compose exec -T edge sh -c 'rm -rf /var/cache/nginx/edge/*'; }

# --- MISS then HIT ---
purge
for i in 1 2 3; do
  curl -s -o /dev/null -D /tmp/h -w "%{time_total}s " "$E/cacheable?key=a&ttl=60"
  grep -i '^x-cache:' /tmp/h
done

# --- Vary: Cookie fragments the cache ---
purge
for c in user1 user2 user3 user1; do
  curl -s -o /dev/null -D /tmp/h -H "Cookie: sid=$c" \
    "$E/cacheable?key=e&ttl=60&vary=cookie"
  echo "sid=$c -> $(grep -i '^x-cache:' /tmp/h)"
done

# --- Origin load: RAW key vs NORMALIZED key (1000 random utm_source values) ---
purge && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://edge:8082/cacheable?key=a&ttl=60" \
  -c 32 -d 10 -w 3 --key-space 1000 --key-param utm_source --json
./tools/cache-stats.sh          # see how many requests the origin actually processed

purge && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js \
  --url "http://edge:8082/norm/cacheable?key=a&ttl=60" \
  -c 32 -d 10 -w 3 --key-space 1000 --key-param utm_source --json
./tools/cache-stats.sh          # compare: 1,003 vs 4

Summary

Geographic distance is a physical constant: 1 ms for every 100 km round trip, and a new HTTPS connection needs 2 RTTs before the request is even sent. There's no way to fix this with code β€” only to place the data closer. The lab's edge layer delivers 25x throughput and keeps 99.998% of requests away from the origin, using nothing but nginx and proxy_cache.

The three easiest mistakes all live in the header and cache-key area, and none of them produce an error: no-cache still caches (you need no-store to actually not store it); an ETag generated from a moment in time instead of from content means 304 never happens; and a cache key containing a tracking parameter makes origin load jump 251x while not a single client-side metric changes at all.

Finally, proxy_cache_use_stale keeps the site alive when the origin goes down β€” but only for URLs that were already in the cache. That turns cache coverage into part of your incident plan, and turns "flush the cache and then deploy" into something worth rethinking.

Lesson 7 leaves the cache tier and goes down to the data tier: replication. The lab will have a real PostgreSQL primary and replica, measure replication lag with pg_stat_replication, and reproduce by hand the bug every system with a replica runs into β€” a user changes their profile photo, reloads, and sees the old one.

πŸ“– Further reading

Download the lab source

The edge tier's nginx config from the lab, containing both cache-key locations (raw and normalized) measured in section 6.4:

Download edge.conf

Related lessons in this series

Lesson 5: Caching: Cache-Aside, TTL & Invalidation Lesson 7: Replication & Scaling the Read Tier Back to the System Design roadmap

Comments