Seventeen lessons, each solving one problem in isolation. This lesson puts them together: take a spec, estimate, pick the minimal architecture, run it for real, measure, find the bottleneck, fix it, then measure again. The most valuable part isn't the final architecture — it's the before/after table for every step, because that's what shows which step was worth it and which one just added complexity.

The overall result: from 6,115 up to 25,786 requests/second (4.2 times) and p99 from 14.71 down to 1.28 ms (11.5 times). But two more memorable numbers sit in the middle. Caching — the optimization everyone reaches for first — eliminates 99% of read queries but only lifts throughput 16%, because the real bottleneck is on the write path. And a read replica, a perfectly reasonable optimization on paper, delivers exactly 0% improvement — actually slightly worse.

ℹ️ Measurement setup
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. A URL shortener service (app/shortener.js, plain Node http, 0 dependencies) capped at 1 CPU and 256 MB; PostgreSQL 18 primary + read replica, Redis 7; a hand-written loadgen.js, 20 connections, closed-loop, 3-second warm-up, 12-second measurement window, a 1,000-link key space.

Each version changes exactly one parameter from the one before it. This isn't a minor detail — it's the condition for a conclusion to mean anything: change two things at once and you only know "something got better," not which thing — and next time you'll repeat both, even though only one of them mattered.

18.1 Take the spec, and pin down the requirements

The spec: a URL shortener with click statistics. This spec is chosen deliberately, because it packs three very different kinds of load into a system small enough to read in one sitting:

Path Load characteristics Consistency requirement
Redirect — a user clicks a link Extremely read-heavy, the read:write ratio can be 1000:1 Moderately strong — a wrong link sends someone to the wrong place
Create a link Write, low volume Strong — codes must not collide
Click statistics Write-heavy, generated by every redirect Weak — a few seconds of delay hurts nobody

The last column decides the entire design, and it only surfaces if you're willing to ask. Three paths, three different consistency requirements, so they deserve three different treatments — and as the measurements will show, that's exactly where most of the performance lives.

Before drawing anything, here are the questions that must be asked. Without answers, every choice after this is a guess:

  • DAU and peak RPS? Decides whether horizontal scaling is even needed.
  • Read:write ratio? Decides whether caching and replicas are worth it.
  • How long do links live? Decides storage capacity and cleanup strategy.
  • How accurate do the stats need to be? Decides whether async is allowed.
  • What's the SLO? Decides when "good enough" is reached and optimizing should stop.
⚠️ Pitfall: drawing the architecture before knowing the numbers
A diagram with a gateway, a cache, a queue, sharding, and three kinds of database looks very convincing — and is completely meaningless if nobody yet knows whether the system needs to handle 100 or 100,000 requests a second. Those two numbers lead to two entirely different designs, and picking wrong in the "bigger, just in case" direction costs just as much as picking wrong the other way.

The right order is: ask for the numbers → estimate → design the minimal version → measure → only add a component when the data shows the need. Section 18.4 shows an optimization that sounds very reasonable and measures out to zero improvement.

18.2 Estimate and design v1

Say the numbers you get back are 10 million redirects a day and 100,000 new links a day. Back-of-envelope, the same way Lesson 1 did it:

Quantity Calculation Result
Average RPS (reads) 10,000,000 ÷ 86,400 ~116 req/s
Peak RPS (3x factor) 116 × 3 ~350 req/s
Write RPS (creating links) 100,000 ÷ 86,400 ~1.2 req/s
Read:write ratio 116 ÷ 1.2 ~100:1
Link storage after 5 years 100k/day × 365 × 5 × ~200 bytes ~36 GB
Click record storage after 5 years 10M/day × 365 × 5 × ~50 bytes ~912 GB

The last two rows already tell us something important before a single line of code gets written: the click table is 25 times bigger than the link table. The real problem here isn't storing links, it's handling clicks — and that's the first hint that the analytics write path deserves different treatment.

What about that 350 req/s peak? It's tiny. A single server handles it easily. So v1 is deliberately minimal: one service, one PostgreSQL, no cache, no queue. Click writes go straight into the clicks table right inside the redirect request.

💡 ADR: write down the decision, the reasoning, and what got rejected
A good ADR (Architecture Decision Record) needs only four parts and fits on one page: context (the numbers and constraints at the time), the decision, consequences (what you gain, what you lose), and the part most often skipped — the options considered and why they were rejected.

