Caching Strategies: Cache-Aside, Write-Through, Write-Behind
· Pavan Y N · system-design, scalability, architecture, distributed-systems, databases

A cache is a bet that the same data will be asked for again before it changes. When the bet pays off, a slow lookup becomes a fast one. When it does not, you have a second copy of the truth that can drift, expire at the wrong moment, or hide a bug for hours. EduGeek explains caching as four decisions: where copies live, who fills them, how they leave, and what you accept when they disagree with the source.
This guide covers the layers, the four read and write patterns, eviction, invalidation, stampedes and hot keys, sizing, measurement, and the cases where the right answer is no cache. It includes a tested TypeScript implementation of cache-aside with stampede protection. The same ideas drive the read path in the URL shortener walkthrough.
# Where caches sit
A single request can pass through several caches before it touches your database. Each layer trades reach for freshness control.
| Layer | What it holds | Who controls freshness | Typical failure |
|---|---|---|---|
| Browser / HTTP cache | Responses keyed by URL and headers | Origin, through Cache-Control | You cannot purge it; you can only wait for expiry |
| CDN (content delivery network) / reverse proxy | Shared responses near the user | Origin headers plus purge APIs | Serving a stale or wrong variant to everyone |
| In-process cache | Objects in the app's own memory | The app | Every instance holds a different copy |
| Distributed cache (Redis, Memcached) | Objects shared by all instances | The app | Becomes a hard dependency and a hot spot |
| Database buffer cache | Pages and sometimes query results | The database | Invisible until it is cold, for example after a restart |
The closer a cache is to the user, the cheaper a hit is and the harder the data is to correct. A response cached in a browser for an hour is a promise you cannot retract; a value in Redis can be deleted in one command.

Cache at the outermost layer that can still meet your correctness requirement. Public, identical-for-everyone content belongs at the CDN; per-user data belongs in the application tier or a distributed cache. HTTP caching (RFC 9111) is worth learning properly because it gives you two layers for free.
# The four patterns
The patterns differ in one question: who talks to the database, and when.
## Cache-aside (lazy loading)
The application owns the logic. On a read it checks the cache; on a miss it reads the database, writes the result to the cache and returns it. On a write it updates the database and then deletes the cached key (or occasionally updates it).
read(key):
v = cache.get(key)
if v is missing:
v = db.read(key)
cache.set(key, v, ttl)
return v
write(key, v):
db.write(key, v)
cache.delete(key)
This is the default for good reason: the cache holds only data that was actually requested, and a cache outage degrades to slower reads rather than failures. The costs are a miss on the first request for each key and a read/write race, covered under invalidation. Prefer deleting on write to updating: deleting is idempotent and cannot store a value computed from stale inputs.
## Read-through
Same as cache-aside, but the cache (or a library wrapping it) loads from the database on a miss, so application code only calls cache.get. That removes duplicated loading logic and gives request coalescing a natural home, at the cost of coupling the cache layer to your data access code.
## Write-through
Every write goes to the database and the cache in one synchronous operation. Recently written keys are always warm and consistent.
write(key, v):
db.write(key, v) # source of truth first
cache.set(key, v) # then the cache, same request
You pay in write latency and cache pollution, since data that is written but rarely read still occupies memory. The pair is not atomic: if the cache write fails after the database write succeeds, delete the key or let a time to live (TTL) repair it. It suits read-heavy data read soon after writing, such as profiles and settings.
## Write-behind (write-back)
Writes land in the cache and are acknowledged immediately; a background process flushes them to the database later, often in batches. Repeated writes to one key coalesce into a single database write, which suits high-frequency counters and telemetry.
The price is durability. Until a flush, the cache holds the only copy, so a node failure loses those writes, and other database readers see old data. Use it only when losing a small recent window is acceptable.
Start with cache-aside. Move to another pattern only when you can name the specific problem it solves.
# Eviction: what leaves when memory runs out
A cache is smaller than the data behind it, so something has to leave. Three families matter.
- →LRU (least recently used) evicts what has gone longest without access. It works well when recent access predicts future access, and it is the default recommendation in the Redis eviction documentation for skewed workloads. Its weakness is scans: one pass over a large cold dataset can flush the genuinely hot entries.
- →LFU (least frequently used) evicts what is accessed least often. It protects a stable hot set from scans, but a naive implementation never lets go of keys that were popular. Redis decays its approximate frequency counter over time to address this, and W-TinyLFU, used in the Caffeine library, combines a frequency filter with a recency window.
- →TTL is not a memory policy at all. It bounds how long a value may be wrong. Treat TTL as your correctness backstop and LRU or LFU as your memory policy, and set both.
Avoid noeviction for a cache: a full cache then returns write errors.
# Invalidation: why it is hard
An old joke, often attributed to Phil Karlton, lists cache invalidation as one of the two hard problems in computing. It is a distributed-systems problem in disguise: two stores, no transaction across them, concurrent actors. The standard cache-aside race:
- •Reader A misses on key
kand reads the old value from the database. - •Writer B updates the database and deletes
kfrom the cache. - •Reader A, still holding the old value, writes it to the cache.
The cache now holds stale data until the TTL expires. The window is small, but at high volume small windows are hit daily. Mitigations, simplest first:
- →Always set a TTL, so a bad entry has a bounded life.
- →Delete after the write commits, not before. Deleting first widens the window.
- →Version your values. Store a version or timestamp from the database row and refuse to overwrite a newer cache entry with an older one. Compare-and-set on Redis (a small Lua script or
WATCH) makes this atomic. - →Invalidate from the change log. Publishing invalidations from the database's change stream (change data capture) removes the dependence on every writer remembering to delete.
- →Leases. Facebook's memcache deployment, described in Scaling Memcache at Facebook, hands the missing client a token so that a set with an outdated token is rejected, which addresses both stale sets and herds.
Derived data is the other trap: if a cached page was built from five rows and one changes, the key does not say which entry to drop. Cache at the granularity of what changes, and use short TTLs for aggregates.
# Stampedes and thundering herds
When a popular key expires, every request that arrives before the first reload finishes also misses, and each one goes to the database. For example, a key read 5,000 times per second whose recomputation takes 200 ms will see about 5,000 × 0.2 = 1,000 concurrent misses for a single expiry. If the query is expensive, the database slows, reloads take longer, more requests pile up, and the system cannot recover on its own.

