System Design Foundations
Caching
Why does a creator's Instagram profile feel instant when millions of phones ask for the same avatar, bio, and posts? The database cannot answer every identical read from scratch, so almost none of those reads ever reach it.
A keeps a fast copy of the data closer to the user than the database: in the browser, on a (servers placed near users), in the memory of your app process, or in a distributed pool (a shared in-memory store such as Redis that every app replica can reach). Your job on this page is choosing where to cache, how long entries live, and what happens when the data changes.
Profile-view journey
Bytes that are identical for every user stop at the edge and never reach your servers.
Memory inside one app replica serves its hottest keys without any network hop.
Distributed cache
A shared in-memory pool serves hot reads for every app replica at once.
Database
The source of truth, read only on a cache miss or right after a write.
What actually happens
The profile in the story above looks like one screen. Underneath, a single read may be answered by the browser cache, by a POP (point of presence, a cache server physically near the user), by an inside the app, or by a Redis-style pool, before anyone touches Postgres (the database). When a layer does not have the answer, that is a miss, and the request cascades outward until something recomputes the value or reads durable storage. The layer that finally got the answer then fills its cache with a (an expiry time) plus jitter (a small random offset so many entries do not expire on the same tick), and the next viewer is cheaper.
When the creator edits their bio, the hard part begins: , which means getting rid of the copies that are now wrong. One logical profile may sit in many keys: the detail page, the search card, a recommendation list, and a CDN response. The beginner-safe story is still : commit the change to the database first, delete the cache key, and let the next read reload the truth. Every later section on this page is a refinement of that one sentence.
Scale problems show up in three shapes you will meet below: a (many replicas missing at once after a mass expiry), a (one object so popular it melts a single cache shard), and a cold deploy that empties memory and tanks the (the share of reads the cache answers).
Try it: follow a cache entry through its life
This is one application process caching one profile. A version number represents the profile’s contents: v2 is a newer edit than v1. Requests and time move only when you press a button, so you can inspect the gap between a miss starting a database read and that read filling the cache.
Finish pending loads before changing coalescing. Changing TTL affects future fills; it does not extend the current entry.
Callers
0 waiting
0 replies returned
Cache at t=0s
Empty
Next read will miss
Authoritative database
v1
0 loads in progress
The cache is empty. Send a read or a burst to start loading the profile.
- Cache hits
- 0
- Cache misses
- 0
- Origin reads started
- 0
- Hit ratio
- No requests yet
Start with a cold burst. Press “Burst of 20 reads” and then “Complete next load.” With coalescing, all twenty callers miss, but one database read answers them. Reset, turn coalescing off, and repeat: twenty loads start before any has filled the cache. The hit ratio is zero in both experiments. This is why origin traffic and waiting callers matter alongside hit ratio.
Now make a hit stale. After filling v1, edit the origin and read again before the TTL expires. The cache returns v1 even though the database holds v2. Advance to the expiry time, read, and complete the load to fetch v2. Expiration prevents future hits on the old entry; it does not fetch the replacement by itself. Shortening TTL trades fewer opportunities for stale hits against more database reads.
Look for the refill race. Reset, start a read of v1, edit the origin to v2, then complete the old load. It caches v1 with a new TTL. Here a load captures its database snapshot when it starts; real reads can also overlap writes. A version check or a cache-fill lease can stop an older reader from repopulating an invalidated value. Coalescing reduces duplicate work, but does not establish that ordering guarantee.
Model scope: one key, one process, successful loads, no eviction or background refresh. A process-local coalescer still allows one origin read per process across a fleet. See the AWS Builders’ Library discussion of coalescing and cache failures and Redis expiration semantics.
How to choose a cache strategy
Knowing the layers is not the same as knowing which one to use. Three questions settle most caching decisions before you write code.
1. What staleness can users tolerate? Immutable assets (files whose bytes never change) can live for days. Prices and inventory may need seconds, or a write path that bypasses the cache until the change is committed.
2. Is the data shared across many users? Put shared bytes on a or in the distributed cache so one fill serves everyone. Keep private sessions and tiny hot reference tables closer, in an inside the process, but only after you have measured that they really are hot.
3. What happens when the cache disappears? Reads may slow down or serve stale data, and that is acceptable. Writes must still commit to the database and must never silently depend on the cache accepting them.
Simple rule of thumb
Start with plus explicit plus jitter. Add an only after measuring hotspots. Protect the origin (the database or service behind the cache) with refresh on cold keys, so one missed key triggers one reload, not hundreds.
In simple terms: cache what is hot and safe to be slightly old, and always know how freshness returns after a write.
Cache-aside invalidation flow
Deleting after a database commit is a useful baseline, but it still has a race. A reader misses and reads old version 7; a writer commits version 8 and deletes the cache key; then the first reader populates version 7 after the deletion. A TTL eventually repairs this stale fill, but strict read-after-write behavior needs additional coordination, version-aware fills, or a read routed to the authoritative store. Explain which guarantee the chosen pattern actually provides.
The rule of thumb starts with , so here is that pattern slowed down into individual messages, first for a read that misses and then for a write that must invalidate.
Read the diagram definition
sequenceDiagram
autonumber
participant U as User
participant A as App
participant C as Cache
participant D as Database
U->>A: Read product 123
A->>C: GET product:123
C-->>A: Miss
A->>D: SELECT product 123
D-->>A: Fresh row
A->>C: SET product:123 with TTL+jitter
A-->>U: Response
U->>A: Update product 123
A->>D: Commit write first
D-->>A: OK
A->>C: DELETE product:123
A-->>U: Updated response
Note over C,D: Next read reloads source of truthThe diagram shows one key. In practice the same product appears in several keys (see What actually happens above), so a single update has to find all of them. Good key naming, surrogate tags (a shared label on every key that belongs to one entity, so they can be purged together), version numbers in keys, and a short as a safety net keep one update from turning into a manual purge incident.
Multi-layer cache hierarchy
The flow above treated "the cache" as one box. Real systems stack several, and the table below lists them from closest to the user (fast, small) to the durable source of truth (slow, complete).
| Layer | Type | Latency | Technology |
|---|---|---|---|
| L0 | Browser cache | ~0 ms | Cache-Control, storage APIs, service workers |
| L1 | CDN edge | 5-20 ms | CDN points of presence |
| L2 | API gateway | 1-5 ms | Managed gateways with response caching |
| L3 | In-process | sub-µs | Language-native maps or local cache libraries |
| L4 | Distributed | ~1 ms LAN | Distributed key-value cache pools |
| L5 | Database buffers | ~5 ms | Buffer pool, shared_buffers |
| L6 | Disk | ~10 ms SSD | Durable source of truth |
Read the table as a chain: every hop that hits removes load from the tier beneath it, and every miss falls through to the next row. Note that the table's L1 to L6 labels number the whole chain, while the rest of this page uses "L1" in its more common sense of the in-process tier (row L3 here). LAN means the cache sits in the same data centre, so a round trip costs about a millisecond; shared_buffers is Postgres's own in-memory page cache, which is why a "database read" is not always a disk read.
Three of these tiers are the ones you actively design. The in-process tier is the fastest but private to one replica, so it suits configuration blobs or reference tables that refresh periodically. The distributed tier keeps users consistent across app instances at the cost of a network hop per lookup. The CDN tier is great for bytes identical for many users (assets, some GET APIs), provided cache keys and TTLs are chosen deliberately rather than left at defaults.
When to add each tier
Do not build the whole hierarchy on day one. Add a tier only when the previous one still leaves measurable database or origin pressure.
- No cache: every read hits the database; simplest to run, and the first to fail under read-heavy traffic.
- Distributed cache: shared hot reads and sessions served once for all app replicas.
- L1 plus distributed: in-process memory for tiny hot keys, removing network hops on celebrity objects.
- CDN / edge cache: public GET responses stop before they reach the app tier at all.
Read and write patterns
Cache-aside leaves the application responsible for misses and invalidation. Write-through updates a backing store as part of the write path, while write-behind accepts writes into a buffer and persists them later. The last option changes the durability boundary: if the buffer is lost before persistence, acknowledged writes may disappear. It is appropriate only when that buffer has the required durability or the product explicitly tolerates the loss.
Whichever tiers you pick, each one needs a rule for how data gets in and how writes are handled. Cache-aside is the pattern from the flow above; two alternatives move the write into the cache itself.
Cache-aside (lazy loading)
def get_user(user_id):
data = cache.get(f"user:{user_id}")
if data:
return json.loads(data) # hit
data = db.query("SELECT * FROM users WHERE id=%s", user_id)
cache.setex(f"user:{user_id}", 300, json.dumps(data))
return data # miss filled
def update_user(user_id, updates):
db.execute("UPDATE users SET ...", user_id)
cache.delete(f"user:{user_id}") # invalidateThe default for most APIs: the write path is one delete, so there is no race between two writers updating the cache.setex sets the value with a 300-second TTL in one call.
Write-through & write-behind
# WRITE-THROUGH (sync cache + DB)
def update_user(user_id, updates):
data = db.execute("UPDATE ... RETURNING *", user_id)
cache.setex(f"user:{user_id}", 300, json.dumps(data))
return data
# WRITE-BEHIND (cache now, DB async)
def increment_views(video_id):
cache.incr(f"views:{video_id}")
# worker batch-flushes to DBsuits reads that must see the write immediately, because the cache is refreshed in the same request. answers from the cache and lets a worker flush later, trading durability for the ability to absorb spikes.
The table adds read-through, where the cache itself (not your app code) knows how to load a missing key from the origin.
| Pattern | Write path | Consistency | Typical use |
|---|---|---|---|
| Cache-aside | App writes DB, deletes cache | Eventual | General REST services |
| Write-through | Cache + DB synchronously | Stronger reads | Sessions, entitlement graphs |
| Write-behind | Cache first, DB async | Weakest | Counters, analytics buffers |
| Read-through | Cache loader on miss | Configurable | CDN, reverse proxies |
Stampede and hot keys
A stampede is duplicated refill work; a hot key is concentrated demand even when the key is present. For a stampede, let one caller refresh while others wait briefly or receive an allowed stale value, and stagger expirations. For a hot key, distribute read copies or add a small local cache because adding unrelated shards does not divide one key’s traffic. Track refill concurrency and per-key demand separately to distinguish the two problems.
The patterns above assume misses arrive one at a time. The two problems in this section are what happens when they do not: many replicas missing the same key together, or one key drawing far more traffic than its neighbours.
Here are the three mitigations from the blurb, side by side. In the lease code, nx=True means "set only if the key does not already exist," so exactly one caller wins the right to reload; the rest sleep briefly and read what the winner stored.
Lease / mutex
lock = cache.set(
f"lock:{key}", "1",
nx=True, ex=10)
if lock:
data = db.query(...)
cache.set(key, data)
else:
sleep(0.05)
return cache.get(key)Probabilistic early refresh
Each request has a small, growing chance of refreshing the key before its hard , so refreshes spread over time instead of all aligning on one clock tick.
Stale-while-revalidate
Serve the slightly old bytes immediately while a background task fetches fresh data and swaps it in silently. Users never wait for the reload.
Hot keys
A stampede is a burst in time; a is a burst in space, where one celebrity row lands on one cache shard that cannot keep up even when nothing has expired.
- Two-tier cache: an in-process L1 absorbs most hits on each pod, so the shared shard sees far fewer requests.
- Key fan-out: suffix the key with a replica number so several nodes each hold a copy of the hot payload.
- Request coalescing: the first miss registers an in-flight promise, and concurrent peers await that same single fill.
Consistency patterns
Stampedes and hot keys are about load. The other half of caching is freshness: how wrong is a cached value allowed to be, and how does it get corrected after a write?
There are four common ways to bring the cache back in line. Write-invalidate is the cache-aside delete you saw in the flow diagram: after the database commits, delete the affected keys and let readers reload lazily. Write-update pushes the fresh object into the cache instead of deleting it, which saves the next reader a miss but means two concurrent writers can overwrite each other; guard it with version numbers or CAS (compare-and-swap, an update that only applies if the value is still the one you last read).
Events / CDC (change data capture, streaming the database's own change log) moves invalidation out of the request path: centralised workers listen for changes and purge every key that depends on the changed row, which is how one update can reach the search card and the feed item as well as the detail page. Finally, the TTL safety net makes every entry expire eventually, with jitter, so a missed invalidation is a temporary bug rather than a permanent one.
Numbers to know: sizing and hit ratio
Patterns tell you what to build; numbers tell you how big. This box collects every figure on the page, so later sections point back here instead of repeating them.
Cache sizing worksheet
cache_bytes ≈ working_set_objects × avg_value_bytes × overhead_factorThe overhead factor covers key names, pointers, and fragmentation (memory wasted between values of different sizes). Multiply by the replica count because each replica holds a full copy.
| Object type | Calculation | Result |
|---|---|---|
| Active sessions | 100K sessions × 2KB × 2 replicas × 1.5 overhead | ~600MB provisioned |
| Hot profiles | 20M profiles × 2KB × 1.2 overhead × 2 replicas | ~96GB provisioned |
| API response cache | 1M hot responses × 4KB × 1.3 overhead × 2 replicas | ~10.4GB provisioned |
| Eviction headroom | Keep 20-30% free for churn and fragmentation | Avoids constant eviction loops |
Read the profile row as the worked example: twenty million warm profiles at about 2 KB of JSON come to roughly 48 GB of data, and two replicas double it to the 96 GB provisioned. Size for the burst too: a mass expiry can turn 500 database QPS (queries per second) into 10K in a moment, which is the stampede from the previous section.
TTL starting points
Memory is one axis; how long entries live is the other. These are typical starting ranges, always with jitter added.
Hit ratio and database relief
At 10,000 reads per second, a 95% hit rate leaves 500 misses per second, assuming one database operation per miss. A fall to 80% leaves 2,000 misses, four times the database load even though the hit rate fell by only 15 percentage points. Calculate miss traffic and miss latency directly; a healthy-looking global hit ratio can hide an expensive endpoint or a newly cold tenant.
Once the cache is sized and TTLs are set, the is the number that proves it is working. DAU below means daily active users; the 1M DAU scenario is shared by the other foundations pages.
The cards assume misses are spread evenly. A single hot key expiring on every replica at once breaks that assumption; the failure timeline further down shows exactly how.
Hit ratio hygiene
If the ratio from Numbers to know comes in lower than planned, one of three causes is almost always responsible.
- Working set larger than RAM: add capacity or split into tiers so the hot set fits in memory.
- Aggressive TTL: relax it once the business confirms how much staleness is acceptable.
- Deploy cold starts: warm keys with dual-write or shadow traffic before a pod takes live reads.
Distributed cache chooser
Sizing assumed a distributed pool; the remaining choice is which kind. In the table, AOF is an append-only file that logs every write so the store can rebuild after a restart, and cluster slots are the store's built-in way of assigning keys to shards.
| Concern | Richer cache store | Minimal cache store |
|---|---|---|
| Structures | Rich (hash, stream, etc.) | Opaque blobs |
| Persistence | Optional snapshots/AOF | Ephemeral by design |
| Sharding story | Built-in cluster slots | Client / proxy hashing |
Case study pattern: graph-shaped caching
At the very largest scale, the distributed pool stops being one cluster and becomes an architecture of its own. This is the vocabulary from the Facebook memcache paper in the references.
Leases are the same idea as the single-flight lock in the stampede section, applied fleet-wide: the cache hands one token to the first client that misses, and only that client may repopulate the key. A routing proxy sits between the apps and the cache nodes to multiplex connections, fail over shards, and centralise policy so every app does not need its own client logic. Regional caches serve reads from the nearest data centre and propagate invalidations asynchronously, accepting a short window of staleness across regions. Gutter pools are small, short-lived overflow caches that take the traffic of a node that has disappeared, so the database never sees the full miss load while the fleet heals.
Failure timeline, outage drill, and the invariant
Everything above describes the cache working. This section is about the cache not working: a stampede second by second, what to do when the whole pool is gone, and the one rule that must survive both.
Timeline: hot-key stampede
Here is the celebrity profile from the hero story, with a TTL and no jitter. Note that the fix at t=70s is coordination, not hardware.
t=0 Celebrity profile: 10K QPS hits; cache hit ratio ~95%; DB calm t=60s TTL expires on the same second across many replicas (no jitter) t=61s Every replica misses; 10K identical SELECTs hit the primary t=62s DB p99 collapses; app timeouts; clients retry → more misses t=70s With single-flight + jitter + soft TTL: one refresher, peers wait/stale Outcome Stampede is a coordination failure, not a "need bigger DB" moment
Cache outage and cold-start drill
A cache outage should not trigger unlimited fall-through traffic. If the database cannot serve the uncached peak, protect it with a concurrency limit, a bounded queue, and an explicit degraded response. Serve stale public data when allowed, but fail or defer operations that require current authoritative state. Test both a missing cache and an empty cache: timeouts from an unreachable cache can add latency before the database is even queried.
A stampede loses one key. An outage or a fresh deploy loses every key at once, so the plan has to be rehearsed, not improvised.
Cache unavailable
Losing an 85% hit ratio at peak sends the roughly 144 database read QPS that remained back to the full peak read rate (see Numbers to know). Add database rate limits, feature flags, a local stale cache, and clear overload errors for non-critical reads.
Cache cold after deploy
Warm the top keys gradually and jitter their TTLs. The single-flight refresh from the stampede section keeps the first miss per key alone while peers wait or receive stale data.
Test the degraded path
Run game days (planned failure rehearsals) that block cache access, flush a representative namespace, and expire one hot key under load. Watch database QPS, p95/p99 latency, error rate, and refill concurrency.
What must never become false
Both drills work only because of one invariant. The database (or origin) remains the source of truth. A cache miss, eviction, or outage may slow reads; it must not invent data or silently drop committed writes. After a successful write, readers eventually see the truth via and/or , and cache success is never a substitute for the database commit.
How to explain this in an interview
With the mechanics and failures covered, this is the shape of a strong caching answer and the questions that show you understand the trade-offs.
A strong answer includes
- What to cache: hot reads, expensive joins, immutable assets, or semi-dynamic GET APIs.
- Where to cache: browser/CDN for shared bytes, distributed cache for cross-instance state, L1 for tiny hot reference data.
- Freshness policy: TTL, explicit delete, event-based purge, versioned keys, or stale-while-revalidate.
- Failure mode: a cache outage should degrade to slower reads, never to corrupted writes.
Questions worth asking
- What staleness can users tolerate for this object, in seconds or minutes?
- Can one key become hot enough to overload a single cache shard?
- How will we prevent stampedes after deploys or mass expiry of keys?
- Which metrics prove the cache is helping: hit ratio, evictions, p95/p99, DB QPS?
Wrap-up
You have seen the layers, the patterns, the numbers, and the failures. Use these exercises to check the ideas stuck, then follow the references for the primary sources.
Exercises
- At the peak read rate in Numbers to know, how many DB reads remain at 80%, 90%, and 95% hit ratio? What degraded mode do you use if the hit ratio drops to 0% for five minutes?
- A product update touches detail, search card, and feed keys. Design cache-aside invalidation so you never leave a stale paid price in the CDN or app cache forever.
- Choose cache-aside, write-through, or write-behind for a user session, an inventory count, and an analytics page-view counter. Name the durability risk for each.
References
- RFC 9111: HTTP Caching (freshness, revalidation, Cache-Control).
- Facebook: Scaling Memcache at Facebook (NSDI) / related memcache lease and gutter patterns.
- Redis documentation: Eviction policies, key expiration, and clustering hot-key guidance.
Key takeaway
Follow the strategy from the beginning of the lesson and rehearse the cache outage path. Fall back to the database only within its safe capacity; otherwise serve allowed stale data, defer work, or reject excess requests explicitly. Monitor hit ratio, miss traffic, eviction rate, and latency together, because any one metric can look healthy while users experience failures.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Define cache keys, freshness, invalidation, admission, eviction, stampede control, negative caching, and consistency tests.
Explain the decision
How does probabilistic early refresh reduce stampedes?
Read the reasoning
Requests refresh before hard expiry with increasing probability near expiration, spreading regeneration work instead of synchronizing it.
Apply it to a production scenario
A hot key expires and 20,000 requests hit the database. What defenses compose?
Read one approach
Singleflight/coalescing, stale-while-revalidate, jittered TTLs, hot-key replication, bounded regeneration, and a stale fallback.
Work through the numbers
At 40,000 reads/s, a 95% hit rate leaves how much origin read load?
Read the worked calculation
2,000 reads/s; also model miss amplification during cold start and partial cache loss.
Reason through a failure
What must a cached authorization decision preserve?
Read the recovery reasoning
Revocation and tenant isolation must meet a stated maximum staleness bound; cache keys must include every security-relevant dimension.
Sources and further study
Continue with Redis.