Why Caching Matters
A cache is a fast data store that sits between a requester and a slower data source. Instead of fetching the same data from a database or API on every request, you store the result once and serve it repeatedly from memory. The speed difference between storage levels is not incremental: it is orders of magnitude.
If a CPU cycle takes 1 second in human time, a RAM access takes 6 minutes. A disk read takes 6 months. A cross-continent network round-trip takes 10 years. Caching collapses that gap.
The memory hierarchy
Latency by storage level (log scale)
The principle at the heart of caching is temporal locality: data accessed recently is likely to be accessed again soon. Most real workloads follow the Pareto principle, where 20% of the data handles 80% of the reads. A small cache covering the hot fraction of your data dramatically reduces load on your database.
Cache layers in a web system
A request can be served by any of several cache layers before it reaches your database. Each layer a request avoids saves both latency and infrastructure cost.
Assets stored on disk. No network request at all.
Nearest point-of-presence serves the response. No origin hit.
Process-local memory: fastest, but lost on restart, not shared.
Shared distributed cache across all app instances.
Database-internal cache for repeated identical queries.
Last resort: full table scan or index lookup from storage.
These layers compound. A CDN hit means zero app server load. An in-memory app cache hit means zero Redis network hop. Design your caching strategy as a hierarchy, not as a single layer bolted on at the end.
Eviction Policies
When a cache is full and a new item needs to be stored, something must be removed. The eviction policy determines which entry gets dropped. The choice significantly affects hit rate for the same cache size.
Use the explorer below to step through a 20-request sequence with a 5-slot cache. Switch between LRU, LFU, and FIFO to see how eviction decisions differ and how the hit rate changes at the end of the sequence.
Incoming requests (0/20 processed)
Cache (0/5 slots used)
Evict the entry that was accessed least recently.
# On access: move to front of list # On eviction: remove from tail cache = OrderedDict() cache.move_to_end(key) # hit cache.popitem(last=False) # evict
Strengths
- +Recency is a strong proxy for future access. Hot data stays warm.
- +Handles bursty workloads well. A spike of reads on key A keeps A cached.
Weaknesses
- -Sequential scans pollute the cache. Reading a 1M-row table evicts all hot data.
- -Requires tracking access order on every read, which adds bookkeeping overhead.
Best for: General-purpose web application caching. The right default when unsure.
Evict the entry with the lowest access frequency. Ties broken by recency.
# Track access counts # On eviction: find min-frequency key counts = Counter() counts[key] += 1 # hit evict = min(counts, key=lambda k: (counts[k], last_used[k]))
Strengths
- +Better for skewed workloads where a small set of keys is always hot.
- +Resists cache pollution: one-off access to cold keys does not evict warm ones.
Weaknesses
- -New keys start at frequency 1 and are immediately vulnerable to eviction.
- -Historical frequency can be misleading. A key popular two hours ago is stale information.
Best for: Read-heavy workloads with a stable hot set (recommendation engines, leaderboards).
Evict the entry that has been in the cache the longest, regardless of use.
# Queue: entries leave in insertion order from collections import deque queue = deque() queue.append(key) # insert queue.popleft() # evict oldest
Strengths
- +Trivially simple to implement. No access tracking required.
- +Predictable eviction order makes cache behavior easy to reason about.
Weaknesses
- -Ignores access patterns entirely. A frequently accessed key is evicted just as readily as a cold one.
- -Performs poorly for workloads with repeated access to a working set.
Best for: Rarely the right choice for application caches. Useful for simple buffers with uniform TTLs.
Redis uses LRU or LFU eviction, configurable via maxmemory-policy. Memcached uses LRU within slabs. Most application-layer caches (Guava, Caffeine, node-lru-cache) default to LRU with optional size-based weighting. Start with LRU and only change if profiling shows a specific pattern that would benefit from LFU.
Cache Invalidation
There is a famous quip in computer science: there are only two hard problems, cache invalidation and naming things. The reason cache invalidation is hard is not technical: it is a consistency problem. Your cache and your database can disagree, and every user who hits a stale cache entry gets wrong data silently.
Serving stale data is worse than a cache miss in most contexts. A miss is slow. Stale data is incorrect. Design your invalidation strategy before you design cache population, not after.
Every cache entry expires after a fixed duration. The next request after expiry triggers a database fetch and repopulates the cache.
redis.set("user:42", json, ex=300) # expires in 5 minTrade-off: Simple to implement everywhere. Allows staleness up to the TTL window. No coordination required.
When data changes in the database, publish an event. Cache nodes subscribe and delete (or update) the affected keys immediately.
# On user update:
db.update(user)
redis.delete(f"user:{user.id}")
# or: publish("cache.invalidate", key)Trade-off: Zero staleness window. Requires a messaging layer (Redis pub/sub, Kafka, webhooks) and careful key mapping.
Embed a version or hash in the cache key. When data changes, increment the version. Old keys are never explicitly deleted; they expire via TTL or fall off via eviction.
# Cache key includes schema version
key = f"user:{user_id}:v{SCHEMA_VERSION}"
# Deploy with new SCHEMA_VERSION to bust all keysTrade-off: No coordination needed. Old keys waste memory until evicted. Works well for static assets with content-addressed filenames.
Choosing a TTL
TTL is a correctness budget. Set it to the maximum staleness your users can tolerate for each resource. There is no universal answer: a product price and a static asset have completely different tolerances.
| Resource | Typical TTL | Rationale |
|---|---|---|
| User profile | 5 min | Changes infrequently. Short TTL covers rare updates without DB pressure. |
| Product price | 30 sec | Can change rapidly. Stale price shown to a buyer is a business problem. |
| Homepage feed | 1 min | Slightly stale is acceptable. Reduces DB load massively at scale. |
| Auth token validity | 0 / never | Must invalidate on logout. TTL alone is insufficient for security. |
| Static asset (JS/CSS) | 1 year | Filename includes content hash. Cache forever; deploy new hash to bust. |
| Search results | 10 min | Fresh enough for most queries. Index updates do not require immediate flush. |
The cache stampede problem
When a popular cache key expires, dozens or hundreds of requests can simultaneously see a miss and all rush to the database to repopulate it. The database receives a spike of identical queries at once.
Probabilistic early expiration
Before a key expires, randomly decide to refresh it early based on proximity to TTL. Spreads the refresh load over time.
# XFetch algorithm
if now - computed_at > ttl - beta * log(rand()):
recompute_and_cache()Locking / mutex
When a miss occurs, the first process acquires a lock and fetches from DB. Others wait. On release, all processes read the now-warm cache.
if not cache.get(key):
with cache.lock(key, timeout=2):
if not cache.get(key): # re-check
cache.set(key, db.fetch())For most applications, cache-aside with TTL plus event-driven invalidation on writes covers 95% of needs. Add stampede protection only after you observe it in production. Premature complexity in cache layers is a frequent cause of subtle bugs.
Write Strategies
Eviction policies decide what leaves the cache. Write strategies decide how the cache is populated and kept in sync with the database. They are separate decisions that combine independently.
The central tension: you want fast reads (cache hits) but also fresh data (no stale reads) and safe writes (no data loss). No strategy gives you all three without trade-offs.
The application manages the cache explicitly. On a read, check the cache first. On a miss, load from the database, write the result into the cache, and return it. On a write, update the database and delete (or update) the cache key.
Read path
Write path
Strengths
- +Cache only holds data that has actually been requested.
- +Cache failures are transparent: the app falls back to the database.
- +Works with any database. No special cache support required.
Weaknesses
- -First request after a miss pays the full database round-trip cost.
- -Window of stale data between a write and the next cache population.
- -Cache stampede risk: many processes miss simultaneously and hammer the DB.
When to use: The right default for most read-heavy web applications.
Every write goes to the cache and the database synchronously. The cache is always up to date because it is updated on every write, not just on reads.
Read path
Write path
Strengths
- +Cache is always consistent with the database. No stale reads.
- +Read performance is optimal once the cache is warm.
Weaknesses
- -Write latency is higher: must wait for both cache and DB.
- -Cache fills with data that may never be read again (write-heavy workloads waste cache space).
When to use: Good for write-then-read patterns where freshness is critical.
Writes go to the cache immediately and return. The cache asynchronously flushes changes to the database in batches. The cache is the primary write target; the database is a secondary, eventually-consistent sink.
Read path
Write path
Strengths
- +Lowest write latency: the caller is not blocked on a database write.
- +Batching reduces database write amplification under high write volume.
Weaknesses
- -Data loss risk: if the cache crashes before flushing, writes are permanently lost.
- -Significantly more complex to implement and reason about correctly.
When to use: Use only when write latency is the bottleneck and data loss is acceptable.