Three mitigations compose well.
Request coalescing (single-flight). Within a process, keep a map from key to the in-flight load. The first miss starts the load; later misses for the same key await the same promise. Across processes, you either accept up to one load per instance per expiry or add a short distributed lock (for example SET key NX PX) so one instance refreshes while others serve stale data or wait.
Jittered TTLs. Keys written together with a 300-second TTL during a deploy or warm-up all expire together. Random spread, say plus or minus 10 percent, turns that cliff into a slope.
Stale-while-revalidate. Keep serving the expired value for a bounded extra window while one request refreshes it in the background. Users see no latency spike, and the database sees one query. The same idea exists in HTTP as the stale-while-revalidate and stale-if-error extensions in RFC 5861, supported by many CDNs.
Related: cache penetration. Requests for keys that do not exist always reach the database. Cache the "not found" answer with a short TTL.
## A working implementation
The class below implements cache-aside with all three mitigations: single-flight per process, jittered fresh windows, and a stale window served while a background refresh runs. It is written against a minimal Store interface so Redis, Memcached or an in-memory map can sit behind it.
export interface Store {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttlMs: number): Promise<void>;
del(key: string): Promise<void>;
}
interface Options {
freshMs: number; // serve straight from cache for this long
staleMs: number; // after that, serve stale while refreshing in the background
jitter: number; // 0.1 means the fresh window varies by +/-10%
}
interface Envelope<V> {
v: V;
freshUntil: number; // epoch ms
}
export class CachedLoader<V> {
private inflight = new Map<string, Promise<V>>();
constructor(
private readonly store: Store,
private readonly load: (key: string) => Promise<V>,
private readonly opts: Options,
) {}
async get(key: string): Promise<V> {
const raw = await this.store.get(key);
if (raw === null) return this.refresh(key); // miss: one caller loads, the rest wait
const entry = JSON.parse(raw) as Envelope<V>;
if (Date.now() >= entry.freshUntil) {
// Stale but still usable: answer now, refresh behind the response.
this.refresh(key).catch((err) => console.error("background refresh failed", key, err));
}
return entry.v;
}
async invalidate(key: string): Promise<void> {
await this.store.del(key);
}
private refresh(key: string): Promise<V> {
const existing = this.inflight.get(key);
if (existing) return existing; // request coalescing
const promise = (async () => {
const v = await this.load(key);
const spread = 1 + (Math.random() * 2 - 1) * this.opts.jitter;
const freshMs = Math.round(this.opts.freshMs * spread);
const entry: Envelope<V> = { v, freshUntil: Date.now() + freshMs };
await this.store.set(key, JSON.stringify(entry), freshMs + this.opts.staleMs);
return v;
})().finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
}
}
A test harness confirmed that 100 concurrent cold reads cause one load, fresh reads cause none, 50 concurrent reads after expiry return the old value immediately and trigger one background reload, invalidate forces a reload, and a failed load clears the in-flight entry so the next call retries.
Its limits: the in-flight map is per process, so N instances can still produce up to N loads per expiry. invalidate can lose a race with a refresh that started before it, so keep the TTL as a backstop or add versioning. And decide deliberately how to represent "not found".
# Consistency trade-offs
Every cache read answers "how wrong am I allowed to be?" Decide per data type.
- →Bounded staleness: a 60-second TTL means readers may see data up to 60 seconds old. Fine for listings, view counts and feeds.
- →Read-your-writes: a user who just changed their display name must see it. Refresh or bypass the cache for that user's own key after a write, or use write-through on that path.
- →Strong consistency: balances, checkout inventory, permission revocation. Read the source of truth.
Multiple regions compound this: invalidations reach other regions after a delay, so the same key can return different values depending on where the request lands.
# Hot keys and sizing
A distributed cache spreads keys across nodes, not traffic to one key. In Redis Cluster, a key maps to one of 16,384 hash slots, and each slot is served by one primary, so a single celebrity key can saturate one node while the rest sit idle. Typical fixes:
- →Put a small in-process cache with a short TTL in front, so each instance asks Redis at most once per window.
- →Replicate the hot value under several keys (
k#0tok#7) and have each reader pick one at random. - →Keep global, rarely changing values (flags, config) in application memory with periodic refresh.
For sizing, start from the working set, not the dataset. If 10 million objects are in active use at an average of 1 KB each, that is roughly 10 GB of payload before per-key overhead, replication and fragmentation, so budget meaningfully above it. Access is usually skewed, so a cache holding a fraction of the dataset can reach a high hit ratio, but the curve flattens: the last few points cost disproportionate memory. Find the knee by varying size while watching hit ratio and eviction rate.
# What to measure
A cache you do not measure is a cache you cannot trust. Track these:
- →Hit ratio, per keyspace. A global number hides a cold keyspace behind a hot one. In Redis, start with
keyspace_hits / (keyspace_hits + keyspace_misses). - →Latency of hits and misses separately, at p50 and p99. With an assumed 1 ms hit and 20 ms database read, average latency at hit ratio h is about 1 + (1 − h) × 20 ms: 3 ms at 90 percent, 1.2 ms at 99 percent. The bigger effect is on the database: at 10,000 requests per second, 90 percent sends 1,000 queries per second to the origin and 99 percent sends 100. Nine points of hit ratio cut origin load ten-fold.
- →Eviction rate. Evictions climbing while hit ratio falls means the cache is too small or the policy is wrong.
- →Origin load and concurrent loads per key, the stampede alarm.
- →Entry age at read time, if you serve stale data.
These fit naturally into the metrics pipeline described in the Prometheus monitoring guide.
# When not to cache
Caching is an optimization with a maintenance bill. Skip it, or delay it, when:
- →The data is read once or rarely. If most keys are requested a single time, the hit ratio will be low and you pay memory and complexity for nothing.
- →The query is already cheap. A network hop to Redis can cost as much as a fast indexed lookup.
- →The data must be exactly current. Balances, stock counts at checkout and authorization decisions are better served by a faster or better-indexed source of truth.
- →You have not found the real bottleneck. A missing index or an N+1 query is cheaper to fix than to hide.
- →The data is per-user and the cache is shared. A key that forgets the user ID leaks data between accounts, which is a security incident, not a performance bug.
- →A cold cache would overload the database. The cache has then become a hard dependency, and you need warm-up or capacity for the cold case. Failover in a multi-zone design, as in the AWS multi-AZ architecture guide, often lands on a cold cache.
# Key takeaways
- →Cache at the outermost layer that still meets your correctness requirement; copies nearest the user are the hardest to correct.
- →Default to cache-aside with delete-on-write. Use write-through when read-after-write matters and write-behind only when losing recent writes is acceptable.
- →Pair a memory policy (LRU or LFU) with a TTL; the TTL is the safety net for invalidation bugs.
- →The cache-aside read/write race is real. Mitigate it with TTLs, versioning, change-log invalidation or leases.
- →Prevent stampedes with request coalescing, jittered TTLs and stale-while-revalidate, and cache negative results. A distributed cache does not protect you from one hot key, so add a local tier or replicate the key.
- →Measure hit ratio per keyspace, hit and miss latency, eviction rate and origin load.
- →Sometimes the right answer is no cache: fix the query or the index first.
# Further reading
- →RFC 9111: HTTP Caching, browser, proxy and CDN caching.
- →RFC 5861: HTTP Cache-Control Extensions for Stale Content,
stale-while-revalidateandstale-if-error. - →Redis documentation: Key eviction, policies, approximated LRU and LFU, tuning stats.
- →Scaling Memcache at Facebook (NSDI 2013), leases, invalidation and cross-region consistency in production.