Caching, replicas & shards: scaling the read and write paths
- Trace a request's read and write paths, put each kind of data in a cache layer that can hold it, size the database for the cache's miss rate, and use a queue to level bursty writes
- Spot the races cache-aside leaves once the write order is right — a stale set, a stampede across the fleet — and fix each in the shared cache (a lease, a lock key), with the TTL as the bound on what's left
- Predict a read-your-writes failure under asynchronous replication and choose a fix: read from the primary after a write, wait for the replica, or accept the lag
- Choose a partition key and routing scheme, and explain why
hash % Nmoves most keys on a resize while consistent hashing with virtual nodes moves about 1/N - Explain CAP and PACELC in plain terms and name the trade-off a concrete setting makes, such as asynchronous vs synchronous replication
Read
Every system has a read path and a write path
FlagFollow one request through a system and you get a path: the hops it touches, in order. Most systems have two paths that matter, and they want opposite things.
READ: client → CDN → app → cache ──miss──→ database replica
WRITE: client → app → database primary → delete the cached copy
└──→ queue → workers (the slow follow-up work)
- The read path wants copies close to the reader. Every copy — a CDN edge, a cache entry, a read replica — takes load off the source of truth and latency off the request. The price is staleness: a copy can be older than the original.
- The write path wants one authoritative place to land, durably. Copying a write doesn't make it faster. You can only make it smaller (do less before replying) or spread it (partition the data so different writes land on different machines).
So the tools sort themselves by path:
| Tool | Path | What it buys | What it costs |
|---|---|---|---|
| Cache, CDN | read | fewer trips to the source, lower latency | stale data; invalidation |
| Read replicas | read | more read capacity | replication lag |
| Queue | write | the reply doesn't wait for slow work; bursts get absorbed | work finishes later; duplicates |
| Partitioning (sharding) | write and storage | writes and data spread across machines | cross-shard queries; resharding |
The first number to get in any design is the read:write ratio, because it says which half of this table you're shopping in. The URL shortener, at 100 reads per write, lives almost entirely in the top half.
Cache-aside, sized — the miss rate is the database's load
FlagThe pattern itself is the NestJS track's Caching & rate limits with Redis unit: read the cache; on a miss, load from the database and leave a copy with a TTL; on a write, update the database first, then delete the key (deletes, unlike sets, can't land in the wrong order). This tier assumes that and asks the design questions around it: what to cache, how big, and what the database behind it has to survive.
What to cache is an estimate too. The shortener stores 6 × 10⁹ links — about 3 TB — but the redirect only needs the links people are clicking now:
Assume: 10 million distinct links are clicked on a given day (assumption)
Cache: 10,000,000 × ~500 B per entry ≈ 5 GB
Five gigabytes fits in one cache node's memory; 3 TB doesn't fit in any. So the cache holds the working set, the TTL decides how long a link stays in it once its clicks stop, and the database still holds everything. Get the assumption from real traffic when you have it.
The hit ratio is the database's load, read upside down. At 10,000 reads a second:
hit ratio 99% → 1% miss → 100 reads/s reach the database
hit ratio 98% → 2% miss → 200 reads/s (one point lower, twice the database load)
hit ratio 90% → 10% miss → 1,000 reads/s
A dashboard showing 98% looks almost the same as 99%. The database sees its load double. Near the top, read a hit ratio as its complement.
Size the database for a bad day's miss rate, not today's. Three ordinary events push it up:
- a deploy restarts every server, and every in-process cache starts empty;
- a change to key names (a new prefix, a new field in the key) makes every old entry unreachable at once;
- traffic shifts to keys nobody has asked for lately — a newsletter links to old posts.
And the worst day is the cache being gone altogether: 100% misses. The complex tier of A framework for any design does that arithmetic, and AWS's caching article gives the test: run load tests with the cache disabled.
The TTL is a requirement, not a tuning knob. Azure's Cache-Aside guidance is to fit the expiry to how the data is used: too short and requests keep going back to the database, too long and stale data lingers. Delete-on-write only covers writers that go through your code. If another service, an admin script or a migration can change the table, the TTL is the only freshness guarantee those writes get, so pick it by asking "how stale may this be?" — and write the answer down next to the requirement it serves.
The two races cache-aside still has
FlagUpdating the database before deleting the key closes one race (the NestJS track's Caching & rate limits with Redis unit). Two remain, and at fleet scale the fixes have to live in the shared cache, not in one process.
The stale set. A reader that misses can be slower than a writer:
reader A: GET link:x → miss
reader A: SELECT … → old row
writer B: UPDATE … → COMMIT
writer B: DEL link:x
reader A: SET link:x = old row ← lands after the delete
The delete happened — and then A refilled the cache with the row it read before the update. Every reader gets the old value until the TTL expires. Facebook's memcache paper calls this a stale set and fixes it with leases: on a miss, the cache hands the reader a 64-bit token bound to the key; a delete invalidates outstanding tokens; a set carrying an invalidated token isn't stored.
Redis has no leases, and a plain version check can't stand in for one: after B's DEL there's
nothing left in the cache to compare A's old version against. You can build the lease:
- On a miss, the reader takes a token key:
SET lease:link:x <random> NX PX 5000. - The writer's invalidation deletes
lease:link:xalong withlink:x. - The fill is a small Lua script that writes
link:xonly iflease:link:xstill holds the reader's token. Redis runs a script atomically, so nothing slips in between the check and the write.
(Versions work if the writer leaves its version behind instead of deleting — the new value, or a tombstone holding the version — and every fill refuses to overwrite a newer version: the version-column idea from Transactions & isolation, applied to the cache.) With neither, the TTL is your only bound on how long a stale set survives, so choose it knowing that.
The stampede across the fleet. A hot key expires and every request that misses asks the
database for the same row. In-process coalescing — one load per key per process, like
cache-manager's wrap — caps that at one query per server: with 50 servers, a cold key still
costs up to 50 identical queries, every time it expires. Removing the multiplier needs a decision
every server can see, which means the cache. AWS's Builders' Library describes coalescing done by
the servers or by an external cache. Memcache does it with the same leases: a server hands out
a token for a key at most once every 10 seconds, and other clients that miss are told to wait
briefly. The paper reports a set of keys prone to herds going from a peak of 17K database
queries a second without leases to 1.3K with them. The Redis lease above has the same property —
whoever loses the NX waits briefly and reads again.
Quiz
GET /api/dashboard returns JSON that's different for every signed-in user and is expensive to compute. Where can you cache it?
Producers send an average of 1,200 messages/s; the consumers can process 1,000/s. What does putting a queue between them do?
FlagYour cache-aside code already updates the database and then deletes the key, yet a link occasionally serves its old destination for a full TTL after an edit. Which change closes that gap with Redis as the cache?
FlagEach of 40 app servers already coalesces concurrent misses in-process (one load per key per server, like cache-manager's wrap). When a very hot key expires, the database still takes a burst of identical queries every time. What removes the rest of the burst?
Reads go to async Postgres replicas. A user saves their profile, gets 200 OK, and the reloaded page shows the old name for about a second. What's the targeted fix?
FlagYou shard an orders table by created_at ranges, one shard per month. What goes wrong first?
A cache cluster routes with node = hash(key) % N. Why is adding a fifth node to four so painful?
A teammate says: "Our Postgres primary with async read replicas is a CA system, so network partitions don't concern us." What's the best correction?
FlagFill in the blank
Read-your-writes on PostgreSQL 18 replicas: you kept the primary's WAL position from the moment the user's write committed. Serve their next read from a replica only if it has replayed past that point.
Flag-- on the primary, right after the user's write commits; keep the result in their session SELECT pg_current_wal_lsn(); -- e.g. 0/3000148 -- on a replica, before serving that user's next read SELECT () >= '0/3000148'::pg_lsn; -- false → wait briefly, or read from the primary
Keep a hash ring's positions in numeric order.
Flagpositions.sort((a, b) => );
After a binary search, low is the index of the first position ≥ the key's hash — or positions.length if there's none. Wrap around the ring.
const index = low === positions.length ? : low;
Make each commit wait until the synchronous standby has replayed it, so a read on that standby right after the commit sees the change.
FlagSET synchronous_commit = ;
Redis Cluster's key → slot function. The modulus never changes, which is why adding a node moves slots instead of rehashing keys.
FlagHASH_SLOT = CRC16(key) mod Predict the output
// Estimate reads_per_second = 10_000 // Monday: cache hit ratio 99% // Tuesday: a deploy changes key names for some pages; hit ratio 98% database_reads_per_second(Monday) = ? database_reads_per_second(Tuesday) = ?
// hash32 maps a string to an unsigned 32-bit integer (the challenge's hash.ts)
const keys = Array.from({ length: 10_000 }, (_, i) => `user:${i}`);
const moved = keys.filter((k) => hash32(k) % 4 !== hash32(k) % 5).length;
console.log(moved / keys.length);// cache-aside, TTL 1 hour; the database row for x starts as OLD
reader A: cache.get("link:x") // miss
reader A: row = db.select("x") // reads OLD
writer B: db.update("x", NEW) // commits
writer B: cache.del("link:x")
reader A: cache.set("link:x", row, { ttlSeconds: 3600 })Challenge
Fix a consistent-hash shard router
Flaghash-ring.ts decides which node (a cache server, a database shard) owns each key, using
consistent hashing. Every node is hashed onto a ring of 32-bit positions — once per
virtual node, so replica i of node n sits at hash(`${n}#${i}`) — and a key belongs to the
node at the first position at or after the key's own hash. A key that hashes above the
highest position wraps around to the lowest.
That rule is what makes resizing cheap. A new node only takes over the arcs just before its own
positions, so only those keys move. Compare hash(key) % nodeCount: going from 4 nodes to 5
changes the answer for about 80% of keys, which for a cache means a fleet-wide miss storm. The
preview prints both numbers.
The ring compiles and answers every lookup, and it's wrong in four ways:
- Order — positions are sorted with
sort()and no comparator, which compares numbers as strings (10000sorts before900), so a lookup walks the ring out of order. - Wrap-around — a key that hashes above the highest position finds no position "at or
after" it, and gets
undefinedinstead of the lowest position's node. - Virtual nodes — every replica of a node lands on the same point, so each node really has one position and the load is lopsided.
- Removal —
removeNodedrops the node's owner entries but leaves its positions on the ring, so keys still land on a node that's gone.
hash.ts (read-only) provides hash32. The tests pass their own hash function through the
constructor to put nodes and keys at exact positions — the same seam you'd use to test any
router without depending on what a real hash happens to produce.
Your task
Edit hash-ring.ts so the tests pass:
- A key routes to the node at the first ring position at or after its hash, with positions compared as numbers
- A key hashing past the highest position wraps around to the lowest one
- With virtual nodes, each of 4 nodes owns between 15% and 35% of 10,000 keys
- Removing a node moves only the keys it owned, and no key routes to it afterwards
- Adding a 5th node moves keys only to the new node — between 10% and 30% of them
Further reading
- AWS Builders' Library: Caching challenges and strategies
- Azure Architecture Center: Cache-Aside pattern
- Nishtala et al., Scaling Memcache at Facebook (NSDI '13)
- PostgreSQL 18 docs: Log-shipping standby servers (streaming and synchronous replication)
- PostgreSQL 18 docs: Hot Standby
- PostgreSQL 18 docs: Table partitioning
- Azure Architecture Center: Sharding pattern
- Amazon DynamoDB guide: Using write sharding to distribute workloads evenly
- DeCandia et al., Dynamo: Amazon's Highly Available Key-value Store (SOSP 2007)
- Redis docs: Redis cluster specification
- Redis docs: Scripting with Lua (atomic execution)
- Eric Brewer, CAP Twelve Years Later: How the "Rules" Have Changed
- Daniel Abadi, Consistency Tradeoffs in Modern Distributed Database System Design (PACELC)
- Azure Architecture Center: Queue-Based Load Leveling pattern