Search Tech Journey

Find topics, journeys and posts

6-month learning plan63 / 130
back to blog
systemsintermediate 55m read

S063 · Caching — Cache-Aside, Write-Through, TTLs, Invalidation

The oldest performance trick in the book, done right. Cache-aside vs write-through vs write-back, TTLs, invalidation patterns, and the two hardest problems in computer science.

⚙️SystemsM07 · Systems & Infrastructure· Session 063 of 130 90 min

🎯 Implement cache-aside against Redis in Python, defend it from stampedes and stale writes, and explain when NOT to cache.

Why this session exists

Caching is the single highest-leverage performance technique you own. A well-placed cache turns a 200 ms query into a 2 ms lookup — a 100× speedup with 20 lines of code. It's also the source of half the "why is production returning yesterday's data?" tickets you'll ever see. This session is both halves: how to reach for a cache, and how not to shoot your foot off.

You will be able to
  • Choose between cache-aside, write-through, and write-back given a workload.
  • Implement cache-aside with proper TTL, negative-cache, and stampede protection in Python + Redis.
  • Explain cache invalidation patterns (TTL-only, event-driven, version-tag) and when each breaks.
  • Diagnose the four canonical cache failure modes: stale reads, thundering herd, negative-cache poisoning, and cold-cache meltdown.

Prerequisites

  • S015 · HTTP basics — request/response, headers, status codes.
  • S062 · Load balancers — LBs often front the cache layer.
  • S055 · Redis basics — you know GET, SET, EXPIRE.


(a) Intuition · 5 min

A librarian and a photocopy pile
🌍 Real world

Every day 200 students ask for the same three chapters of the same textbook. The librarian used to walk to the stacks 600 times a day.

Now she keeps a stack of pre-made photocopies of those chapters on the desk. First student of the day still triggers a walk to the stacks (+ a copy for the pile). Every subsequent student for the rest of the day gets served in 3 seconds instead of 3 minutes. At closing time she throws the photocopies away — tomorrow the textbook might be a new edition.

💻 Code world

Same idea. Your database is the stacks. Your cache (Redis, Memcached, in-process LRU) is the desk. The first read of user:42 misses the cache, hits Postgres, and fills the cache. The next 10,000 reads for the next 10 minutes are 1 ms Redis GETs.

The "throw the photocopies away at closing time" is the TTL — the guarantee that stale data self-destructs after N seconds without you writing any invalidation code. It's the cheapest and most-used correctness knob in caching.

The two hard problems in computer science

The three primary patterns

Cache-aside · Write-through · Write-back
  • Cache-aside (lazy) — App reads cache, on miss it reads DB and populates cache. On write, app writes DB and invalidates (or updates) cache. Default choice; used by 90% of systems.
  • Write-through — App writes to the cache; the cache synchronously writes to the DB. Reads are always served from the cache. Strong consistency, higher write latency.
  • Write-back (write-behind) — App writes to the cache; the cache asynchronously flushes to the DB later. Fastest writes, but if the cache dies before flush you lose data. Used carefully (metrics buffers, click counters).

A quick history so the vocabulary makes sense

  1. 1965
    IBM System/360 Model 85
    First commercial CPU cache. The idea ‘keep hot data closer’ predates everything else here by 40 years.
  2. 2003
    Memcached · Brad Fitzpatrick / LiveJournal
    Simple in-memory key-value cache. Livejournal, Facebook, Twitter, YouTube all ran on it.
  3. 2009
    Redis 1.0 · Salvatore Sanfilippo
    Memcached + real data structures + persistence + pub/sub. Becomes the default cache and message broker.
  4. 2013
    ‘Scaling Memcache at Facebook’ paper
    The reference architecture. Leases (stampede protection), gutter pool (failure isolation), regional replication.
  5. 2020
    Redis 6 · client-side caching (RESP3)
    Redis tells clients when their local cache is invalidated. Two-tier caching becomes trivial.

