Lesson 3 put nginx in front of three replicas and gave it exactly one job: splitting load. But the component sitting in that spot — where every request must pass through — can do far more: terminate TLS, route by path, serve static files, reject requests missing a token, rate-limit. This lesson keeps the same nginx but hands it those jobs, then measures what each one is actually worth.

Two measurements in this lesson went against what I predicted while writing the outline. First: the advice "let the gateway serve static files so the app doesn't have to" turned out to make the gateway 2.6x slower than forwarding the request to the app — until adding exactly one config directive. Second: "TLS costs about 10%" is a number I measured myself, and one that gets quoted a lot, but it only holds under one very specific condition; drop that condition and the cost becomes nearly 16x.

ℹ️ The machine every figure in this lesson was measured on
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Containers run native arm64 (Node v22.23.2). Each app replica is limited to 1 CPU / 256 MB, nginx is 1.27-alpine, and the load generator gets 2 CPUs. Every experiment was repeated at least 3 times and the lesson reports the range. Absolute numbers depend on your machine — what to take away is the ratio between the two configurations being compared.

4.1 Three names for the same position

"Reverse proxy", "load balancer" and "API gateway" often get talked about as three different products. In reality they are three levels of responsibility assigned to the same position in the system: the hop standing between the client and the services. The same nginx process in this series' lab plays all three roles in turn.

Same position, three levels of responsibility — each level includes the one before it 1 · REVERSE PROXY — hides and forwards Receives requests on behalf of the real server · client doesn't know the internal address · one single entry point The question it answers: "send this request WHERE?" State it must keep: none 2 · LOAD BALANCER — proxy + pick among multiple copies Distribution algorithm · health checks · pulls a dead node out of the pool (Lesson 3) The question it answers: "among N identical copies, pick WHICH ONE?" State it must keep: the health and connection count of every node 3 · API GATEWAY — load balancer + understands the API TLS termination · routes by path/version · authentication · rate limiting · aggregates multiple services The question it answers: "WHAT API is this request for, from WHOM, and is it ALLOWED?" State it must keep: public keys, quotas, the route table, TLS sessions The tradeoff: knowing the business => prone to bloating into a place logic accumulates (section 4.4) There's no product boundary here — only a responsibility boundary. Climb to a higher level only when you genuinely need what that level provides.
Each level adds power, and adds state that has to be managed. State is the real cost, not CPU.

