SYSTEM DESIGN · PATTERNS · 2 OF 7

Scaling Reads: Indexes, Replicas, Caches, CDNs and Precomputed Views

What to do when reads outnumber writes a hundred to one: spotting a read-heavy question, why a single database tops out, then the standard moves in order (fix the query, add read replicas, cache-aside, a CDN, precomputed views), plus replication lag, cache invalidation, hot keys and stampedes, and how to present the read path in an interview.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
1 / 15

Press play to hear the narration, or step through at your own pace.

Notes

The signals

The signal is a lopsided read to write ratio. Words like view, browse, load the page, show the profile. A URL shortener redirects about a hundred times per link created. A product page might be read a thousand times per edit. When your estimate shows reads dominating, say so, and tell the interviewer you will optimise the read path.

Signals in the question
  • "view", "browse", "load the
  • page", "show the profile"
  • read:write of 10:1 or more
  • the same item read by many
  • users (popular post, product)
  • p99 latency targets on reads
  • examples: URL redirect 100:1,
  • product page 1000:1,
  • news feed, Pastebin

The naive design

The naive design sends every read to one database. A single well-indexed Postgres server might handle tens of thousands of simple queries a second, but far fewer once there are joins and sorts. Writes compete for the same CPU and memory, and connection counts run out. At forty thousand reads a second, latency climbs and then requests start timing out.

Why one DB tops out
one Postgres box, indexed reads:
  roughly 10k-50k simple QPS
  far fewer for joins and sorts

need 40,000 reads/s at peak
+ connection limits (hundreds)
+ writes compete for the
  same CPU and buffer cache

→ latency climbs, then timeouts

Move 1: fix the query

Move one is free: make each read cheap. Add the index that matches the query, see Concepts episode seventeen. Remove N plus one patterns, where a list page fires one query per row. Select only the columns you need, and paginate with a cursor instead of a huge offset. Sometimes denormalising, like storing the author name on the post, removes a join from the hottest path.

Make each read cheap
  • add the right index
  • (Concepts episode 17)
  • kill N+1 queries: one query
  • for the list, not one per row
  • select only needed columns
  • denormalise the hot read
  • (store author name on post)
  • paginate with a cursor,
  • not OFFSET 100000

Move 2: read replicas

Move two: read replicas. The primary takes every write and streams its change log to several copies; reads are spread across them, so five replicas give roughly five times the read capacity. The catch is replication lag. It is usually milliseconds, but under load it can be seconds, and a user who edits their profile and refreshes might see the old value. Concepts episode twenty covers replication.

The standard fix is read-your-writes for the person who just wrote. For about ten seconds after a write, route that user's reads to the primary, or remember the log position of their write and only read from a replica that has reached it. Everyone else can tolerate a second of lag. That one sentence shows you understand replication, not just the diagram.

Read replicas
  • primary takes all writes
  • replicas stream its log
  • reads go to any replica
  • 5 replicas ≈ 5x read capacity
  • catch: replication lag,
  • usually ms, sometimes seconds
  • user edits, refreshes, sees
  • the old value
Read your own writes
  • after a user writes, route
  • their reads to the primary
  • for ~10 s (cookie or flag)
  • or pass the write position and
  • wait for a replica to reach it
  • everyone else: replicas are
  • fine, a second of lag is ok

Move 3: cache

Move three, the big one: a cache. With cache-aside, the app checks Redis first. On a miss it reads the database, then stores the result with an expiry. If ninety-five percent of reads hit, forty thousand reads a second becomes two thousand at the database. A single Redis node serves around a hundred thousand operations a second in under a millisecond. See Concepts episode eleven.

Cache-aside
v = cache.get(key)
if v is null:
  v = db.query(...)
  cache.set(key, v, ttl=300)
return v

95% hit ratio on 40k reads/s
→ only 2,000 reach the DB
Redis node: ~100k ops/s, <1 ms

Invalidation

Caches go stale, so plan invalidation. A TTL bounds how stale anything can be. On a write, update the database, then delete the cache key rather than setting it, because two concurrent writers setting values can leave the older one in the cache. For stricter setups, a consumer of the database change log deletes keys, so no code path can forget.

Keeping the cache honest
  • TTL: bounds staleness
  • (5 min for a product page)
  • on write: update DB, then
  • DELETE the cache key
  • (do not SET: races)
  • versioned keys: product:9:v42
  • change data capture: a
  • consumer of the DB log
  • invalidates keys

Hot keys

Two traps. A stampede: a hot key expires and five thousand requests miss at the same instant and hammer the database. Fix it with request coalescing, so one request fetches and the rest wait, plus jittered expiry and background refresh. A hot key: one celebrity's profile lands on a single cache shard. Add a tiny in-process cache in front, or store several copies under suffixed keys.