(b) Visual walkthrough · 15 min

Cache-aside — the pattern you'll use 90% of the time

The three write patterns, side-by-side

Cache-aside

App is boss · lazy fill

  • Read: cache first, DB on miss, backfill
  • Write: write DB → invalidate (or update) cache
  • Simple, resilient to cache outage (just slower)
  • Risk: read-after-write race → stale cache
  • Default choice for user profiles, product catalogues
Write-through

Cache is boss · synchronous fill

  • Every write hits cache first; cache writes DB
  • Reads always cache-first (never miss for written data)
  • Strong read-your-writes consistency
  • Write latency = cache + DB. Cache outage = no writes
  • Good fit: session store, shopping cart
Write-back

Cache is buffer · async flush

  • Write to cache; batched flush to DB every N seconds
  • Lowest write latency, highest throughput
  • Cache crash = data loss between flushes
  • Needs durable cache (Redis AOF) or accept loss
  • Good fit: metrics, view counters, likes

Cache invalidation strategies — from lazy to strict

Ordered from ‘easiest to reason about’ to ‘strongest guarantees’

TTL-only
Keys expire after N seconds. No explicit invalidation. Simplest to reason about; every entry is at most N seconds stale.
lazy
Write-invalidate
On write, DELETE the cache key. Next read misses and backfills. Simpler than write-update because you don't need to reconstruct the cached value on the write path.
common
Write-update
On write, overwrite the cache key with the new value in the same transaction. Zero staleness window IF the transaction succeeds. Beware partial failures.
strict
Version tag / cache-key versioning
Include a version in the cache key (`user:42:v37`). To ‘invalidate’ you bump the version — no delete required. Safe against races; old key naturally evicts.
elegant
Pub/Sub or CDC-driven
Cache subscribes to Postgres logical replication or Kafka. Every DB write emits an event; caches listen and invalidate. Strongest consistency in a distributed setup.
modern

Where a cache lives — pick the right tier


Common misconception
✗ What most people think

"Caching is a performance optimisation. Add a cache, everything gets faster, and the worst case is a cache miss."

✓ What is actually true

A cache is a second copy of your data with its own consistency model, its own failure modes, and its own capacity limits. Adding one converts a latency problem into a correctness problem plus an availability dependency. The worst case is not a miss — it is a stampede on expiry, a cold cache after a restart taking down the database, or serving a stale value that causes a wrong decision.

Why the myth is so sticky

The myth is sticky because the happy path is genuinely dramatic: a millisecond instead of a hundred, with three lines of code. Nothing in that experience hints at the failure modes, which only appear under conditions you cannot easily reproduce — a cache node failing under peak traffic, a popular key expiring while ten thousand requests are in flight, an invalidation that fires before the write commits. Every one of those requires load plus timing, so they arrive in production first.

Prove it to yourself

Ask the three questions that decide whether a cache is safe, before writing any code:

1. Can the system survive the cache being entirely empty right now?
   (restart, eviction, failover - if the DB cannot take 100% of
    traffic, the cache is not a cache, it is a load-bearing tier)

2. What is the maximum staleness a consumer can tolerate?
   (this number IS your TTL; if nobody can state it, do not cache)

3. What happens when 10,000 requests miss the same key at once?
   (without single-flight, they all hit the database simultaneously)
From first principles
Start with the question

Why is cache invalidation famously hard? It sounds mechanical: the data changed, so delete the cache entry.

  1. 1
    A cache entry is a claim that some derived value was correct at some past instant.
    forced by · it was computed from a snapshot of the source, then stored — the source moved on independently
  2. 2
    The source of truth can change at any moment, and it has no inherent knowledge of which caches hold which derived values.
    forced by · caches are added by consumers; the database was not designed to track its readers
  3. 3
    Therefore invalidation requires someone to know the mapping from "this data changed" to "these cache keys are now wrong", and that mapping is application knowledge that lives only in a developer's head.
    forced by · derived values may aggregate many rows, so the dependency graph is implicit in the code that computed them
  4. 4
    That mapping is never complete. A new feature adds a cached view, and the invalidation code for the write path is in a different file written by a different person months earlier.
    forced by · the write path and the caching decision are separated in both code and time, so nothing forces them to be updated together
  5. 5
    And even with a perfect mapping, invalidation and the write are two operations that cannot be made atomic across two systems, so there is always a window — and a crash between them leaves permanent staleness.
    forced by · there is no distributed transaction between your database and your cache, and adding one would cost more than the cache saves
