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.
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.
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.
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."
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.
$ ./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.
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 |
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.
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.
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:
$ 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.
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.
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.
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
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
- Wikipedia — Architecture Decision Record: the context/decision/consequences/rejected-options format, source for the ADR in section 18.2
- Google SRE Book — Embracing Risk: the error budget concept used to decide when to stop optimizing, source for section 18.5
- Wikipedia — Back-of-the-envelope calculation: the quick estimation technique used in section 18.2
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)
Comments