That last part turns out to be the most valuable one later. Six months from now someone will ask "why didn't we use X?", and without an ADR the whole team re-litigates it from scratch — with less information than at decision time, because the context has faded. This project's first ADR has exactly one memorable line: "350 req/s peak doesn't need a distributed architecture; start with one server and measure."
Evolving architecture — each step adds exactly one thing, after the data shows the need v1 shortener PostgreSQL reads AND writes clicks — every redirect is 1 read + 1 WRITE 6,115 rps · p99 14.71 ms v2 shortener Redis PostgreSQL reads: 90,431 → 1,006 queries (99% eliminated) · but WRITES unchanged 7,122 rps (only +16%) · p99 5.94 ms v3 shortener Redis cache + click-count buffer 25,853 rps (×3.6) · p99 1.75 ms — the hot path NO LONGER touches the database v4 shortener read replica 24,803 rps — NO improvement at all, actually a bit worse Because the cache already absorbs 100% of reads: there's nothing left to route to the replica. Overall v1 → v5: throughput ×4.2 · p99 11.5 times better. But most of the improvement comes from EXACTLY ONE step (v3).
Four steps, and only one was actually worth it — something no diagram could have told you in advance.

18.3 Build it for real, and measure the baseline

v1 runs. Now the most important step, and the one most often skipped: measure before optimizing. Without a baseline, every improvement after this is just a feeling, and you have no way to know whether a change was worth keeping.

baseline_v1.txt
$ ./tools/capstone-test.sh v1
  v1  rps=   6115.1  p50=  2.91  p95=  4.93  p99=  14.71
      dbReads=  90431  dbWrites=  90431  redisOps=       0  hitRatio=None

Those two lines already contain the whole clue. The number of read queries and the number of write commands are exactly equal: 90,431 and 90,431. That means every redirect generates one write to the database — and a write is far more expensive than a read, and it sits right on the hottest path in the system.

The common instinct here says: "read-heavy, so add a cache." That instinct isn't wrong, but it answers the wrong question. Let's follow it first anyway — because that's exactly what everyone would do — and then see what the data says.

⚠️ Pitfall: optimizing by gut feeling or by habit
"The database is probably slow" opens a lot of optimization sessions, and it leads straight to adding caches, adding indexes, adding replicas — things that may have nothing to do with the real bottleneck.

The surprisingly cheap fix: count operations, not just latency. The dbReads and dbWrites counters above are just two integers, but they point exactly where to look in a way a latency chart cannot. This is Lesson 16's lesson applied to a concrete case: metrics tell you that something's going on, and the right counter tells you where.

18.4 The measure → patch → measure loop

Four rounds, each changing exactly one parameter. All of it real measurements on the same machine, the same load scenario:

Version Change Throughput p99 DB reads DB writes
v1 Minimal baseline 6,115 rps 14.71 ms 90,431 90,431
v2 + cache-aside (Lesson 5) 7,122 rps (+16%) 5.94 ms 1,006 103,775
v3 + asynchronous click counting (Lesson 12) 25,853 rps (×3.6) 1.75 ms 0 0
v4 + read replica (Lesson 7) 24,803 rps (−4%) 1.85 ms 0 0
v5 + rate limiting (Lesson 13) 25,786 rps 1.28 ms 0 0
Throughput and p99 through each patch round (real measurements) Throughput (req/s) — higher is better v1 6,115 v2 7,122 — only +16% even though the cache eliminated 99% of reads v3 25,853 v4 24,803 — added a replica, got WORSE v5 25,786 p99 (ms) — lower is better v1 14.71 v2 5.94 — caching helps p99 far more than it helps throughput v3 1.75 v4 1.85 v5 1.28
v2's two columns tell two different stories: caching helps p99 a lot but barely helps throughput.

v1 → v2: caching eliminates 99% of read queries but only lifts throughput 16%. If you only looked at the "read queries" column, this looks like a huge win — 90,431 down to 1,006. But the throughput column tells a different story, and the reason is in the last column: the number of writes doesn't drop at all, and even rises with traffic. Caching fixed something that wasn't the bottleneck. The one bright spot is p99, down 2.5 times (14.71 → 5.94), because reads no longer have to queue for the connection pool alongside writes.

🔬 Why caching helps p99 so much but barely helps throughput
v2's two columns diverge suspiciously: p99 is 2.5 times better while throughput only nudges up 16%. The explanation is that these two metrics measure two different things.

Throughput is capped by the scarcest resource on the path — in v2, that's the database write path, and caching doesn't touch it at all. Remove the reads and the bottleneck is exactly the same, so the throughput ceiling barely moves.

p99 reflects queueing time. In v1, reads and writes compete for the same connection pool, so an unlucky read can end up waiting behind several writes (Lesson 1). Caching pulls 99% of reads out of that queue, so the tail shrinks dramatically — even though total capacity hasn't grown.

The general lesson: an optimization can improve latency without improving throughput, and vice versa. Track only one of the two and you'll either miss a change's real value or credit it with value it doesn't have.