⇒ Therefore

Therefore explicit invalidation cannot be made complete or atomic in a real codebase. This is a structural property, not a discipline failure.

And note what this predicts: TTL-based expiry is not the lazy option — it is the only mechanism that bounds staleness without requiring a complete dependency map, because it converges regardless of what invalidation you forgot. That is why mature systems use both: TTL as the correctness floor that guarantees convergence, and explicit invalidation as a latency optimisation on the paths you did remember. It also predicts that the right question is never "how do I invalidate everything correctly" but "how stale can this value be", which is answerable and testable.

Mental modelEvery cache is a bet on locality

A cache pays memory to avoid work, and the bet only pays off if the same items are requested repeatedly within the TTL. Hit rate is the odds on that bet, and it is entirely determined by your access distribution — no amount of cache size fixes a workload with no repetition.

Picture it as a layer with a hole in it: everything that misses falls through to the origin. Your origin must be sized for the traffic that comes through that hole at its widest, which is when the cache is empty.

  • Cache-aside (read, miss, fetch, populate) is the default and puts you in control. Write-through keeps the cache warm at the cost of write latency; write-behind is fast and can lose data on failure. Pick deliberately — the default is a choice too.
  • Always add jitter to TTLs. Identical TTLs on items populated together expire together, producing a synchronised stampede at a predictable moment. Randomising by ±10% costs nothing and removes an entire failure class.
  • Use single-flight (request coalescing) on every expensive key: the first miss fetches, concurrent misses wait for that result. Without it, cache expiry on a hot key delivers your full request rate to the database in one instant.
  • Hit rate alone is a misleading metric. Measure origin load and p99 latency as well — a 95% hit rate on a system that cannot survive the remaining 5% is a system that is one eviction away from an outage.
🔔 Fires when you see

Fire this model when you see: a database spike at a round-numbered interval · a service that cannot restart under load · users reporting they saw an old value after saving · latency that is fine at p50 and terrible at p99 · a Redis outage taking down a service that "only used it for caching".

The tradeoff

A popular cache entry has just expired and requests are arriving. Do you block them while recomputing, serve the stale value, or recompute in advance?

Block and recompute (with single-flight)
+ you gain every response is fresh within the TTL, the semantics are simple, and exactly one recomputation happens no matter how many requests are waiting. Nothing stale is ever served.
− you pay all waiting requests absorb the full recomputation latency, so p99 shows a spike at every expiry of a hot key. If recomputation is slow, waiting requests can exhaust connection or thread pools.
pick when when staleness is genuinely unacceptable — prices, permissions, balances — and recomputation is fast enough to hide inside your latency budget
Serve stale while revalidating
+ you gain latency stays flat because no user ever waits for a recomputation; a background refresh replaces the value. This is the strongest option for tail latency and it also shields the origin from expiry bursts.
− you pay consumers receive data known to be out of date, and if the origin is down the staleness window extends indefinitely — which is sometimes a feature and sometimes a silent correctness failure nobody notices for hours.
pick when read-heavy content where a value seconds or minutes old is harmless: feeds, catalogues, dashboards, aggregates
Proactive refresh before expiry
+ you gain hot keys are refreshed just before they expire, so users never encounter a miss on the items that matter and the origin sees a smooth, predictable load rather than bursts.
− you pay you spend work refreshing entries that may never be read again, and you need to track access patterns to know which keys are hot — extra machinery and extra origin load in exchange for tail latency.
pick when a small, identifiable set of very hot keys where a miss is expensive and the item is definitely going to be requested again
What a senior engineer actually does