Hot keys and stampedes
  • stampede: a hot key expires,
  • 5,000 requests miss together
  • → request coalescing: one
  • fetch, others wait for it
  • → jitter TTLs (300 ± 30 s)
  • → refresh early in background
  • hot key: one Redis shard
  • → small in-process cache
  • → copies: key#1 ... key#8

Move 4: CDN

Move four: if many users get identical bytes, serve them from a CDN. Images, video, scripts, and even public API responses with the right cache headers. An edge hit costs tens of milliseconds and zero load on your origin. Purge on publish, or better, put a version in the URL so new content simply has a new address. Concepts episode twelve goes deeper.

Push reads to the edge
  • same bytes for every user?
  • → cache at a CDN
  • images, video, JS, CSS
  • public pages, API responses
  • with Cache-Control headers
  • edge hit: tens of ms, zero
  • load on your servers
  • purge on publish or use
  • versioned URLs

Move 5: precompute

Move five: precompute. If every read does the same heavy computation, do it once at write time. Keep a like count column instead of counting rows. Fan a post out to followers' timelines so reading a feed is one lookup. Keep a leaderboard in a sorted set. Refresh a materialised view every minute for dashboards. You trade write cost and storage for very cheap reads.

Move 5: precompute
  • do the expensive work at
  • write time, once
  • feed: fan-out to timelines
  • counts: keep like_count
  • instead of COUNT(*)
  • leaderboard: sorted set
  • search: build an index
  • reports: materialised view
  • refreshed every minute

Trade-offs

The trade-offs. Every copy, replica, cache or edge, adds staleness, so state the freshness budget, like a few minutes for a product description and zero for a bank balance. A cache costs memory and can fail; after a restart it is cold and the database takes the full load, so warm it or rate limit misses. Precomputing makes writes heavier. And sharding is for when writes or data outgrow one primary, not reads.

Trade-offs
  • every layer adds staleness
  • → say how stale is ok
  • more copies, more ways to
  • disagree
  • cache = memory cost + one
  • more thing to fail
  • (a cold cache after restart)
  • precompute = slower, bigger
  • writes
  • shard only when data or
  • writes outgrow one primary

Where it shows up

You will use this in almost every problem. The URL shortener redirect path is a textbook cache plus replicas. Pastebin and YouTube push content to a CDN. News feed and Twitter precompute timelines. E-commerce caches product pages. Yelp caches nearby results by area. Whenever the read path is the hot path, walk through these moves.

Problems that use it
  • URL shortener: redirect path
  • Pastebin: read-mostly blobs
  • News feed, Twitter: timelines
  • Instagram, YouTube: media
  • via CDN
  • E-commerce: product pages
  • Yelp: nearby search results
  • Leaderboard: top-N reads

Say it like this

Here is the phrasing. Reads are a hundred times writes, so I will optimise the read path. First indexed queries. Then read replicas, with read-your-writes for the author. Then a Redis cache-aside with a five-minute expiry, deleting the key on write and coalescing misses. A CDN for media and public pages. And precomputed counts so nothing aggregates at read time.

In the interview
  • "Reads are 100x writes, so I
  • optimise the read path.
  • Indexed queries, then read
  • replicas with read-your-
  • writes for the author, then
  • a Redis cache-aside with a
  • 5 min TTL and delete on
  • write, a CDN for media, and
  • precomputed counts."

Interview questions

How would you scale a read-heavy service?

Make queries cheap with the right indexes, add read replicas, put a cache-aside layer such as Redis in front of the database, serve public static content from a CDN, and precompute expensive aggregations at write time.

What problem do read replicas introduce?

Replication lag: a replica can be behind the primary, so a user may not see their own recent write. Fix it by routing that user to the primary for a short window or by reading only from replicas that have caught up to the write position.

Why delete a cache key on write instead of updating it?

Two concurrent writers can set the cache in the opposite order to their database commits, leaving an older value cached indefinitely. Deleting forces the next read to load the current value from the database.

What is a cache stampede and how do you prevent it?

When a popular key expires, many requests miss at once and all query the database. Prevent it with request coalescing or a short lock so one request refills the key, jittered TTLs, and refreshing hot keys before they expire.

How do you handle a single extremely hot key?

Put a small in-process cache in front of Redis for that key, or store several replicas of it under suffixed keys and pick one at random, so the load spreads across shards and machines.

When does sharding help reads?

Only when the dataset or write rate outgrows a single primary; for pure read volume, replicas and caches are simpler. Sharding mainly splits data and write load across machines.

How do you decide what TTL to use?

Start from how stale the data is allowed to be for the product, for example minutes for a product description and seconds for stock levels, and combine the TTL with explicit invalidation on writes for data that must be fresher.