v2 → v3: changing how clicks are counted, throughput 3.6 times. This is the most worthwhile step, and it's not a technical trick, it's a requirements decision: accepting that statistics are allowed to lag by a few seconds. Recognizing that back in section 18.1 is what makes this step possible. After v3, the system's hot path never touches the database at all — 0 reads, 0 writes.

🚨 v4: reasonable on paper, exactly 0% improvement
A read replica is the obvious next step in every system design write-up: heavy read load, so split reads off to a replica. Measured for real, it gives 24,803 rps versus 25,853 — 4% worse, i.e. within the noise, and certainly not an improvement.

The reason is completely obvious after looking at the data: the "DB reads" column in v3 is already 0. The cache absorbs 100% of reads, so there's nothing left to route to a replica. We just added a container, a replication stream, a source of replication lag, and a layer of operational complexity — in exchange for zero.

The limit of this conclusion: the hot dataset here is only 1,000 links, so it fits entirely in the cache. With a key space many times larger than the cache's memory, the hit ratio would drop and the replica would have real work to do. The lesson isn't "read replicas are useless," it's that the order you apply optimizations decides the value of each one — and only measurements on your own system can tell you that order.

v4 → v5: rate limiting is nearly free. p50 goes up 0.06 ms — exactly one network round trip to Redis, matching the number from Lesson 13. And just as in Lesson 13, p95/p99 actually measure lower (1.01 and 1.28 versus 1.49 and 1.85). This effect repeats reliably across two different lessons but the mechanism hasn't been proven, so it's recorded here as an observation, not a conclusion.

What about the cost of v3? It's real, it just doesn't show up in the throughput column. The /stats endpoint exposes it:

cai_gia_cua_bat_dong_bo.json
$ curl -s localhost:3010/stats/k7
{
  "code": "k7",
  "clickTrongDb": 191,          // already flushed to the database
  "clickConTrongBoDem": 1028,   // still sitting in Redis, not flushed yet
  "tong": 1219                  // the CORRECT number only exists when you add both
}

The gap between 191 and 1,219 is eventual consistency in its most concrete form (Lesson 9). Anyone reading the clicks table directly in the database sees a wrong number. That's the price paid for 3.6 times throughput — and for click statistics that's the right price to pay; for an account balance, it isn't.

18.5 Summing up the trade-offs, and what this lab can't tell you

Seventeen lessons, and each one boils down to one trade-off sentence:

Topic Gain Cost
Caching (Lesson 5) Less database load, lower p99 Stale data, thundering herd, one more layer to operate
CDN / edge (Lesson 6) Overcomes the limit of physical distance Cache invalidation gets hard, fragmented by cache key
Replication (Lesson 7) Scales reads, gives you a standby copy Replication lag, the read-your-writes bug
Sharding (Lesson 8) Overcomes a single machine's limit Cross-shard queries, hotspots, rebalancing
Asynchrony (Lesson 12) Decouples user latency from heavy work Eventual consistency, backlog, mandatory idempotency
Rate limiting (Lesson 13) The system says "no" in a controlled way One more network round trip on every request
Event sourcing (Lesson 14) Full history, rebuild a read model by replay Cognitive overhead, upcasting, loss of ad-hoc querying
Microservices (Lesson 15) Independent deploys, scale hot parts on their own p99 4 times worse, cumulative availability, sagas
Observability (Lesson 16) Can answer questions you never anticipated Storage cost, cardinality, has to be maintained
Circuit breakers (Lesson 17) Stops cascades, fails fast One more state to tune, and it has its own bugs

Looking at the whole table at once reveals the most important point: no row has an empty "cost" column. Nothing in this series is free. The right question was never "should I use X" — it was always "is the problem I have worth X's price" — and that question can only be answered with measurements from your own system.

⚠️ The biggest pitfall: believing that running well on a laptop means it's ready
Every number in these 18 lessons comes from Docker on a personal computer. There are things this lab cannot simulate, and they're usually exactly what takes down a real system:

A real network. Every container here talks over loopback, a round trip that costs ~0.07 ms. Reality is 0.5–2 ms within the same data center, tens to hundreds of ms across regions — and more importantly, a real network drops packets, fragments, and stalls unpredictably, not cleanly like here.

Infrastructure failure. A dead disk, a reclaimed node, an entire availability zone losing power, DNS misbehaving. The lab can only inject the failures we thought of in advance.

Money. Here, one more container is free. Out there, every component is a monthly bill, and budget constraints often shape architecture more than technical constraints do.