Stale-while-revalidate should be your default for read paths, because it is the only option that decouples user-visible latency from origin latency entirely. State the acceptable staleness explicitly and make it visible in the code — a named constant with a comment, not a magic number.

Reserve blocking recomputation for values where being wrong is worse than being slow, and be honest about which those are: most teams classify far more data as "must be fresh" than the business actually requires. The question to ask a stakeholder is not "do you want fresh data" — everyone says yes — but "is a value from thirty seconds ago acceptable if it means the page always loads instantly". That reframing usually settles it.


(c) Hands-on · 25 min

Cache-aside against Redis, in Python, with the three defences you'll always want: TTL, negative cache, and stampede protection (via a short lock).

"""s063_cache.py — production-shaped cache-aside in ~100 lines.
 
Run:  docker run -d --name s063-redis -p 6379:6379 redis:7-alpine
      pip install redis
      python s063_cache.py
"""
import json
import random
import time
from contextlib import contextmanager
 
import redis
 
# ---- 1. wire up ----
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
 
# ---- 2. the "slow" data source we're caching ----
_call_count = {"db": 0}
 
def slow_db_lookup(user_id: int) -> dict | None:
    """Simulates a 200 ms DB query. Returns None for user_id > 1000 (not found)."""
    _call_count["db"] += 1
    time.sleep(0.2)
    if user_id > 1000:
        return None
    return {"id": user_id, "name": f"user-{user_id}", "email": f"u{user_id}@ex.com"}
 
# ---- 3. constants ----
TTL_SECONDS         = 300         # 5 min for real data
NEGATIVE_TTL        = 30          # 30 s for "does not exist" — shorter, so a newly-created row shows up quickly
LOCK_TTL            = 5           # stampede lock lives 5 s max
NEGATIVE_SENTINEL   = "__MISS__"  # marker for "we looked, it wasn't there"
 
# ---- 4. the stampede lock ----
@contextmanager
def redis_lock(key: str, ttl: int = LOCK_TTL):
    """SET NX EX — classic Redis lock. If we don't get it, we block briefly."""
    token = f"{time.time()}-{random.random()}"
    got = r.set(key, token, nx=True, ex=ttl)
    try:
        yield bool(got)
    finally:
        # Only delete if we still own it (avoid stealing another holder's lock)
        if got and r.get(key) == token:
            r.delete(key)
 
# ---- 5. the cache-aside read ----
def get_user(user_id: int) -> dict | None:
    cache_key = f"user:{user_id}"
    cached = r.get(cache_key)
 
    if cached == NEGATIVE_SENTINEL:
        return None                        # negative-cache hit
    if cached is not None:
        return json.loads(cached)          # positive hit
 
    # ---- miss path with stampede protection ----
    lock_key = f"lock:{cache_key}"
    with redis_lock(lock_key) as got_lock:
        if not got_lock:
            # Someone else is filling. Wait briefly, then re-check the cache.
            time.sleep(0.05)
            cached = r.get(cache_key)
            if cached == NEGATIVE_SENTINEL:
                return None
            if cached is not None:
                return json.loads(cached)
            # Fall through — worst case we do the DB call ourselves
 
        value = slow_db_lookup(user_id)
        if value is None:
            r.set(cache_key, NEGATIVE_SENTINEL, ex=NEGATIVE_TTL)
            return None
        r.set(cache_key, json.dumps(value), ex=TTL_SECONDS)
        return value
 
# ---- 6. the write path (invalidate on write) ----
def update_user(user_id: int, patch: dict) -> None:
    # In real life this would write to Postgres. We just invalidate.
    print(f"[write] updating user {user_id} in DB (imagined)")
    r.delete(f"user:{user_id}")            # simplest safe invalidation
 
