TL;DR

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:

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:

OperationApprox. latencyRelative to a RAM readWhere you meet it
CPU L1 cache reference~1 ns0.01×Hot loops inside a process
Main memory (RAM) reference~100 ns1×In-process cache (a hash map in the service)
Read 1 MB sequentially from RAM~10–250 µs100–2,500×Copying a cached value
Random 4 KB read from NVMe SSD~20–150 µs200–1,500×DB page not in the buffer pool
Round trip inside one data center~0.2–0.5 ms2,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 ms10,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:

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.

🧠 Analogy You are writing an essay. The books you are using right now are open on your desk (CPU cache / in-process cache). A few dozen more are on the bookshelf behind you (distributed cache — a short walk). The rest are in the university library across campus (the database), and rarely used volumes are in an off-site warehouse (archival storage). You keep the books you use most within arm's reach, and when the desk is full you put back the one you haven't touched for the longest time (LRU eviction). The risk: if a new edition of a book is published, your desk copy is now out of date (stale cache) — someone has to tell you to fetch the new one (invalidation).

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).

📱 Client cache0 ms (on device) CDN edge~10–30 ms API Gateway~1 ms Service (L1)~100 ns Redis/Memcached~0.2–1 ms DB buffer pool~1–10 ms query images, feed JSON,drafts, profile photos, videosegments auth tokens,public responses config, hot keys,celebrity profiles timelines, counters,objects, sessions hot pages oftables & indexes closer to the user: faster, cheaper per read, but harder to invalidate and less consistent closer to the data: easier to keep correct
The cache hierarchy for one Instagram request. Each layer absorbs some reads so the next layer sees less traffic.
LayerWhat 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 ClientsTTL, HTTP Cache-Control/ETag, pull-to-refresh; you can't purge a phone
CDNImmutable media renditions and video segments — see CDNVersioned URLs, long TTL, purge API
API gateway / reverse proxyPublic, non-personalised responses; token-validation results — see API GatewayShort TTL (seconds)
In-process (L1) cacheFeature 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, sessionsCache-aside with delete-on-write, TTLs, invalidation events
DB buffer pool / page cacheHot data and index pages of PostgreSQL / CassandraManaged 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.

Cache-aside (lazy loading) Read-through / write-through Applicationowns the logic CacheRedis / Memcached Databasesource of truth Applicationtalks to cache only Cache + loaderfetches/writes DB itself Database ① GET key② miss → ⑤ SET key ③ SELECT row④ row returned get / put on miss / on write App does the miss handling and invalidation Cache library hides the DB from the app
Left: in cache-aside, the application explicitly reads the cache, falls back to the DB and populates the cache. Right: in read/write-through, the cache layer itself loads from and writes to the DB.

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

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.

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

PatternRead pathWrite pathConsistencyWrite latencyBest for (Instagram example)
Cache-asideApp reads cache → DB on miss → app fillsApp writes DB, deletes keyEventual; small stale windowDB onlyUser profiles, post metadata — general purpose
Read-throughCache loads from DB on miss(paired with any write strategy)Same as cache-aside—Libraries/local caches with loaders; config
Write-throughCache (usually hit)Cache and DB synchronouslyStrong-ish (if both writes succeed)Higher (2 writes)Data read right after it's written: your own new post in your profile grid
Write-behindCacheCache now, DB later in batchesWeak; risk of lossLowestView counts, "last active" timestamps, high-rate counters
Write-aroundCache-asideDB only (+ delete key)EventualDB onlyWrite-heavy, rarely re-read data: old comments, logs
💡 A note on "push" caches Instagram's feed timelines are a special case: fan-out workers push new post IDs into followers' timeline lists in Redis as soon as a post is created (see News Feed). That's effectively write-through into a derived data structure: the cache isn't a copy of one DB row but a precomputed answer to "what should user X see?". If it's lost, it can be rebuilt from the posts store and the social graph.

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:

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.

Hash map "post:7" → node "user:3" → node "feed:9" → node HEAD post:7 user:3 feed:9 TAIL most recently used evict from here (LRU) GET user:3 → unlink node, move it right after HEAD
O(1) LRU: the hash map finds a node instantly; the doubly linked list keeps recency order. Hits move a node to the front; eviction pops the tail.
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