The human factor. No lab simulates having to diagnose an unfamiliar system at 3 a.m., with stale docs and the person who wrote them long gone. This is the real reason simplicity has value — not because simple is elegant, but because a simple system is one you can still understand while tired and scared.
💡 When to stop optimizing
The answer isn't "when you run out of ideas," it's when you've met the SLO and still have error budget left (Lesson 16). In this capstone, the initial estimate was 350 req/s peak — meaning even v1, at 6,115 req/s, already had 17 times the headroom needed.

In other words: if this were a real system with those real numbers, then v2, v3, v4, and v5 would all be unnecessary. They exist in this lesson to teach the measurement process, not because the problem demanded them. This might be the hardest thing for an engineer to accept: the right architecture is usually a boring one, and most of the optimization we enjoy doing is optimizing for a scale that never arrives.
The final architecture — and which lesson contributed what HOT PATH — redirect · 25,786 req/s · p99 1.28 ms · does NOT touch the database user rate limiter Lesson 13 shortener Lesson 2 · 3 Redis: cache Lesson 5 (TTL with jitter) 302 Found 99% stop here Every component on this path sits on the p99 of EVERY request — so anything added here has to pay for itself in latency. COLD PATH — analytics Redis INCR (buffer) flushed to PostgreSQL Lesson 12 · eventual consistency (Lesson 9) The most worthwhile step: ×3.6 throughput The cost: /stats has to add both sources, and anyone reading the DB table directly sees the wrong number. ADDED BUT NEVER USED read replica (Lesson 7) 0 read queries pass through it, because the cache already absorbed 100%. Added 1 container, 1 replication stream, 1 source of lag — in exchange for 0% improvement. Kept in the diagram because it's a lesson, not a drawing mistake.
A real system's final architecture always has some dead weight — what matters is knowing which part it is.

Self-assessment checklist for a design

  • Do you have numbers yet — peak RPS, read:write ratio, storage, SLO?
  • Has the minimal version measured a baseline, or are you optimizing in the dark?
  • Does every added component have before/after numbers proving it was worth it?
  • Does every network call have a timeout, and do timeouts shrink with depth?
  • Which operations can be retried — are they idempotent yet?
  • How many tiers retry? (The right answer is almost always: one.)
  • Under overload, what do you drop first, and has that been decided already?
  • Is there a correlation ID all the way through, including across queues?
  • Does the fallback path depend on fewer things than the primary path?
  • When was the failover mechanism last drilled?

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

./tools/capstone-test.sh seed   # seed 1,000 links (run once)
./tools/capstone-test.sh all    # run v1 -> v5 in sequence

#   v1 baseline             rps  6,115  p99 14.71ms   dbReads 90,431  dbWrites 90,431
#   v2 + cache              rps  7,122  p99  5.94ms   dbReads  1,006  dbWrites 103,775
#   v3 + async click counts rps 25,853  p99  1.75ms   dbReads      0  dbWrites      0
#   v4 + read replica       rps 24,803  p99  1.85ms   (!) NO improvement at all
#   v5 + rate limit         rps 25,786  p99  1.28ms

# The visible cost of v3:
curl -s localhost:3010/stats/k7   # inDb 191 · stillBuffered 1028 · total 1219

In summary

Process matters more than architecture. Ask for the numbers before drawing anything; design the minimal version; measure the baseline; then each round changes exactly one thing, with before/after numbers recorded. It sounds slow, but it's the only way to know whether you're actually improving things or just adding complexity.

Three numbers from this capstone are worth carrying with you. Caching — the optimization everyone reaches for first — eliminates 99% of read queries but only lifts throughput 16%, because the bottleneck sits somewhere else. What's genuinely worthwhile is a requirements decision, not a technical trick: accepting that click statistics can lag by a few seconds, and throughput goes up 3.6 times. And a read replica, reasonable on every diagram, delivers exactly 0% — because the cache had already absorbed all the reads it was meant to carry.

And the last point, also the single most memorable thing across all 18 lessons: the trade-off table in section 18.5 has no row with an empty "cost" column. Every technique in this series has a price. A designer's job isn't knowing lots of techniques — it's knowing when that price is worth paying — and the answer always lives in the data of your own system, not in any article, including this one.

This is the last lesson of the roadmap. If you've run every lab and measured it yourself on your own machine, you don't just know these concepts — you've watched them happen. That's the difference this whole series was written to create.

📖 References

Download the practice source code

The full URL shortener used in the capstone: all five versions v1–v5 (baseline, cache, asynchronous click counting, read replica, rate limiting) live in the same file, selected via an environment variable — every number in this lesson comes from this file:

Download shortener.js (the entire URL shortener — 0 dependencies)

Related lessons in this series

Lesson 17: Failure Modes & Resilience 🎉 You've completed the System Design roadmap Back to the System Design roadmap

Comments