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.
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.
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.
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 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) |
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:
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".
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.
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:
# 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.
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".
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.
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:
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 |
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
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
-
MDN β Cache-Control (the full semantics of
max-age,s-maxage,no-cache,no-store,immutable) -
MDN β the
Varyheader (used in section 6.4) -
MDN β the
ETagheader and revalidation withIf-None-Match(section 6.3) -
IETF RFC 9111 β HTTP Caching (the official spec, including
stale-while-revalidate) - Wikipedia β Anycast (the routing mechanism that sends users to the nearest PoP, section 6.2)
-
nginx β official documentation for
proxy_cache_use_staleandproxy_cache_key(sections 6.3, 6.4)
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
Comments