PolicyEvictsStrengthWeaknessRedis setting
LRULeast recently accessedGreat default, adapts quicklyScan pollution; pointer/lock overheadallkeys-lru, volatile-lru
LFULeast frequently accessedKeeps stable hot set; scan-resistantSlow to adapt without decayallkeys-lfu, volatile-lfu
FIFOOldest insertedVery cheap, lock-friendlyIgnores popularity— (Memcached/other caches)
RandomAny keyZero overheadMay evict a hot keyallkeys-random
TTLKeys nearest expiryBounds stalenessChoosing the TTL is a guessvolatile-ttl (+ per-key EXPIRE)
No evictionNothing — writes fail when fullNever silently loses dataErrors under memory pressurenoeviction (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:

StrategyHow it worksStaleness boundCost / risk
TTL expiry onlySet a TTL; accept staleness until it expires≤ TTLSimplest; 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~millisecondsWriter must know every key derived from the row; a crashed writer leaves stale data → keep a TTL as a safety net.
Update on writeWriter overwrites the cached value~millisecondsConcurrent writers can leave the older value in the cache (section 6.2). Prefer delete.
Event / CDC-drivenA daemon tails the DB's commit log (or a Kafka topic of change events) and deletes keysReplication lag (ms–s)Decoupled and reliable (never misses a committed write); one more pipeline to run. See Message Queues.
Versioned keysInclude a version in the key (user:42:v17); bumping the version makes old entries unreachable, and they age out by LRU/TTLInstant (once readers see the new version)Readers need the current version number (itself cached); wastes memory until old keys evict.
Write-throughThe cache is updated as part of the write pathNone (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.

⚠️ Know your derived keys One row often feeds many cache keys. Changing a username affects 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:

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:

  1. On a cache miss, memcached hands the client a lease token — a 64-bit number bound to that key.
  2. The client reads the DB and calls set(key, value, lease_token).
  3. 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.
  4. 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.
  5. 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:

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:

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:

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:

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.

MemcachedRedis (and its open-source fork Valkey)
Data modelOpaque byte strings (default max item 1 MB)Strings, lists, hashes, sets, sorted sets, bitmaps, HyperLogLog, streams, geo, JSON (module)
ThreadingMulti-threaded — scales up on one big boxCommands run on a single main thread (atomic, no locks); I/O threads help networking since 6.0 → scale out with more shards
Memory managementSlab allocator by size class; very predictable, small per-item overheadjemalloc + compact encodings (listpack/intset) for small collections
EvictionSegmented LRU per slab classConfigurable: LRU/LFU/random/TTL approximations, or noeviction
PersistenceNone — pure cacheRDB snapshots and/or AOF append-only log
Replication / HANone built in (clients or proxies like mcrouter replicate)Async leader-follower replication, Sentinel failover, Redis Cluster
ShardingClient-side, usually consistent hashingRedis Cluster: 16,384 hash slots spread over nodes; or client/proxy sharding
Atomic server-side logicincr/decr, casRich atomic commands, MULTI/EXEC transactions, Lua scripts / functions
Extras—Pub/Sub, streams, keyspace notifications, TTL per key
Choose whenYou need a huge, simple, multi-threaded look-aside cache for serialized objectsYou 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).

⚠️ Gotcha: sorted-set scores are doubles Redis scores are 64-bit floating-point numbers, which represent integers exactly only up to 253. Instagram-style 64-bit IDs (see ID Generation) are larger than that, so using the raw ID as the score silently loses precision and can mis-order or collide posts. Use the millisecond timestamp (41 bits — fits exactly) as the score and the full ID as the member string, or use a list.

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:

Region A (primary for this shard) Region B (replica) Writer service Primary DB Invalidation daemontails commit log Cache cluster A Replica DB Invalidation daemontails replica log Cache cluster B Readers in B write DELETE async repl. DELETE read invalidate only after the commit invalidate only after the replica applied it
Each region's cache is invalidated by a daemon tailing that region's database log, so a cache is never refilled from a replica that hasn't seen the write yet.

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:

11. How Instagram uses caching

Follow a home-feed request through the caches in our architecture diagram:

📱 Applocal cache of last feed
→GET /feed
API Gatewayauth, rate limit
→
News Feed ServiceL1 for hot keys
→ZREVRANGE
Feed Cache (Redis)timeline ids, counts
→on miss
Cassandra / PostgreSQLsource of truth
  1. The app renders its locally cached feed instantly, then asks for fresh data.
  2. 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).
  3. 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.
  4. The response contains CDN URLs; media bytes are cached at the CDN, never in Redis.
  5. Writes (like, edit, follow) update the source of truth, then delete or atomically update related keys, with Kafka invalidation events as a backstop.
📸 In Instagram Instagram's early public engineering posts describe a Django stack with Memcached for object caching and Redis for data structures such as the main feed and activity feed — plus a well-known write-up about squeezing hundreds of millions of media-ID→user-ID mappings into Redis by bucketing them into small hashes to exploit Redis's compact encoding. Today Instagram runs on Meta's infrastructure, whose caching is described publicly in the Scaling Memcache at Facebook and TAO papers (TAO is a graph-aware, write-through cache in front of MySQL for the social graph — see Social Graph). For our design: assume a ~95–99% hit ratio on hot objects, timelines capped at a few hundred IDs per user, TTLs on everything, and fan-out for celebrities replaced by pull + caching of their recent posts.

Per-data-type choices:

DataCache structurePatternFreshness
Home timelineRedis list / sorted set of post IDsPush (write-through into derived data) + rebuild on missSeconds; small delays acceptable
Post & user objectsSerialized blobs (Memcached or Redis strings)Cache-aside + delete-on-write + CDC backstopMilliseconds after write
Like/comment countsRedis hash with HINCRBYWrite-behind to Cassandra, rebuildableApproximate is fine
"Did I like this?"Set / bitmap per post or per user, or cached per (user, post)Cache-aside, updated on likeMust reflect your own action immediately (read-your-writes)
Story seen-stateRedis sets with 24h TTLWrite-through on viewSeconds
Sessions / auth tokensRedis strings with TTLWrite-throughRevocation must be fast
Photos / videosCDN + device cachePull CDN, immutable URLsImmutable

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

13. Real-world technologies

TechnologyWhat it isUse it when
Redis / ValkeyIn-memory data-structure server with replication, persistence, ClusterTimelines, counters, leaderboards, sessions, rate limits, queues — the default distributed cache
MemcachedMulti-threaded, simple LRU key-value cacheMassive look-aside caches of serialized objects; maximum simplicity and multi-core efficiency
mcrouter (Meta), twemproxy, Envoy Redis proxyProxies that route, shard, replicate and fail over cache trafficHundreds of cache nodes, many clients; consistent hashing and replication without client changes
Amazon ElastiCache / MemoryDB, Azure Cache for Redis, GCP MemorystoreManaged 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) cachesHot keys, config, per-server memoization with sub-µs latency
Varnish, NGINX proxy cacheHTTP reverse-proxy cachesCaching whole HTTP responses at the gateway
CDNs (CloudFront, Akamai, Fastly, Cloudflare)Edge HTTP cachesStatic 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.

🎯 Key takeaways

Related lessons