# ---- 7. demo ----
if __name__ == "__main__":
    r.flushdb()
 
    print("--- cold cache: first call hits DB ---")
    t0 = time.perf_counter()
    print(get_user(42))
    print(f"took {(time.perf_counter()-t0)*1000:.1f} ms  db_calls={_call_count['db']}")
 
    print("\n--- warm cache: 100 calls, ~1 ms each ---")
    t0 = time.perf_counter()
    for _ in range(100):
        get_user(42)
    print(f"took {(time.perf_counter()-t0)*1000:.1f} ms total, db_calls={_call_count['db']}")
 
    print("\n--- write invalidates the cache ---")
    update_user(42, {"name": "renamed"})
    t0 = time.perf_counter()
    print(get_user(42))
    print(f"took {(time.perf_counter()-t0)*1000:.1f} ms  db_calls={_call_count['db']}")
 
    print("\n--- negative cache: not-found result is remembered ---")
    print(get_user(9999)); print(get_user(9999)); print(get_user(9999))
    print(f"db_calls after 3 not-found reads: {_call_count['db']}")

What each block does

Anatomy of the module

Line 20 · slow_db_lookup
Simulates the expensive origin. Returning None for id>1000 lets us demo negative caching without a real DB.
origin
Line 30 · TTL_SECONDS + NEGATIVE_TTL
Two different TTLs. Hits get a long TTL (5 min) since the data rarely changes. ‘Not found’ gets a short TTL (30 s) so a freshly-created row appears quickly.
ttl
Line 37 · redis_lock
SET key value NX EX ttl — the atomic ‘lock if not set’ primitive. The token check on release prevents deleting someone else's lock. This is the classic Redis SET-NX pattern (not Redlock — that's for cross-instance locks, S089 material).
stampede
Line 55 · double-check after lock
The waiter re-reads the cache after the brief sleep. 90% of the time the holder has already written it, and the waiter serves from cache without hitting DB.
correctness
Line 70 · NEGATIVE_SENTINEL
A specific string marks ‘we asked, it doesn't exist’. Without this, every request for a nonexistent key hits the DB — the ‘cache-penetration’ attack vector.
negative-cache
Line 82 · update_user → DELETE
Write-invalidate. Simpler than write-update because we don't need to know how to reconstruct the cached value at write time — the next read rebuilds it.
invalidation
Try itSimulate a thundering herd and see the stampede lock earn its keep

Fire 50 concurrent requests at a cold cache and count DB calls with the lock enabled vs disabled:

import threading
 
r.flushdb()
_call_count["db"] = 0
 
threads = [threading.Thread(target=get_user, args=(42,)) for _ in range(50)]
for t in threads: t.start()
for t in threads: t.join()
 
print(f"db_calls after 50 concurrent cold reads: {_call_count['db']}")

With the lock: ~1–3 DB calls (usually 1). Without: 50 — every thread raced past the empty cache and hit the DB at once. Multiply that by every popular key on a real product and you understand why caches without stampede protection make DBs melt.

💡 Hint · Comment out the `with redis_lock(...)` block and repeat. You'll see all 50 threads call the DB simultaneously.

Bonus — what to cache and what NOT to cache

  • Cache: read-heavy, expensive to compute, tolerant of some staleness (product pages, user profiles, search results, aggregations).
  • Do NOT cache: write-heavy tables, personalised data with strict freshness (bank balances, unread-message counters that must be exact), anything where staleness can cause security or safety bugs (permission checks, feature flags for security features).

(d) Production reality · 15 min

War story Meta (Facebook)· 2013petabyte-scale · billions of ops/sec
🔥 What broke

Facebook ran into ‘thundering herd’ on hot keys — a celebrity's profile expired, and 100k requests raced to backfill the same key, all hitting the DB. MySQL fell over regularly.

