Codetail

Article 4 of 15

Caching

The fastest request is one you never make.

24 min read

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)

CPU L1 cache~0.5 ns
CPU L2 cache~4 ns
CPU L3 cache~40 ns
RAM~100 ns
SSD (NVMe)~100 ยตs
Redis / Memcached~1 ms
HDD~10 ms
Network roundtrip~100 ms

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.

1๐ŸŒ
Browser cache0 ms

Assets stored on disk. No network request at all.

2๐ŸŒ
CDN edge5-20 ms

Nearest point-of-presence serves the response. No origin hit.

3โšก
App in-memory<1 ms

Process-local memory: fastest, but lost on restart, not shared.

4๐Ÿ”ด
Redis / Memcached1-5 ms

Shared distributed cache across all app instances.

5๐Ÿ—„๏ธ
DB query cachevaries

Database-internal cache for repeated identical queries.

6๐Ÿ’ฟ
DB disk10-100 ms

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.

Eviction Policy Explorer

Incoming requests (0/20 processed)

A
B
C
D
A
B
E
A
C
F
A
B
D
A
G
B
A
C
H
A

Cache (0/5 slots used)

empty
empty
empty
empty
empty
Hits: 0Misses: 0
Hit rate0%
LRULeast Recently 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.

LFULeast Frequently Used

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

FIFOFirst In, First Out

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.

โฑ๏ธTTL (time-to-live)

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 min

Trade-off: Simple to implement everywhere. Allows staleness up to the TTL window. No coordination required.

๐Ÿ“กEvent-driven invalidation

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.

๐Ÿ”‘Cache versioning

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 keys

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

ResourceTypical TTLRationale
User profile5 minChanges infrequently. Short TTL covers rare updates without DB pressure.
Product price30 secCan change rapidly. Stale price shown to a buyer is a business problem.
Homepage feed1 minSlightly stale is acceptable. Reduces DB load massively at scale.
Auth token validity0 / neverMust invalidate on logout. TTL alone is insufficient for security.
Static asset (JS/CSS)1 yearFilename includes content hash. Cache forever; deploy new hash to bust.
Search results10 minFresh 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.

Cache-asideLazy loadingrecommended default

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

1
App checks cache: HIT? Return immediately.
2
Cache MISS: Fetch from database.
3
Populate cache: Store result. Return to caller.

Write path

1
Write to database: Source of truth updated.
2
Invalidate cache key: Next read repopulates from DB.

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.

Write-through

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

1
App checks cache: Always a HIT (if key exists).

Write path

1
Write to cache: Synchronous.
2
Write to database: Also synchronous. Both succeed or both fail.

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.

Write-behindWrite-back

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

1
App checks cache: HIT: serves latest data including unflushed writes.

Write path

1
Write to cache: Immediate. Fast response to caller.
2
Async flush to DB: Batched. Happens in background.

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.