Almost every system you will be asked to design is read-heavy. A social network, an e-commerce catalog, a news site, a video platform — in all of them, far more people view content than create it. The read-to-write ratio is commonly 10:1, often 100:1, and for something like a celebrity’s profile it can be 1000:1 or more. That asymmetry is the single most important thing to notice early, because reads and writes scale in completely different ways.
The good news is that reads are the easy direction. A piece of data can be copied cheaply — into a cache, onto a replica, out to a CDN edge — and every copy serves reads independently. This lesson is the toolbox for scaling the read path. The next lesson covers writes, which are much harder precisely because you cannot just make copies.
In the interview: after you estimate QPS, say out loud “this is read-heavy, maybe 100:1, so I will lean on caching and read replicas, and keep the write path simple.” That one sentence signals that you understand the shape of the problem before you have drawn a single box.
The core idea: push copies closer to the reader
The strategy for scaling reads is always the same: answer the read as far from the database as possible. Every layer you can satisfy a read at is a layer the database never sees. A request should ideally be served by a CDN edge; if not, by an application cache; if not, by a read replica; and only as a last resort by the primary database.
Each layer absorbs a fraction of the traffic and forwards only its misses to the next. If the CDN serves 60% of requests, the cache serves 90% of the remainder, and replicas handle the rest, the primary database ends up seeing a tiny trickle of the original firehose. The art of scaling reads is stacking these layers so that the slow, expensive, hard-to-scale layer — the primary database — is touched as rarely as possible.
Caching tiers
Caching is the highest-leverage tool for read scaling. A cache stores the result of an expensive operation (a query, a computation, a rendered fragment) in fast storage so that the next identical request skips the work entirely.
The hierarchy
- Browser / client cache — the cheapest read is the one that never leaves the user’s device. HTTP
Cache-ControlandETagheaders let the browser reuse responses. - CDN / edge cache — a globally distributed set of caches close to users. Ideal for static assets (images, CSS, JS, video) and cacheable API responses.
- Application cache (Redis / Memcached) — an in-memory store for hot application data: user sessions, rendered feed entries, product details, counters. Sub-millisecond lookups, hundreds of thousands of operations per second per node.
- Database buffer cache — the database itself caches hot pages in memory, but you do not want to rely on it as your main defense; it is the last line, not the first.
The hit-ratio math
The value of a cache is entirely in its hit ratio, and the relationship is non-linear. If your database can handle 10,000 reads per second and you are receiving 100,000 reads per second, you need to offload 90% of reads. The math is simple but worth saying explicitly in an interview:
DB load = total reads × (1 − hit_ratio)
100,000 reads/sec × (1 − 0.90) = 10,000 reads/sec ✓ survives
100,000 reads/sec × (1 − 0.95) = 5,000 reads/sec ✓ comfortable
100,000 reads/sec × (1 − 0.99) = 1,000 reads/sec ✓ easyA 95% hit ratio cuts database read load by 20x; a 99% hit ratio cuts it by 100x. This is why a well-placed cache can turn an impossible read load into a trivial one, and why interviewers love to hear you reason about the hit ratio rather than just saying “add Redis.”
Cache-aside vs read-through
The two dominant patterns differ in who fills the cache:
- Cache-aside (lazy loading) — the application checks the cache; on a miss it reads the database, writes the result into the cache, and returns it. The application owns the logic. This is the most common pattern and the one to describe by default.
- Read-through — the cache itself knows how to load from the database on a miss. The application only ever talks to the cache. Simpler application code, but requires a cache that supports it.
def get_product(product_id):
# Cache-aside pattern
cached = redis.get(f"product:{product_id}")
if cached:
return deserialize(cached) # cache hit
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
redis.set(f"product:{product_id}", serialize(product), ex=300) # TTL 5 min
return productWhat to cache and for how long
Cache data that is read often and changes rarely — product catalogs, user profiles, configuration, computed aggregates. The TTL is a trade-off between freshness and load: a short TTL means fresher data but more database misses; a long TTL means less load but staler data. For data that must never be stale, pair a long TTL with explicit invalidation — delete or update the cache entry when the underlying data changes. Getting invalidation right is famously hard, which is why many teams prefer short TTLs and accept a few seconds of staleness.
Read replicas
Caching handles hot data, but some read traffic always reaches the database. The standard way to scale database reads is replication: one primary (leader) accepts all writes, and multiple read replicas (followers) each hold a full copy and serve reads.
Because each replica is a complete copy, read throughput scales almost linearly with the number of replicas: two replicas roughly double read capacity, five replicas roughly quintuple it. Writes do not scale this way — every write still has to go to the single primary and then to every replica — which is exactly why reads are easier than writes.
The replication-lag trap
Replication is almost always asynchronous: the primary commits a write and ships it to replicas a moment later. That gap — the replication lag — is usually a few milliseconds but can spike to seconds under load. During that window, a replica returns stale data.
This creates the classic read-your-writes bug: a user updates their profile photo (a write to the primary), the page reloads (a read from a lagging replica), and they see the old photo. To them, the write “didn’t work.”
Common fixes, in rough order of simplicity:
- Read-from-primary after write — for a short window after a user writes, route that user’s reads to the primary. Simple and effective.
- Sticky routing — pin a user to a specific replica so they at least see a consistent view, even if slightly behind.
- Wait for replication — the write returns a position/token; subsequent reads require a replica caught up to that position. Strongest, but more machinery.
In the interview: whenever you add read replicas, proactively mention replication lag and how you would handle read-your-writes. Interviewers specifically probe for this, because candidates who add replicas without noticing the staleness problem have not actually thought about consistency.
CDNs and the edge
For static and semi-static content — images, video segments, CSS, JavaScript, and even cacheable JSON — a CDN is the most effective read-scaling tool of all. A CDN replicates content to hundreds of edge locations worldwide, so a user in Mumbai is served from a nearby edge rather than an origin server in Virginia. This cuts latency dramatically and offloads the vast majority of traffic from your origin.
For a video platform or an image-heavy app, the CDN can carry well over 90% of all bytes served. Push anything cacheable to the edge, set sensible cache headers, and use versioned URLs (/static/app.9f3c2.js) so you can cache aggressively and bust the cache by changing the URL when content changes.
Precomputation: denormalization and read models
Caching and replicas help, but if the underlying read is a 6-table join that takes 400ms, you are only caching a slow query. The deeper fix is to make the read cheap in the first place by doing the expensive work ahead of time.
- Denormalization — store data in the shape you read it. Instead of joining
users,posts, andlikesat read time, keep apostrecord that already embeds the author name and like count. You trade write complexity (you must update the denormalized copy) for read speed. - Materialized views — precomputed, periodically refreshed query results. The database maintains them so reads hit a simple table.
- Precomputed read models (CQRS) — separate the write model from the read model entirely. Writes go to a normalized store; a pipeline transforms them into read-optimized views (a per-user feed, a leaderboard, a search document). The read side becomes a single key lookup. This is exactly how news feeds and timelines are built, and it is the natural end-state when a read is both expensive and extremely frequent.
The principle: move work from read time to write time. Since reads vastly outnumber writes, doing the work once per write instead of once per read is almost always a win.
When reads still don’t scale
If you have layered caches, replicas, a CDN, and precomputed read models and reads are still a bottleneck, reach for:
- Covering indexes — indexes that contain all the columns a query needs, so the database answers from the index without touching the table.
- Read-optimized stores — move specific read patterns to a specialized engine: Elasticsearch for text search and filtering, a columnar store for analytics, a graph database for relationship queries.
- Serving slightly stale data — for many read paths, returning data that is a few seconds old is perfectly acceptable and lets you serve entirely from cache. Decide explicitly which reads can tolerate staleness; most can.
Key Takeaways
-
Notice the read-to-write ratio first. Most systems are read-heavy (10:1 to 1000:1), and that asymmetry drives the whole design. Reads scale by copying; writes do not.
-
Answer reads as far from the database as possible. Stack layers — CDN, application cache, read replicas — so each absorbs traffic and the primary database sees only a trickle.
-
Cache hit ratio is non-linear leverage. A 95% hit ratio cuts database read load 20x; 99% cuts it 100x. Reason about the hit ratio, not just “add a cache.”
-
Read replicas scale read throughput almost linearly — but they lag. Always mention replication lag and how you handle read-your-writes (read-from-primary after write, sticky routing, or wait-for-replication).
-
Use a CDN for anything static or cacheable. It offloads the majority of bytes and slashes latency by serving from the edge.
-
Precompute expensive reads. Denormalize, build materialized views, or keep a separate read model (CQRS). Moving work from read time to write time wins because reads vastly outnumber writes.
