- A cache is a small, fast copy of data whose source of truth lives somewhere slower. Reading RAM over the network (~0.2–0.5 ms) is 10–100× faster than a database query that touches disk, and it costs a fraction of the CPU.
- Caches exist at every layer: the phone, the CDN, the gateway, inside each service process, a distributed cache tier (Redis/Memcached) and the database's own buffer pool.
- The workhorse pattern is cache-aside: read the cache, on a miss read the DB and fill the cache; on a write, update the DB and then delete (not update) the cache key.
- When the cache is full, an eviction policy (LRU, LFU, FIFO, TTL) decides what to drop; invalidation decides when cached data is no longer true. Invalidation and races are the hard part — Facebook's public Scaling Memcache paper solves them with leases.
- Know the classic failure modes and fixes: stampede (coalescing, locks, early expiration), hot keys (replicas, local L1), penetration (negative caching, Bloom filters) and avalanche (jittered TTLs).
- Instagram's Feed Cache (Redis) holds pre-computed timelines, counters and seen-state; it is what makes opening the app feel instant.
1. The problem: why cache at all?
Open Instagram and, within a few hundred milliseconds, the app shows your home feed: 10–20 posts, each with an author name and avatar, a like count, a comment count, the first comments, whether you already liked it, and the story tray at the top showing which stories you have not yet seen. Behind that single screen sit hundreds of small data lookups. Instagram is also overwhelmingly read-heavy: a post is written once but read thousands or millions of times. If every one of those reads went to the primary databases (sharded PostgreSQL and Cassandra in our diagram), three bad things would happen:
- Latency adds up. A database read that misses the DB's memory and touches SSD costs ~1–10 ms including query parsing, locking and network. Do 200 of them, even partly in parallel, and the feed takes seconds.
- Databases are expensive to scale. A database node does a lot of work per query (parsing, planning, transactions, durability). A cache node does almost nothing: hash the key, copy bytes from RAM to the socket. A single Redis node serves ~100,000+ simple operations per second; a database node might serve a few thousand complex queries per second.
- Popularity is skewed. A celebrity's post or profile is read millions of times per minute. Without a cache, the single database shard holding that row would melt (a hot spot — see Sharding).
1.1 Latency numbers that motivate caching
The reason caching works is that storage media differ in speed by orders of magnitude. These are approximate, order-of-magnitude figures (often called "latency numbers every programmer should know"); exact values depend on hardware, but the ratios are what matter:
| Operation | Approx. latency | Relative to a RAM read | Where you meet it |
|---|---|---|---|
| CPU L1 cache reference | ~1 ns | 0.01× | Hot loops inside a process |
| Main memory (RAM) reference | ~100 ns | 1× | In-process cache (a hash map in the service) |
| Read 1 MB sequentially from RAM | ~10–250 µs | 100–2,500× | Copying a cached value |
| Random 4 KB read from NVMe SSD | ~20–150 µs | 200–1,500× | DB page not in the buffer pool |
| Round trip inside one data center | ~0.2–0.5 ms | 2,000–5,000× | Any call to Redis/Memcached or another service |
| Read 1 MB sequentially from SSD | ~0.5–1 ms | ~5,000–10,000× | Scanning data on disk |
| Typical indexed DB query (end to end) | ~1–10 ms | 10,000–100,000× | PostgreSQL / Cassandra read |
| Spinning-disk seek | ~5–10 ms | ~100,000× | Cold archival storage, old HDD databases |
| Round trip across a continent / ocean | ~70–150 ms | ~1,000,000× | Cross-region calls, user → far data center |
So the hierarchy is: in-process RAM ≫ network RAM (distributed cache) ≫ SSD-backed database ≫ disk ≫ cross-region network. Every caching decision is about moving a frequently read piece of data one or more rungs up this ladder.
1.2 Why it works: locality and skew
Caching only pays off if the same data is read repeatedly. Two properties make that true for Instagram:
- Temporal locality — something read recently is likely to be read again soon (a new post is read heavily in its first hours).
- Skewed popularity — access follows a power law (often modelled with a Zipf distribution): a small fraction of keys gets most of the traffic. A cache holding the hottest ~10–20% of data commonly serves 80–95%+ of reads.
The quantity you care about is the hit ratio h = hits / (hits + misses). The average latency of a cached read path is:
avg_latency = t_cache + (1 − h) × t_db
Example: t_cache = 0.5 ms, t_db = 10 ms
h = 0.80 → 0.5 + 0.20 × 10 = 2.5 ms DB sees 20% of reads
h = 0.95 → 0.5 + 0.05 × 10 = 1.0 ms DB sees 5% (4× less than at 80%)
h = 0.99 → 0.5 + 0.01 × 10 = 0.6 ms DB sees 1% (5× less than at 95%)
Notice that the last few percentage points of hit ratio cut database load dramatically. That is why large companies obsess over hit ratio — and also why a sudden drop in hit ratio (a cache node dying, a bad deploy changing key names) can take the database down.
2. Where caches live: the cache layers
"Add a cache" is not one decision — a single feed request passes through up to six caches. Each layer is closer to the user (faster, but harder to keep consistent and smaller) or closer to the data (slower, but easier to invalidate).
| Layer | What it caches (Instagram) | How it's kept fresh |
|---|---|---|
| Client cache (app memory + disk) | Downloaded images, the last feed page (instant, offline-capable open), your own profile — see Clients | TTL, HTTP Cache-Control/ETag, pull-to-refresh; you can't purge a phone |
| CDN | Immutable media renditions and video segments — see CDN | Versioned URLs, long TTL, purge API |
| API gateway / reverse proxy | Public, non-personalised responses; token-validation results — see API Gateway | Short TTL (seconds) |
| In-process (L1) cache | Feature flags, config, the hottest few thousand keys (celebrity profile) | Very short TTL (1–10 s); thousands of copies to invalidate |
| Distributed cache (Redis / Memcached) | Timelines, post and user objects, counters, seen-state, sessions | Cache-aside with delete-on-write, TTLs, invalidation events |
| DB buffer pool / page cache | Hot data and index pages of PostgreSQL / Cassandra | Managed by the DB — always consistent; cold after restart |
The deeper (closer to the database) a cache sits, the more correct it is: the DB buffer pool is always consistent because the database itself manages it. The closer to the user, the more you trade freshness for speed. Most design discussions in interviews are about the distributed cache tier, so that is where we'll spend most of our time.
3. Caching patterns: how reads and writes flow
A caching pattern answers two questions: who loads data into the cache on a read, and what happens to the cache on a write. There are five classic patterns. Real systems mix them per data type.
3.1 Cache-aside (lazy loading) — the default
The application treats the cache as a side store. On a read it checks the cache; on a miss it reads the database and writes the result into the cache with a TTL. On a write it updates the database and then deletes the cache key so the next reader reloads fresh data.
def get_user(user_id):
key = f"user:{user_id}:v3" # version suffix lets you change the format safely
cached = cache.get(key)
if cached is not None:
return decode(cached) # HIT (~0.3 ms)
row = db.query("SELECT * FROM users WHERE id = %s", user_id) # MISS (~5 ms)
if row is None:
cache.set(key, NOT_FOUND, ttl=60) # negative caching (section 7.3)
return None
cache.set(key, encode(row), ttl=3600 + random.randint(0, 600)) # jittered TTL (7.4)
return row
def update_user(user_id, changes):
db.update("users", user_id, changes) # 1. write the source of truth first
cache.delete(f"user:{user_id}:v3") # 2. then invalidate - delete, don't overwrite
- Pros: only data that is actually read gets cached (memory-efficient); a cache outage degrades to "slower" rather than "broken" because the app can still reach the DB; works with any database; simple to reason about.
- Cons: the first read of every key is a miss (three network hops); stale data is possible between the DB write and the delete; the app code carries the caching logic; subtle races (section 6).
Facebook's Memcache deployment, described in the public Scaling Memcache at Facebook paper (NSDI 2013), is a giant cache-aside ("look-aside" in the paper's words) system, and it is the default answer in interviews.
3.2 Read-through
Same idea, but the miss handling lives inside the cache layer: the app calls cache.get(key) and the cache (or a client library wrapping it) calls a registered loader function to fetch from the DB on a miss. Examples: Guava/Caffeine LoadingCache in Java, NCache, DynamoDB Accelerator (DAX) in front of DynamoDB.
- Pros: app code is cleaner; the library can coalesce concurrent misses for the same key automatically. Cons: the cache must understand your data source; less per-call flexibility.
3.3 Write-through
Every write goes to the cache and the database synchronously (typically cache layer writes DB, then itself); the write returns only after both succeed.
App Cache DB |--- PUT k=v ------->| | | |---- write k=v -------->| | |<------- OK ------------| | | store k=v | |<------ OK ---------| |
Pros: the cache is warm for recently written data and consistent with the DB when both writes succeed. Cons: two writes on every request; caches data that may never be read; a failure between the writes still needs handling.
3.4 Write-behind (write-back)
Writes go only to the cache, which acknowledges immediately and flushes to the database asynchronously later, often batched or coalesced.
App Cache DB |--- INCR likes --->| | |<------ OK --------| (dirty: likes += 1) | |--- INCR likes --->| | |<------ OK --------| (dirty: likes += 2) | | ... every 1 s or 1,000 updates ... | | |--- UPDATE likes = likes+2 ->| one DB write for many cache writes
Pros: lowest write latency; absorbs bursts; coalesces many updates into one DB write. Cons: data-loss risk if the cache node dies before flushing (unless the cache is replicated/durable); the DB is temporarily behind.
Many "counter" systems use a variant of this: increments land in Redis (or in a Kafka topic) and a background job periodically persists aggregated totals. See Likes, Comments & Counters.
3.5 Write-around
Writes go directly to the database, bypassing the cache (plus a delete of any old copy); the cache fills only on reads. It avoids polluting the cache with write-once, read-rarely data (old comments, analytics events), at the cost of a guaranteed miss on the first read after a write.
3.6 Comparison
| Pattern | Read path | Write path | Consistency | Write latency | Best for (Instagram example) |
|---|---|---|---|---|---|
| Cache-aside | App reads cache → DB on miss → app fills | App writes DB, deletes key | Eventual; small stale window | DB only | User profiles, post metadata — general purpose |
| Read-through | Cache loads from DB on miss | (paired with any write strategy) | Same as cache-aside | — | Libraries/local caches with loaders; config |
| Write-through | Cache (usually hit) | Cache and DB synchronously | Strong-ish (if both writes succeed) | Higher (2 writes) | Data read right after it's written: your own new post in your profile grid |
| Write-behind | Cache | Cache now, DB later in batches | Weak; risk of loss | Lowest | View counts, "last active" timestamps, high-rate counters |
| Write-around | Cache-aside | DB only (+ delete key) | Eventual | DB only | Write-heavy, rarely re-read data: old comments, logs |
4. Eviction: what to throw away when the cache is full
RAM is finite and expensive, so a cache is almost always smaller than the dataset. When it's full and a new item arrives, an eviction policy picks a victim. Don't confuse eviction with invalidation: eviction removes data because of space (the data is still correct); invalidation removes data because it's wrong (the source of truth changed).
4.1 LRU — Least Recently Used
Evict the item that hasn't been accessed for the longest time. It's the default almost everywhere because recency is a good predictor of future use (temporal locality). The classic O(1) implementation combines two data structures:
- a hash map from key → node, for O(1) lookup, and
- a doubly linked list ordered by recency: the head is the most recently used item, the tail is the least recently used. "Doubly" linked so a node can unlink itself in O(1) given only a pointer to it.
On get: find the node via the map, unlink it and move it to the head. On put of a new key when full: remove the node at the tail (and its map entry), then insert the new node at the head. Every operation is a constant number of pointer updates.
class Node:
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key=None, val=None):
self.key, self.val, self.prev, self.next = key, val, None, None
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.map = {} # key -> Node
self.head, self.tail = Node(), Node() # sentinels: head = MRU side, tail = LRU side
self.head.next, self.tail.prev = self.tail, self.head
def _unlink(self, n):
n.prev.next, n.next.prev = n.next, n.prev
def _push_front(self, n):
n.prev, n.next = self.head, self.head.next
self.head.next.prev = n
self.head.next = n
def get(self, key):
n = self.map.get(key)
if n is None:
return None # miss
self._unlink(n); self._push_front(n) # now the most recently used
return n.val
def put(self, key, val):
n = self.map.get(key)
if n is not None:
n.val = val
self._unlink(n); self._push_front(n)
return
if len(self.map) >= self.cap:
lru = self.tail.prev # least recently used
self._unlink(lru)
del self.map[lru.key]
n = Node(key, val)
self.map[key] = n
self._push_front(n)
(In Python you can get the same behaviour from collections.OrderedDict with move_to_end() and popitem(last=False), or functools.lru_cache for function results.)
How real caches do it: a full linked list costs two pointers (16 bytes) per item and a lock on every read. Redis therefore uses approximated LRU: each key stores a 24-bit "last access" clock; when memory is needed, Redis samples a few random keys (maxmemory-samples, default 5) and evicts the oldest among them, keeping a small pool of good candidates between rounds. With 10 samples it is very close to true LRU. Memcached uses a per-slab-class LRU that is segmented into hot/warm/cold sub-queues so a single scan of many keys doesn't flush the truly hot ones.
4.2 LFU — Least Frequently Used
Evict the item with the fewest accesses. Better than LRU when popularity is stable and a one-off scan (e.g. a batch job reading every profile once) would otherwise push hot items out. Naïve LFU has two problems: counters grow forever (an item popular last week stays forever) and new items start at 0 and get evicted immediately. Redis's LFU (allkeys-lfu) solves both with an 8-bit logarithmic (Morris) counter that increments probabilistically — so 255 can represent millions of hits — and decays over time (lfu-decay-time); new keys start at a small non-zero value. Modern designs such as W-TinyLFU (used by Java's Caffeine) combine a small LRU "window" with a frequency sketch and achieve near-optimal hit ratios.
4.3 FIFO, random and TTL-based
- FIFO — evict the oldest inserted item regardless of use. Cheap (a queue) but evicts hot items just because they're old. Interestingly, research systems such as S3-FIFO show that FIFO queues with a small probationary queue can match LRU with less locking.
- Random — evict a random key. Surprisingly decent under heavy skew, zero bookkeeping.
- TTL (time to live) — every key carries an expiry time; once it passes, the key is treated as absent. TTL is primarily a freshness tool (it bounds staleness) but it also frees memory. Redis expires keys lazily (checked on access) plus an active background sampler that deletes expired keys;
volatile-ttlevicts keys closest to expiry first.
| Policy | Evicts | Strength | Weakness | Redis setting |
|---|---|---|---|---|
| LRU | Least recently accessed | Great default, adapts quickly | Scan pollution; pointer/lock overhead | allkeys-lru, volatile-lru |
| LFU | Least frequently accessed | Keeps stable hot set; scan-resistant | Slow to adapt without decay | allkeys-lfu, volatile-lfu |
| FIFO | Oldest inserted | Very cheap, lock-friendly | Ignores popularity | — (Memcached/other caches) |
| Random | Any key | Zero overhead | May evict a hot key | allkeys-random |
| TTL | Keys nearest expiry | Bounds staleness | Choosing the TTL is a guess | volatile-ttl (+ per-key EXPIRE) |
| No eviction | Nothing — writes fail when full | Never silently loses data | Errors under memory pressure | noeviction (use when Redis is a primary store) |
The volatile-* variants only evict keys that have a TTL, which lets you mix "must keep" keys (no TTL) and "cache" keys in one Redis — though it's cleaner to use separate clusters.
5. Invalidation: keeping the cache truthful
When the source of truth changes, cached copies become stale. Invalidation strategies, from simplest to most precise:
| Strategy | How it works | Staleness bound | Cost / risk |
|---|---|---|---|
| TTL expiry only | Set a TTL; accept staleness until it expires | ≤ TTL | Simplest; short TTL → low hit ratio, long TTL → stale data. Fine for counts, recommendations. |
| Delete on write (explicit invalidation) | Writer deletes affected keys after committing to the DB | ~milliseconds | Writer must know every key derived from the row; a crashed writer leaves stale data → keep a TTL as a safety net. |
| Update on write | Writer overwrites the cached value | ~milliseconds | Concurrent writers can leave the older value in the cache (section 6.2). Prefer delete. |
| Event / CDC-driven | A daemon tails the DB's commit log (or a Kafka topic of change events) and deletes keys | Replication lag (ms–s) | Decoupled and reliable (never misses a committed write); one more pipeline to run. See Message Queues. |
| Versioned keys | Include a version in the key (user:42:v17); bumping the version makes old entries unreachable, and they age out by LRU/TTL | Instant (once readers see the new version) | Readers need the current version number (itself cached); wastes memory until old keys evict. |
| Write-through | The cache is updated as part of the write path | None (if both succeed) | Higher write latency; partial failures. |
A good default for a system like Instagram: cache-aside + delete-on-write + CDC-driven deletes as a backstop + a TTL on every key so that any bug eventually heals itself. Facebook's paper describes exactly this backstop: daemons called mcsqueal tail the MySQL commit log and broadcast deletes for every committed change, so even if a web server crashes after writing the DB, the cache still gets invalidated.
user:42, the rendered header of every comment by user 42 if you cached rendered comments, search typeahead entries, and so on. The safest designs cache normalised objects (one key per entity) and assemble responses at read time, so a change invalidates one key, not thousands.6. Consistency races (and why "delete, don't update")
Cache and database are two separate systems with no shared transaction, so concurrent operations can interleave in nasty ways. Two classic races:
6.1 Race 1: the stale set after delete
A reader misses, reads the DB, but is slow to write the value into the cache. Meanwhile a writer updates the DB and deletes the key. Then the slow reader finally sets the old value — which now stays in the cache until its TTL expires.
Reader R Cache Writer W DB |-- GET user:42 -------------->| | | |<------------- MISS ----------| | | |-- SELECT user:42 -------------------------------------------------------> | name = "ana" |<------------------------------------------------------------ "ana" ------- | | (R pauses: GC, slow network...) |-- UPDATE name="ana_k" ->| | |<--- DELETE user:42 ----| | |-- SET user:42 = "ana" ------>| ✗ stale value written AFTER the delete | | | cache says "ana", DB says "ana_k" until TTL|
This window is small but at billions of requests per day it happens constantly. Fixes:
- Leases (Facebook Memcache — below): the cache itself rejects the late
SET. - Compare-and-set with versions: store a row version with the value and only overwrite if the new version is higher (Redis Lua script or Memcached
cas). - Delayed double delete: delete, then delete again ~1 s later to remove any stale set that slipped in. Crude but common.
- A TTL on every key bounds the damage.
6.2 Race 2: concurrent updates applied out of order
If writers update the cache instead of deleting, two writers W1 (sets bio = A) and W2 (sets bio = B) can commit to the DB in order A then B but reach the cache in order B then A. The DB says B; the cache says A — forever (until TTL). A delete doesn't have this problem: deletes are idempotent and order-independent ("delete, delete" in any order leaves the same result), and the next reader repopulates from the DB, which is authoritative. That's why the Memcache paper's rule is: on writes, delete cached data rather than updating it. Updating can still make sense for derived structures that you modify with commutative operations (e.g. INCR on a counter, ZADD of a post ID to a timeline).
6.3 Leases (from Scaling Memcache at Facebook)
Facebook's lease mechanism fixes both stale sets and thundering herds with one small idea:
- On a cache miss, memcached hands the client a lease token — a 64-bit number bound to that key.
- The client reads the DB and calls
set(key, value, lease_token). - If a delete for that key arrived in the meantime, memcached has invalidated the token, so the late set is rejected. Race 1 is gone: a slow reader can no longer overwrite a newer invalidation.
- Herd control: memcached hands out at most one token per key every ~10 seconds (the paper's default). Other clients that miss during that window get a special "wait and retry" response instead of a token, so only one client goes to the database; the others retry a few milliseconds later and find the value already filled.
- Stale reads when acceptable: after a delete, the old value is kept briefly in a special state; clients that can tolerate slightly stale data may receive it instead of waiting.
The paper reports that leases cut the peak database query rate for some hot keys from ~17,000/s to ~1,300/s.
7. Failure modes at scale (and their fixes)
Four classic problems show up whenever a cache protects a much weaker database. Interviewers love them because each has a crisp name and a crisp fix.
7.1 Cache stampede (thundering herd / dog-piling)
A very popular key expires (or is deleted). In the next few milliseconds, thousands of requests all miss and all query the database for the same row and all try to recompute the same value. The DB shard spikes, latency rises, more requests time out and retry — a feedback loop. Example: a celebrity with 600M followers edits their bio; the delete triggers a herd of profile reads.
Fixes, often combined:
- Request coalescing (single-flight / promises). Within one process, only the first miss for a key does the fetch; concurrent callers wait on the same in-flight future/promise and share the result. Instagram Engineering has publicly written about using promise-based caching for exactly this ("thundering herds and promises"). Across servers, the lease mechanism above does the same at the cache server.
- Distributed lock / mutex on miss. The first miss acquires a short lock in Redis; others wait briefly or serve stale data:
SET lock:user:42 <random-token> NX PX 3000 # only one caller gets "OK" # winner: read DB, SET user:42 ..., then release the lock only if the token still matches (Lua) # losers: sleep 20-50 ms and retry GET user:42 (or return the stale copy) - Serve stale while revalidating. Store the value with a soft expiry inside the payload and a longer hard TTL. After the soft expiry, one request refreshes in the background while everyone else keeps getting the slightly stale value.
- Probabilistic early expiration (XFetch). Each reader independently decides to refresh before expiry with a probability that rises as expiry approaches, so typically exactly one request refreshes early and the key never actually expires under load:
def should_refresh(now, expiry, delta, beta=1.0): # delta = how long a recompute takes (e.g. 0.05 s); beta > 1 refreshes earlier return now - delta * beta * math.log(random.random()) >= expiry # log(random()) is negative, so the subtraction adds a random "head start"
7.2 Hot keys
Even without expiry, one key can receive more traffic than one cache node can serve. A Redis node handles ~100K–200K simple ops/s; a viral post's like counter or a celebrity profile can see far more, and with key-based sharding (consistent hashing) all of it lands on a single node. Fixes:
- Replicate the hot key: store N copies under
post:99:counts#0…#7, which hash to different nodes; readers pick a random copy, writers update/delete all copies. Or use read replicas of the shard. The Memcache paper also describes replicating a whole pool for keys with very high read rates. - Local L1 cache: keep the hottest keys in each app server's memory for 1–5 seconds. With 5,000 app servers, each L1 turns millions of reads per second into ~1 request per server per TTL. Staleness is bounded by the tiny TTL.
- Detect hot keys automatically (sampling in the client, Redis
--hotkeyswith LFU, proxy statistics) and promote them to the L1 or replicated tier dynamically. - Split writes: for hot counters, shard the counter itself (
likes:99:shard{0..15}) and sum on read. See Likes & Counters.
7.3 Cache penetration (lookups for things that don't exist)
If a key doesn't exist in the DB, cache-aside never caches anything for it, so every request for it goes to the database. A bug, a scraper, or an attacker iterating over random usernames or post IDs can bypass the cache entirely. Fixes:
- Negative caching: cache "not found" as a sentinel value with a short TTL (30–300 s). Remember to delete it when the item is created (e.g. a username is registered).
- Bloom filter in front of the DB: a compact probabilistic set of all existing keys. It answers "definitely not present" or "maybe present". If "definitely not", reject without touching cache or DB.
- Input validation and rate limiting — malformed IDs should never reach storage. See Rate Limiting.
Bloom filter in one paragraph: a bit array of m bits and k hash functions. To add a key, set the k bits it hashes to. To query, check those k bits: if any is 0, the key was definitely never added; if all are 1, it was probably added (other keys may have set those bits — a false positive). The false-positive rate is ≈ (1 − e^(−k·n/m))^k. With 10 bits per key and k = 7, it's about 0.8%. For 2 billion usernames that's 2×10⁹ × 10 bits = 20 Gbit ≈ 2.5 GB of RAM — far smaller than the usernames themselves. Standard Bloom filters can't delete; counting Bloom filters or cuckoo filters can.
7.4 Cache avalanche
Many keys expire at the same moment — or a whole cache cluster restarts — and the database suddenly receives the full read load. Typical cause: a batch job warmed a million keys at 03:00 with TTL = 3600, so they all expire at 04:00. Fixes:
- Jittered TTLs:
ttl = base + random(0, 0.1 × base)spreads expiries out. - High availability for the cache: replicas and fast failover, so a node loss doesn't empty a large slice of the keyspace. Facebook's gutter pool (~1% of servers) temporarily serves keys of failed hosts with short TTLs.
- Protect the DB: request coalescing, circuit breakers and load shedding so the DB survives while the cache refills (see Resilience Patterns).
- Gradual warm-up before sending full traffic to a new/empty cluster (section 10).
8. Redis vs Memcached
Both are in-memory key-value stores accessed over the network. Memcached is a deliberately simple, multi-threaded cache; Redis is a "data structure server" that can also act as a database.
| Memcached | Redis (and its open-source fork Valkey) | |
|---|---|---|
| Data model | Opaque byte strings (default max item 1 MB) | Strings, lists, hashes, sets, sorted sets, bitmaps, HyperLogLog, streams, geo, JSON (module) |
| Threading | Multi-threaded — scales up on one big box | Commands run on a single main thread (atomic, no locks); I/O threads help networking since 6.0 → scale out with more shards |
| Memory management | Slab allocator by size class; very predictable, small per-item overhead | jemalloc + compact encodings (listpack/intset) for small collections |
| Eviction | Segmented LRU per slab class | Configurable: LRU/LFU/random/TTL approximations, or noeviction |
| Persistence | None — pure cache | RDB snapshots and/or AOF append-only log |
| Replication / HA | None built in (clients or proxies like mcrouter replicate) | Async leader-follower replication, Sentinel failover, Redis Cluster |
| Sharding | Client-side, usually consistent hashing | Redis Cluster: 16,384 hash slots spread over nodes; or client/proxy sharding |
| Atomic server-side logic | incr/decr, cas | Rich atomic commands, MULTI/EXEC transactions, Lua scripts / functions |
| Extras | — | Pub/Sub, streams, keyspace notifications, TTL per key |
| Choose when | You need a huge, simple, multi-threaded look-aside cache for serialized objects | You need data structures (timelines, counters, sets, leaderboards), persistence or replication |
Facebook's public papers are built on Memcached; Instagram's early engineering posts describe using both Memcached (for Django object caching) and Redis (for data structures such as feeds and a famous memory-optimised mapping of media IDs to user IDs using Redis hashes). In a design interview, "Redis for the feed cache and counters" is the expected answer, matching our architecture diagram.
9. Redis data structures for Instagram
The real power of Redis is that the cache holds structures, so operations like "prepend a post to a timeline" or "add 1 to a counter" happen atomically on the server without read-modify-write races.
9.1 Timelines: lists or sorted sets
Fan-out workers push each new post ID into every follower's timeline. Keep only the newest few hundred IDs per user — older pages can be rebuilt from the database.
# Option A: LIST - newest first, O(1) push, trim to bound memory
LPUSH timeline:1001 3049215820194820101 # fan-out worker adds a new post id
LTRIM timeline:1001 0 799 # keep the newest 800 ids
LRANGE timeline:1001 0 19 # first page (20 ids)
# Option B: SORTED SET - members are post ids, score = created_at in ms
ZADD timeline:1001 1729673400123 "3049215820194820101"
ZREMRANGEBYRANK timeline:1001 0 -801 # drop everything but the newest 800
ZREVRANGEBYSCORE timeline:1001 +inf -inf LIMIT 0 20 # first page
ZREVRANGEBYSCORE timeline:1001 (1729673400123 -inf LIMIT 0 20 # next page: strictly older than cursor
Sorted sets cost more memory than lists but give idempotent inserts (re-adding the same ID is a no-op — handy when a fan-out job retries), ordered merge of late-arriving posts, and score-based cursors for pagination (see API Design).
9.2 Counters: INCR and hashes
HINCRBY post:3049215820194820101:counts likes 1 # atomic, no read-modify-write race
HINCRBY post:3049215820194820101:counts comments 1
HGETALL post:3049215820194820101:counts # -> likes 18342, comments 211
INCR ratelimit:user:1001:likes:202610231450 # per-minute counter for rate limiting
EXPIRE ratelimit:user:1001:likes:202610231450 120
A hash groups related small fields under one key, which is far more memory-efficient than many tiny top-level keys (small hashes use a compact listpack encoding). Counters in Redis are usually write-behind: periodically persisted to Cassandra, and rebuildable from the like records if lost.
9.3 Seen-state and membership: sets, bitmaps, HyperLogLog
# Which stories has viewer 1001 already seen from author 55? (expires with the stories)
SADD seen:1001:55 "story:777" "story:778"
SISMEMBER seen:1001:55 "story:779" # -> 0, so show the ring as unseen
EXPIRE seen:1001:55 86400 # 24 h, matching story lifetime
# Has viewer already been shown this post in feed? (dedupe across sessions)
SADD feed_seen:1001 "3049215820194820101"
# Approximate unique viewers of a Reel: 12 KB per key, ~0.81% standard error
PFADD reel:9001:viewers "user:1001"
PFCOUNT reel:9001:viewers
Sets give O(1) membership checks; bitmaps (SETBIT) are even denser when IDs are small integers; HyperLogLog counts distinct items approximately in fixed tiny memory. See Stories for the full seen-state design.
9.4 Other everyday uses
Sessions (Authentication), rate-limit counters (Rate Limiting), presence keys with short TTLs for Direct Messaging, and trending hashtags with ZINCRBY. Fan-out workers pipeline thousands of ZADDs per round trip; in Redis Cluster, hash tags (timeline:{1001}, seen:{1001}:55) keep one user's keys in the same slot for multi-key operations.
10. Operating a cache fleet: sizing, multi-region and warm-up
10.1 Cache sizing math
You size a cache by working set (memory) and by throughput (ops/s), then take the larger. All inputs below are assumptions for practice, not official figures (see Back-of-the-Envelope Estimation).
Feed timeline cache (Redis)
Assume 500M daily active users, keep newest 800 post ids per timeline
Memory per entry ≈ 40 B (id + score + encoding overhead; sorted sets cost more than lists)
Per user: 800 × 40 B ≈ 32 KB
All users: 500M × 32 KB ≈ 16 TB
× 2 for a replica (HA) ≈ 32 TB
Usable RAM per node ≈ 64 GB (128 GB box; leave headroom for fork/copy-on-write, fragmentation)
Nodes ≈ 32 TB / 64 GB ≈ 500 nodes
Lever: trim to 300 ids → ~12 TB with replicas → ~190 nodes
Throughput check
Feed opens: 500M × 10/day = 5B/day ≈ 58K reads/s average, ~175K/s at 3× peak
Fan-out writes: assume 100M posts/day × 200 followers avg = 20B ZADDs/day ≈ 230K/s, ~700K/s peak
Spread over 250 primary shards → ~3.5K ops/s per shard — tiny vs ~100K ops/s capacity
⇒ this cache is MEMORY-bound, not CPU-bound.
Object cache (post & user objects)
Posts from the last 7 days: 100M/day × 7 × 1 KB ≈ 700 GB
Active user objects: 500M × 0.5 KB ≈ 250 GB
⇒ ~1 TB of hot objects → a few dozen nodes, typically ≥ 95% hit ratio.
Rules of thumb: keep Redis memory below ~60–75% of the box (snapshots fork the process, and copy-on-write can double memory for hot pages); measure real per-key overhead with MEMORY USAGE key; and remember the 80/20 rule — you rarely need to cache everything, only the working set.
10.2 Cross-region invalidation
Instagram runs in several regions (see Multi-Region). Each region has its own cache cluster. A write in one region must invalidate caches in all regions — and the ordering matters. Facebook's Memcache paper describes the canonical design:
- Invalidate from the local replication stream. If region A broadcast deletes directly to region B's cache, a reader in B might miss, read B's replica DB (which hasn't received the write yet due to replication lag) and put the old value back. By having region B's daemon read B's own replica log, the delete in B happens only after B's DB already has the new data.
- Remote markers for read-your-writes: when a user in region B updates something, the write goes to the primary in A. The web server sets a marker key in B's cache ("k was recently changed remotely"), then deletes k. A reader in B that misses and finds the marker reads from the primary region instead of the stale local replica, until replication catches up.
- Alternative: publish invalidation events to a multi-region Kafka topic (or use CDC tools like Debezium) and have per-region consumers delete keys — simpler, but you must still think about replica lag vs invalidation order.
10.3 Warm-up (avoiding the cold-cache cliff)
An empty cache has a 0% hit ratio; if a new cluster (or a restarted one) takes full traffic immediately, the database receives 20–100× its normal load. Strategies:
- Read from a warm peer: Facebook's "cold cluster warm-up" lets clients of a new cluster fetch misses from an existing warm cluster instead of the database, turning days of warm-up into hours. To prevent a stale value from the warm cluster being added right after a delete, the cold cluster refuses adds for that key for a couple of seconds after a delete (a "hold-off").
- Pre-load the hot set from access logs or the previous cluster's key dump (top-N keys by frequency).
- Ramp traffic gradually (1% → 10% → 50% → 100%) while watching DB load and hit ratio, the same way you'd do a canary deploy.
- Persistence: Redis RDB/AOF lets a restarted node come back warm; replicas let a failover start warm.
- Consistent hashing when adding/removing nodes so only ~1/N of keys remap instead of nearly all (see Consistent Hashing).
11. How Instagram uses caching
Follow a home-feed request through the caches in our architecture diagram:
- The app renders its locally cached feed instantly, then asks for fresh data.
- The News Feed Service reads the user's precomputed timeline of post IDs from Redis — written ahead of time by the Feed Fan-out Workers consuming "post created" events from Kafka — and merges in celebrities' cached recent-post lists (pull model).
- It hydrates the IDs with a multi-get of post objects, author objects, counter hashes and "did I like this?" flags; misses fall back to Cassandra (posts) and sharded PostgreSQL (users), then fill the cache.
- The response contains CDN URLs; media bytes are cached at the CDN, never in Redis.
- Writes (like, edit, follow) update the source of truth, then delete or atomically update related keys, with Kafka invalidation events as a backstop.
Per-data-type choices:
| Data | Cache structure | Pattern | Freshness |
|---|---|---|---|
| Home timeline | Redis list / sorted set of post IDs | Push (write-through into derived data) + rebuild on miss | Seconds; small delays acceptable |
| Post & user objects | Serialized blobs (Memcached or Redis strings) | Cache-aside + delete-on-write + CDC backstop | Milliseconds after write |
| Like/comment counts | Redis hash with HINCRBY | Write-behind to Cassandra, rebuildable | Approximate is fine |
| "Did I like this?" | Set / bitmap per post or per user, or cached per (user, post) | Cache-aside, updated on like | Must reflect your own action immediately (read-your-writes) |
| Story seen-state | Redis sets with 24h TTL | Write-through on view | Seconds |
| Sessions / auth tokens | Redis strings with TTL | Write-through | Revocation must be fast |
| Photos / videos | CDN + device cache | Pull CDN, immutable URLs | Immutable |
12. Trade-offs & common pitfalls
✅ Benefits
- Order-of-magnitude lower read latency
- Huge reduction in database load and cost
- Absorbs read spikes and protects hot DB shards
- Rich structures (Redis) make features like timelines and counters simple and fast
- Can keep serving (stale) data during DB incidents
⚠️ Costs
- Staleness and consistency races
- Another distributed system to run, monitor and size
- RAM is expensive per GB
- Hidden dependency: the DB is now sized for the hit rate, so a cache failure can cascade into a DB outage
- Invalidation logic spread across code paths
Common pitfalls
- Updating instead of deleting on writes → out-of-order overwrites leave old data.
- No TTL on keys → any missed invalidation is stale forever.
- Same TTL for millions of keys → avalanche. Add jitter.
- Caching before the DB commit (or deleting before commit) → readers repopulate with uncommitted/rolled-back or old data. Invalidate after commit.
- Huge values or unbounded collections block Redis's single thread and blow up memory — cache IDs and small objects, always trim lists, and use
SCANinstead ofKEYS *. Media belongs in object storage + CDN. - Treating the cache as the source of truth by accident — then eviction or a node loss is data loss.
- Ignoring cold start → a deploy that changes key formats drops hit ratio to zero; version keys and roll out gradually.
13. Real-world technologies
| Technology | What it is | Use it when |
|---|---|---|
| Redis / Valkey | In-memory data-structure server with replication, persistence, Cluster | Timelines, counters, leaderboards, sessions, rate limits, queues — the default distributed cache |
| Memcached | Multi-threaded, simple LRU key-value cache | Massive look-aside caches of serialized objects; maximum simplicity and multi-core efficiency |
| mcrouter (Meta), twemproxy, Envoy Redis proxy | Proxies that route, shard, replicate and fail over cache traffic | Hundreds of cache nodes, many clients; consistent hashing and replication without client changes |
| Amazon ElastiCache / MemoryDB, Azure Cache for Redis, GCP Memorystore | Managed Redis/Memcached (MemoryDB adds durable multi-AZ log) | Don't want to run the fleet yourself |
Caffeine (Java), Guava cache, functools.lru_cache, Ristretto (Go) | In-process (L1) caches | Hot keys, config, per-server memoization with sub-µs latency |
| Varnish, NGINX proxy cache | HTTP reverse-proxy caches | Caching whole HTTP responses at the gateway |
| CDNs (CloudFront, Akamai, Fastly, Cloudflare) | Edge HTTP caches | Static and media content close to users — see CDN |
14. Interview corner
Walk me through how you'd cache user profiles for Instagram.
Cache-aside in a distributed cache keyed user:{id}:v{schema}, storing a compact serialized object (~500 B) with a jittered TTL of about an hour. Reads: GET → on miss read the sharded PostgreSQL replica, then SET. Writes (profile edit): commit to the primary, then DELETE the key; a CDC daemon tailing the DB log also deletes as a backstop. Use leases or versioned compare-and-set to prevent the stale-set race. For celebrity profiles, an in-process L1 with a 2–5 s TTL absorbs hot-key traffic. For the editor's own view, read-your-writes: either bypass the cache right after editing or update the client optimistically. Expected hit ratio 95%+, so the DB sees a few percent of profile reads.
Why delete the cache entry on write instead of updating it?
Because writes to the DB and to the cache are not atomic together. Two concurrent updaters can commit to the DB in one order and reach the cache in the opposite order, leaving the older value cached indefinitely. Deletes are idempotent and order-independent; the next read repopulates from the authoritative DB. Delete also avoids computing values that may never be read. The remaining race (a slow reader setting an old value after the delete) is handled by leases/CAS and TTLs.
A celebrity posts; millions open the post within a minute. What happens to your caches and how do you protect the DB?
The post object and its counters become hot keys. (1) Request coalescing/leases ensure only one DB fetch per key per cache node when it's first missed. (2) Replicate the hot keys across several cache nodes (key suffixes) or serve them from an in-process L1 with a few-second TTL. (3) Counters are incremented atomically in Redis with HINCRBY (sharded counter if a single key can't keep up) and persisted write-behind. (4) Media is served by the CDN with request collapsing at the edge. (5) Feed fan-out for this author uses pull-on-read, so followers' feed services fetch the celebrity's cached recent-posts list rather than 600M timeline writes.
What's a cache stampede and how do you prevent it?
Many concurrent requests miss the same key (usually right after expiry or invalidation) and all hit the DB to recompute it. Prevent it with single-flight request coalescing inside each server, a distributed lock or lease so only one client recomputes, stale-while-revalidate (serve the old value while one request refreshes), probabilistic early expiration (XFetch) so a single request refreshes slightly before the TTL, and jittered TTLs so related keys don't expire together.
Redis or Memcached for Instagram's feed cache? Justify.
Redis. The feed cache needs data structures with atomic server-side operations: lists/sorted sets for timelines (push, trim, range-by-score pagination), hashes with HINCRBY for counters, sets with TTL for seen-state, plus replication for HA and optional persistence for fast warm restart. Memcached only stores opaque blobs, so every timeline update would be a read-modify-write of a big blob with races. Memcached remains a great choice for a large look-aside cache of serialized post/user objects where its multi-threading and memory efficiency shine.
How do you keep caches in multiple regions consistent?
Accept eventual consistency but order invalidations correctly: each region's caches are invalidated by a daemon tailing that region's own DB replication log (or a regional consumer of change events), so the delete happens only after the local replica has the new value — otherwise a miss could repopulate from a lagging replica. For read-your-writes after a user's own update in a non-primary region, set a remote marker so subsequent misses read from the primary region until replication catches up. Keep TTLs as a final safety net.
15. Check your understanding
Your cache hit ratio drops from 98% to 94%. By what factor does database read load change?
DB load goes from 2% to 6% of reads — it triples. Small hit-ratio changes have outsized effects on the backend; that's why hit ratio is a key alert.
Why does an O(1) LRU need a doubly linked list and a hash map?
The hash map gives O(1) lookup of a key's node. Moving that node to the front requires unlinking it from the middle, which needs its predecessor — available in O(1) only with a prev pointer (doubly linked). The tail gives the eviction victim in O(1).
A scraper requests random non-existent usernames at 50K requests/s, and your DB CPU spikes even though the cache hit ratio for real users is 99%. What's happening and what do you do?
Cache penetration: misses for keys that don't exist are never cached, so every request reaches the DB. Fix with negative caching (store "not found" for ~60 s), a Bloom filter of existing usernames in front of the DB, input validation and rate limiting the client.
You warm 10M keys at midnight with TTL = 24 h. What problem will you see and how do you fix it?
A cache avalanche at midnight the next day when all 10M expire together. Add random jitter to TTLs (e.g. 24 h ± 2 h), and protect the DB with coalescing and load shedding.
Why is it dangerous to use a 64-bit post ID as a Redis sorted-set score?
Scores are IEEE-754 doubles with a 53-bit mantissa; integers above 253 can't be represented exactly, so distinct IDs may get the same score and ordering becomes wrong. Use a timestamp (fits in 53 bits) as the score and the ID as the member, or use a list.
With 10 bits per key and 7 hash functions, a Bloom filter's false-positive rate is about 0.8%. How much memory does it need for 1 billion post IDs, and what happens on a false positive?
10 bits × 10⁹ = 10¹⁰ bits ≈ 1.25 GB. On a false positive the filter says "maybe present", so the request proceeds to the cache/DB and finds nothing — a wasted lookup, never a wrong answer. Bloom filters never give false negatives.
Which write pattern would you choose for (a) Reel view counts, (b) a user's password hash, (c) old comments rarely re-read?
(a) Write-behind — very high write rate, approximate counts are fine, batching saves DB writes. (b) Write to the DB (source of truth) and delete any cached copy — correctness matters more than speed; arguably don't cache it at all. (c) Write-around — don't pollute the cache with data unlikely to be read.
- Caching exploits the huge latency gap between RAM and disk/network plus skewed popularity; hit ratio is the number that matters.
- Default to cache-aside with delete-after-commit, a TTL on every key, and CDC-driven invalidation as a backstop.
- LRU (hash map + doubly linked list) is the default eviction policy; LFU resists scans; Redis approximates both by sampling.
- Guard against stampedes (coalescing, leases, early refresh), hot keys (replicas, L1), penetration (negative caching, Bloom filters) and avalanches (jitter, HA, warm-up).
- Use Redis data structures for Instagram's timelines, counters and seen-state; size the cluster by working-set memory first, then check ops/s.
- Across regions, invalidate from the local replication stream and warm new clusters gradually.
Related lessons
- News Feed: Fan-out & Ranking — the main consumer of the Redis timeline cache
- CDN — the same caching ideas applied at the network edge for media
- Consistent Hashing — how keys are spread over cache nodes with minimal remapping
- Likes, Comments & Counters — hot keys and write-behind counters in depth
- Stories & Ephemeral Data — TTLs and seen-state sets
- Replication — replica lag, which drives cache consistency problems
- CAP & Consistency Models — why caches are eventually consistent and what read-your-writes means
- Message Queues & Kafka — event-driven invalidation and fan-out workers
- Back-of-the-Envelope Estimation — practise cache sizing math