They also saw ‘stale sets’ — client A reads DB, gets old value; client B writes DB + invalidates cache; client A writes its stale value back into the cache. Cache now holds pre-write data indefinitely.

🧯 The fix
Introduced leases: on a miss, the cache gives one client a 64-bit ‘lease’ token. Only that client is allowed to write the cache for that key. Everyone else waits or serves a stale value. Also introduced a ‘gutter pool’ — a small fallback cache used when the primary shard is unhealthy — to prevent DB overload during any single-node failure.
🎓 Lesson to steal
At scale, ‘cache-aside as literally described in the textbook’ is not enough. You need coordination for stampedes (leases or SET NX locks) and failure isolation (gutter pool) or one bad cache node takes down the DB.
Post-mortem
War story Instagram· 2016hundreds of millions of users
🔥 What broke
Instagram's follower-feed used cache-aside with a 30-minute TTL. During a hot event (Coachella livestream), 50% of the feed cache expired within the same 5-second window. Cache miss storm → DB overload → 3-minute outage.
🧯 The fix
Added TTL jitter: instead of exactly 1800 s, TTLs randomly range 1500–2100 s. Expirations spread out. Also added ‘probabilistic early expiration’: as a key gets close to TTL, one lucky reader in ~1000 refreshes it early instead of everyone piling up on the actual expiry moment.
🎓 Lesson to steal
Fixed TTLs synchronise expirations. Always add jitter, and consider ‘refresh-ahead’ for hot keys. Random ±20% on TTL is the cheapest fix.
War story Common failure mode · every startup· 2024universal
🔥 What broke
"We deployed the write-through cache last week and now some users see other users' data." The write path stored the value at user:profile (missing the user ID) because a template-string bug. Cache is now a single global mutable variable serving everyone's last-written profile.
🧯 The fix
Add tests: every cache write includes the entity ID in the key. Add a static analysis rule (or code-review checklist): grep for f"strings" without variable substitution in cache-key builders. Even better: a tiny cache_key(entity_type, entity_id, version=1) helper that fails if entity_id is None.
🎓 Lesson to steal
Cache keys are your consistency boundary. A bug in key construction is worse than a bug in the app, because it silently mixes data across users. Never build keys with raw string concatenation — always use a typed helper.

The five failure modes worth memorising

Learn the names, spot them in reviews
  • Cache stampede (thundering herd) — many concurrent misses hit the origin simultaneously. Fix: locks / leases / probabilistic early expiration.
  • Cache penetration — repeated queries for keys that don't exist bypass the cache and hit the origin. Fix: negative cache, or a bloom filter in front.
  • Cache avalanche — mass simultaneous expiry (fixed TTL) or a cold-cache restart. Fix: TTL jitter, warm-up on deploy, gutter pool.
  • Stale writes / read-then-write races — cache-aside without coordination lets an old value overwrite a newer one. Fix: version tags, CAS (compare-and-swap), or write-through.
  • Hot key overload — one key gets 10× the traffic of any other (celebrity, trending post). Even Redis can bottleneck. Fix: L1 in-process cache, or shard by key + random suffix (`user:42:{shard}`).

Where this shows up in the rest of the plan

Caching primitives you built here feed every downstream topic
S064 · CDN & Edge
A CDN is a distributed cache-aside for HTTP responses. Same patterns, planet-scale.
S062 · Load balancers
Nginx microcache is a poor man's shared cache in front of your origin.
S055 · Redis
The tool we used. The next level: cluster mode, replication, persistence trade-offs.
S078 · SRE · SLOs
Cache hit ratio is one of the top-5 metrics you'll always watch.
S089 · System design · News feed
Feed fan-out is 90% about caching strategies.
S090 · System design · URL shortener
Bit.ly-style systems live and die on cache hit ratio.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. What is cache-aside, and what step is easy to forget? (invalidation on write)
  2. Name three cache failure modes and one fix for each.
  3. When would you NOT cache something? (write-heavy, strict-freshness, security-sensitive)

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.