One more easily-confused pair worth separating: forward proxy and reverse proxy. A forward proxy sits next to the client and represents the client (a company's outbound proxy to the Internet, say); a reverse proxy sits next to the server and represents the server. Same technology, opposite direction, and whoever configures it is different too.

💡 Rule of thumb for picking a level
Just one server and you want to hide it behind TLS: a reverse proxy is enough. Multiple identical copies: you need a load balancer. Multiple different services that the client shouldn't have to know the boundaries between: that's when an API gateway earns its keep. Standing up a gateway for a single service is adding one more point of failure for nothing in return.

This lesson's lab

The series' shared lab gets a new gw profile. The important design detail: the HTTP port (8081) and the HTTPS port (8443) include the exact same route file, so any measured difference between the two ports comes purely from TLS, with no configuration drift mixed in:

setup_lab.sh
cd blog/sysdesign/sysdesign-lab

# 1. Generate a SELF-SIGNED cert for the gateway (lab only, never use it anywhere else)
mkdir -p nginx/certs
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout nginx/certs/lab.key -out nginx/certs/lab.crt \
  -subj "/CN=localhost"

# 2. Start the gateway + 3 app replicas
docker compose --profile gw up -d

# 3. Check that both ports are alive
curl -s  http://localhost:8081/gw-health      # -> gateway ok
curl -sk https://localhost:8443/gw-health     # -> gateway ok  (-k: skip cert verification)

# 4. Gateway strips the /api/ prefix before forwarding
curl -s http://localhost:8081/api/whoami
# -> {"instance":"app1", ...}   the app has NO idea the /api/ prefix even exists

If both curl commands in step 3 return gateway ok, the gateway is running correctly and ready for the measurements ahead. Step 4 confirms the single most important fact in this lesson: the gateway and the app speak two different paths — the client calls /api/whoami, the app only ever sees /whoami.

4.2 TLS termination and the truth about X-Forwarded-For

TLS termination means the gateway decrypts HTTPS and speaks plain HTTP to the services behind it. The reason is purely practical: the certificate lives in exactly one place (renew it once instead of N times), services don't need to know anything about TLS, and the gateway can read the content, which is what makes path-based routing possible in the first place.

How much does TLS cost?

Same route /api/fast, 8 concurrent connections, 8 seconds measured, three repeats — the only difference is whether the entry port is HTTP or HTTPS:

Port Throughput (req/s) p50 p99
8081 — HTTP 22,950 / 22,799 / 21,939 0.33–0.35 ms 0.70–0.76 ms
8443 — HTTPS 20,494 / 20,881 / 19,828 0.36 ms 0.88–0.96 ms
Difference ~9–10% lower +0.02 ms +0.18 ms

About 10% throughput, and the extra latency is far smaller than even a trivial database query. The conclusion looks obvious: TLS is cheap, turn it on. But this number is hiding a condition.

The 10% figure only holds with keep-alive

The lab's load generator defaults to persistent connections (keep-alive): open 8 connections and push hundreds of thousands of requests through them. What's expensive about TLS is the handshake — asymmetric key exchange, one or two network round trips — while the symmetric encryption that follows is very cheap. With keep-alive, the handshake cost gets divided across tens of thousands of requests and nearly disappears.

The load generator has a --no-keepalive flag that forces every request to open a new connection. Same route, same connection count:

Configuration Throughput (req/s) p50 p99
HTTP + keep-alive ~22,600 0.34 ms 0.73 ms
HTTPS + keep-alive ~20,400 0.36 ms 0.91 ms
HTTP, one connection per request 7,006 / 7,375 1.07–1.11 ms 1.66–1.88 ms
HTTPS, one connection per request 1,290 / 1,231 5.96–6.21 ms 12.97–13.39 ms

Drop keep-alive, and HTTP loses 3x throughput because it has to redo the TCP handshake every time. But HTTPS loses nearly 16x compared to itself with keep-alive (20,400 req/s down to about 1,260 req/s), and p50 jumps from 0.36 ms to over 6 ms — 17x. That's the real shape of TLS cost: it doesn't live in every request, it lives in every connection.

🔬 Why this matters more than the 10% figure
If you benchmark with a tool that uses keep-alive and conclude "TLS costs 10%", that number is correct for the benchmark but can be wrong in production. Real clients sometimes don't hold connections open: a cron job calls once and exits, a misconfigured HTTP library never enables pooling, a mobile device keeps dropping its network. For that group of clients, every request is a full handshake.

Three things worth doing, in order of impact: (1) enable ssl_session_cache on the gateway so a reconnect can reuse an old session — the lab already has it on, which means the nearly-16x figure above is still an optimistic one; (2) enable HTTP/2 so multiple requests share one connection — the lab already has this on too (http2 on; on port 8443, see nginx/gw.conf), so the cost measured above already accounts for this benefit; (3) check whether your client is actually using a connection pool — this is the most common place to get it wrong, and also the easiest to fix in your own code.

What information does the app lose after decryption?

The TCP connection the app sees is a connection from the gateway, not from the client. So the app's socket.remoteAddress is always the gateway's IP, and the app no longer knows whether the client came in over HTTP or HTTPS. That information has to be forwarded through headers — and this is exactly where a very common vulnerability comes from.

The journey of X-Forwarded-For — every trusted proxy APPENDS to the end of the chain CLIENT (attacker) Real IP: 192.168.65.1 sends its own header: XFF: 1.2.3.4 GATEWAY (nginx) $proxy_add_x_forwarded_for = value client sent + "," + real IP KEEPS what the client sent APP receives XFF: 1.2.3.4, 192.168.65.1 red = forged by the client · green = written by the gateway remoteAddress = the gateway's IP Same string, two ways to read it — opposite results WRONG · reads the FIRST element xff.split(",")[0] => 1.2.3.4 — exactly the value the attacker forged Consequence: bypasses IP rate limits · corrupts logs · bypasses allowlists This is the default in a lot of code copied from the internet. CORRECT · counts from the RIGHT chain[chain.length - TRUST_PROXY_HOPS] => 192.168.65.1 — the real IP, one the client cannot forge Because every TRUSTED hop only appends to the END of the chain The hop count must be a FIXED number you know for certain, not a guess. General rule The RIGHT side of the chain is written by your own infrastructure => trustworthy. The LEFT side is written by the outside world => not trustworthy. The number of trustworthy elements equals the number of proxy hops you actually operate. There's no way to derive it from the chain itself. Safest when the gateway is the first hop: OVERWRITE the whole header instead of appending.
X-Forwarded-For is a string whose front is written by an outsider. Reading from the wrong end is a vulnerability, not a display bug.

The lab has a /client-ip endpoint that exists purely to demonstrate this. Run it for real, with a forged header attached:

spoof_x_forwarded_for.sh
curl -s -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8081/api/client-ip

# {"instance":"app1",
#  "remoteAddress":"::ffff:172.21.0.5",              ← the GATEWAY's IP, not the client's
#  "xForwardedForRaw":"1.2.3.4, 192.168.65.1",       ← spoofed value + real IP
#  "trustProxyHops":0,
#  "trustedClientIp":"::ffff:172.21.0.5",
#  "naiveClientIp":"1.2.3.4"}                  ← WRONG: took spoofed value verbatim

# ---- Fix 1: gateway OVERWRITES the header (route /safe-api/) ----
curl -s -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8081/safe-api/client-ip

# "xForwardedForRaw":"192.168.65.1"                  ← spoofed value wiped out
# "naiveClientIp":"192.168.65.1"                     ← correct no matter how you read it

# ---- Fix 2: app counts from the right ----
# Set TRUST_PROXY_HOPS='1' for all three apps in docker-compose.yml (default is '0'),
# then RECREATE the containers - `restart` does not reload environment variables:
#   docker compose --profile gw up -d --force-recreate app1 app2 app3
curl -s -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8081/api/client-ip

# "xForwardedForRaw":"1.2.3.4, 192.168.65.1",
# "trustProxyHops":1,
# "trustedClientIp":"192.168.65.1"                   ← CORRECT
# "naiveClientIp":"1.2.3.4"                          ← still wrong, kept for comparison

The nginx config that produces those two behaviors differs by exactly one line:

nginx/gw-routes.conf
location /api/ {
    proxy_pass http://api_pool/;
    # APPEND: keep what the client sent, add the real IP at the end.
    # Use this when there is ANOTHER trusted proxy in front of the gateway (a CDN,
    # say) and the app needs the whole chain. The app MUST read from the right.
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP       $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
}

location /safe-api/ {
    proxy_pass http://api_pool/;
    # OVERWRITE: wipe out everything the client sent. Use this when the gateway is
    # the FIRST hop touching the Internet. This is the safest default.
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Real-IP       $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
}
⚠️ Pitfall: a "trust proxy" switch flipped carelessly
Many frameworks have a switch like trust proxy. Setting it to "true" usually means "trust the whole chain", which is exactly the wrong way to read it. The correct value is always a specific number: the number of proxy hops you actually operate. If you don't know that number, don't flip the switch yet — let the gateway overwrite the header instead.

And this isn't a cosmetic bug: a forged X-Forwarded-Proto can make the app think the request already arrived encrypted and skip the redirect to HTTPS; a forged XFF disables every IP-based rate limit.

4.3 Path-based routing — and a measurement that goes against prediction

The most obvious job of a gateway: look at the path, decide where to send it. /api/orders/* to the orders service, /api/users/* to the users service, /static/* answered by the gateway itself. The client sees a single domain and has no idea how many services sit behind it.

⚠️ Pitfall: the trailing slash in proxy_pass
proxy_pass http://api_pool/; — with a trailing / — strips the /api/ prefix before forwarding, so the app receives /fast.
proxy_pass http://api_pool; — without the trailing / — keeps it as-is, the app receives /api/fast and returns 404.

One character, two different behaviors, and the only error you get back is a 404 from the app. Quick check: call /api/whoami; if it comes back 404, check the trailing slash before suspecting anything else.

"Let the gateway serve static files" — measure before you believe it

Classic advice: let the gateway serve static files directly, and every file it returns is one request the app doesn't have to handle. nginx reads files far faster than Node, so this sounds like it doesn't even need checking. I measured it anyway: 16 connections, 8 seconds, three repeats, comparing /static/hello.json (nginx serves it directly) against /api/fast (forwarded to the app):

Route Throughput (req/s) p50 p99
/static/ — served directly by nginx, default config 8,623 / 9,260 / 8,842 1.53–1.63 ms 3.78–4.51 ms
/api/fast — forwarded to Node 22,768 / 22,680 / 22,720 0.58–0.59 ms 1.38–1.41 ms

The static file is 2.6x slower than proxying to a Node process. This is completely backwards from the prediction, so it's one of two things: either the advice is wrong, or I'm measuring something different from what I think I am.

Hypothesis: the static directory is bind-mounted from the host machine into the container. On Docker Desktop (macOS/Windows), a bind-mount crosses the VM boundary, so every syscall against that file is noticeably more expensive. And by default nginx reopens the file on every request: stat(), open(), read(). If the hypothesis is right, caching the metadata and the file descriptor should erase most of the gap. Four lines:

nginx/gw-routes.conf
location /static/ {
    alias /usr/share/nginx/static/;
    expires 1h;

    # Cache metadata + file descriptor instead of stat()+open()+read() every request.
    open_file_cache          max=1000 inactive=60s;
    open_file_cache_valid    60s;
    open_file_cache_min_uses 1;
    open_file_cache_errors   on;
}
/static/ configuration Throughput (req/s) p50 p99
Default 8,623 / 9,260 / 8,842 1.53–1.63 ms 3.78–4.51 ms
With open_file_cache 21,580 / 21,809 / 21,343 0.67–0.69 ms 1.45–1.50 ms
Baseline: proxying to Node ~22,720 0.59 ms 1.40 ms

2.4x faster, p50 drops from 1.6 ms to 0.68 ms. The hypothesis holds: the bottleneck wasn't nginx, it was the filesystem underneath it. After the fix, the static route is nearly as fast as the proxy route — still a bit behind, and with a file larger than the lab's static/hello.json (94 bytes) the advantage would clearly tip toward nginx: the fixed cost per request (internal TCP handshake, event loop) makes up a bigger share when the file is tiny, while nginx reads a larger file straight from cache without spending any CPU on JavaScript.

🔬 A lesson bigger than the numbers
The advice "let the gateway serve static files" isn't wrong — but it implicitly assumes the file sits on fast storage or in cache. Every piece of architectural advice carries an assumption about the environment, and that assumption usually goes unwritten.

More notable still: if I had measured once and stopped, this lesson would have concluded "nginx serves static files slowly" — wrong, but convincingly wrong, because there were numbers attached. A measurement that contradicts your prediction is not a conclusion — it's a question. The next step is always to form a specific hypothesis and try to prove it wrong.

Versioning the API

When an API has to change in a backward-incompatible way, the gateway is the natural place for two versions to coexist. Three common approaches:

Approach Example Gains Costs
In the path /v1/orders, /v2/orders Visible right in the log and the URL; the gateway routes with the mechanism it already has Version bleeds into the resource; cache and bookmarks get split in two
Via header Accept: application/vnd.api.v2+json URL keeps its meaning as a "resource"; matches the spirit of HTTP Invisible in the URL, so harder to debug; easy to forget when calling with curl
Via query string /orders?version=2 Easy to try quickly Easy to miss; many cache layers ignore the query string when building a key

In practice, path-based versioning wins in most systems for a very practical reason: when there's an incident at 2 a.m., you can read the version right off the log line without opening anything else.

The gateway is also the place to canary: push a small slice of traffic to the new version before cutting over completely. nginx does this with split_clients, and the important part is that it hashes on a stable key so one client always sees the same version:

canary_5_percent.conf
# Hash on IP (or better: on the user id in a cookie) => ONE client always sees
# the SAME version. Hashing on $request_id instead makes the same person bounce
# between v1 and v2 across requests — a bug that's very hard to reproduce.
split_clients "${remote_addr}canary" $api_backend {
    5%      api_v2;
    *       api_v1;
}

location /api/ {
    proxy_pass http://$api_backend/;
}

5% here is a cautious starting point, not a fixed number — ramp it up (5% → 25% → 100%) as long as api_v2's separately measured error rate and latency stay healthy, and keep an immediate rollback to 0% ready in case they don't.

4.4 Cross-cutting concerns: what belongs on the gateway

There's a group of jobs every service needs: authentication, rate limiting, logging, compression, CORS. If every service implements its own, you get N implementations and N chances for one of them to get it wrong. The gateway is the place to do it once. But not everything belongs up there.

Task Where Why
Terminate TLS, compress, log access Gateway Purely technical, needs no business knowledge, identical for every service
Check that a token is still valid (signature, expiry) Gateway Blocks junk requests before they consume service resources
Does this person have permission to edit order #42 Service Needs business data only the service has. A gateway that knows this has already started to bloat.
Rate-limit by IP or by API key Gateway Needs one shared counter; counting separately per replica means the real limit is N times the declared one
Enforce a business quota (plan tier, account balance) Service This is a business rule, it changes with the product, not with the infrastructure
Validate input Both The gateway does coarse checks (size, content type); the service checks meaning, and must never assume the gateway already checked

The boundary comes down to one question: does this task need to know the business? If not, gateway. If it does, service. Applying that question to every task is the only method I know of to keep the gateway from slowly turning into a place where logic accumulates.

Aggregating multiple services: latency equals the slowest branch

A single screen often needs data from several services. If the client calls each one itself, it eats the full network latency of doing so — expensive on a mobile network. Having the gateway (or a BFF service — backend for frontend) make those calls internally and return them once is a common design. The crucial part is calling them in parallel:

Three branches at 50 / 120 / 200 ms — the same work, two ways to call it 0 100 ms 200 ms 300 ms 400 PARALLEL Promise.all service A · 50 ms service B · 120 ms service C · 200 ms ← the slowest branch decides total ≈ 200 ms = MAX(branches) measured: 208 / 211 / 213 ms SEQUENTIAL for + await A · 50 B · 120 C · 200 total ≈ 370 ms = SUM(branches) measured: 386 / 384 / 387 ms No service got slower. Same three branches, just a different way of calling them, and the latency differs by 1.8x. The tradeoff: parallel => one broken branch breaks the whole screen, unless you have a separate timeout per branch and a fallback for missing data.
Measurements taken from the lab's /aggregate endpoint, three repeats per mode. The ~8–17 ms gap from theory is the internal HTTP and event-loop cost.

The lab's /aggregate endpoint really does call three branches over HTTP, so the socket and event-loop cost is real, not a fake setTimeout:

measure_fan_out.sh
curl -sk "https://localhost:8443/api/aggregate?branches=50,120,200&mode=parallel"
# "maxBranchMs":200, "sumBranchMs":370, "totalMs":208.36

curl -sk "https://localhost:8443/api/aggregate?branches=50,120,200&mode=sequential"
# "maxBranchMs":200, "sumBranchMs":370, "totalMs":386.00

# Same three branches. Parallel: ~MAX. Sequential: ~SUM.
⚠️ Pitfall: aggregation turns the gateway into a monolith
To aggregate, the gateway has to know which services to call, what shape the data comes back in, and how to merge it — which means it starts holding business logic. A few months in, changing any one service means changing the gateway too, and the gateway becomes a component every team has to edit but no team owns.

The common escape route: split the BFF out into its own service that sits behind the gateway. The gateway keeps the purely technical part (TLS, routing, rate limiting, token verification); the BFF keeps the business-aware part and is owned by the frontend team. Two things that change at different rhythms belong in two different places.

nginx comes with rate limiting built in — but you need to understand exactly how much bursting it allows, because that's where surprises usually happen:

rate_limit.conf
# Shared bucket, keyed by the real IP (do NOT use raw XFF - see section 4.2).
# 10r/s is the REFILL rate, not "10 requests per second, max".
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

location /api/ {
    # burst=20: let up to 20 requests queue up, then drain them at 10r/s.
    # nodelay:  serve those 20 queued requests IMMEDIATELY instead of pacing them.
    # => with nodelay, a client can burst 20 requests in a fraction of a second.
    #    Without nodelay it is smoother, but the client sees the delay stretched out.
    limit_req zone=perip burst=20 nodelay;
    limit_req_status 429;          # default is 503 — 429 is more meaningful
    proxy_pass http://api_pool/;
}

4.5 The gateway as a single point of failure — and a timeout budget

Everything in this lesson funnels into one place, and that place sits on the path of 100% of the traffic. If the gateway dies, the whole system dies, even though every service behind it is perfectly healthy. The paradox: the component added to increase reliability is the most dangerous one.

Risk Mitigation What you still have to accept
The gateway process dies Multiple gateway instances behind a floating IP (keepalived) or multiple DNS records That failover mechanism itself now needs to be trusted; DNS is cached so the switch isn't instant
The whole region hosting the gateway goes down Gateways in multiple regions, routed with anycast (one IP address that resolves to whichever physical location is nearest) or region-aware DNS Cost and complexity rise significantly; the data also needs to be available in multiple regions
A bad config takes down everything nginx -t before reloading; roll out one instance at a time, with something to roll back to Still needs a human to check; see the pitfall right below
The gateway becomes the bottleneck Measure the gateway's CPU separately; keep its jobs purely technical; scale it out Rate limiting needs a shared counter, so you need a shared store (Redis) — one more dependency
⚠️ A pitfall that actually happened while building Lesson 3
Editing the config file with sed -i on a bind-mounted volume, then calling nginx -s reload, nginx reported pread() returned only 2830 bytes instead of 2832: it read the file while the file was still being written. The reload failed but nginx kept running fine on the old config, so from the outside nothing looked wrong — and a whole batch of measurements afterward turned out meaningless and had to be thrown away.

Two rules that follow: (1) write config atomically — write to a temp file, then mv it over, because mv on the same filesystem is a single operation; (2) always confirm the config actually in effect after a reload, don't just trust that it succeeded. A reload that fails silently is worse than one that crashes, because it tells you nothing.

Timeout budget

Every hop along the path has its own timeout, and they must shrink as you go further in. If an inner hop waits longer than an outer one, the excess wait is work that's guaranteed to be thrown away: the client is long gone, but the service is still holding a connection and CPU to compute a result nobody will receive.

Hop Example budget Why smaller than the outer hop
Client (mobile app) 10 s The ceiling: past this the user has already left
Gateway → service 8 s Leaves 2 s so the gateway can still return a decent error instead of letting the client time out on its own
Service → database 3 s Leaves room for a retry and for other branches called within the same request
Each branch during aggregation 2 s One slow branch doesn't get to drag the whole screen down with it
⚡ Retries multiply across tiers
Lesson 3 used proxy_next_upstream so the gateway automatically retries another replica — very effective, and exactly why that measurement showed zero 502 errors. But if the client also retries 3 times, and the gateway tries 3 replicas, and the service also retries the database 3 times, one small incident produces 27x the work at exactly the moment the system is weakest. This is how a small incident becomes a total one.

The rule: retry at exactly one tier, with a total retry cap and increasing backoff plus random jitter. If you're not sure which tier is currently retrying, your system has a multiplier you haven't seen yet.

Summary

Reverse proxy, load balancer and API gateway are three levels of responsibility at the same position, and each level adds power by adding state that has to be managed. TLS termination is cheap with persistent connections and many times more expensive without them — the cost lives in every connection, not every request. X-Forwarded-For has to be read from the right, or better yet, let the gateway overwrite it. Whatever doesn't need business knowledge belongs on the gateway; whatever does belongs on the service. And everything funneling through the gateway means the timeout budget and retry policy have to be designed on purpose, not left at their defaults.

The static-file measurement in this lesson leaves a lesson bigger than architecture itself: a number that contradicts your prediction is not a conclusion, it's a question. Had I stopped at the first measurement, this lesson would have stated "nginx serves static files slower than Node" — wrong, but backed by numbers, so it would have sounded thoroughly convincing.

Lesson 5 moves into the layer right after the gateway: caching. The lab already has Redis waiting in the cache profile, and the central question is the one every caching layer must answer — how stale can data get before it's unusable, and whose job is it to invalidate it.

📖 Further reading

Download the lab source

Two nginx config files for the gateway in the lab: the server block for both the HTTP/HTTPS ports, and the shared location blocks (TLS termination, X-Forwarded-For, static files with open_file_cache):

Download gw.conf Download gw-routes.conf

Related lessons in this series

Lesson 3: Scaling Out & Load Balancing Lesson 5: Caching: Cache-Aside, TTL & Invalidation Back to the System Design roadmap

Comments