Cache-Aside
Lazy-load data into a cache the application controls, keep the database as source of truth, and treat invalidation as the hard part — the stampede and the stale-write race — with a correct TypeScript loader.
Every read-heavy system arrives at the same wall: the database is the source of truth, and the source of truth is too slow to ask on every request. The product page that renders ten thousand times a minute does not need ten thousand identical queries against the same rarely-changing row. The obvious move is to keep a copy of that row somewhere fast — an in-memory store, a Redis cluster, a local map — and read from there. The instant you do, you have signed up for the second-hardest problem in computer science: keeping that copy honest as the truth underneath it changes.
Cache-Aside is the most common answer, and the most common thing done wrong. Its defining choice is that the application, not the cache, owns the read-and-populate logic. On a read the app looks in the cache; on a miss it fetches from the database and puts the result in the cache itself; on a write it updates the database and invalidates the cached copy. The cache sits beside the data path — hence the name — as a fast lane the application fills lazily, one miss at a time.
The problem
Consider a catalog service serving product detail. Each product is a row that changes maybe once a day when a price or description is edited, and gets read thousands of times a minute. Hitting the database on every read is pure waste: the same query, the same answer, the connection pool saturated by requests that a single cached copy could have served.
So you add a cache. Now three concrete questions decide whether it helps or hurts:
- Who fills the cache, and when? You cannot pre-load everything — the working set is too large and mostly cold. You want to cache what is actually being read, populated on demand. That means the first reader of any key pays a miss, and every reader after them gets a hit until the entry expires or is invalidated.
- What happens when the underlying row changes? The cached copy is now a lie. Every reader who hits that key gets stale data until something corrects it. "Something" is either a TTL quietly expiring the entry or a write path explicitly removing it — and choosing wrong here is where cache bugs are born.
- What happens when a hot key misses under load? A popular entry expires. In the same millisecond, a thousand concurrent readers all miss, all fall through to the database, and all run the identical expensive query at once. The cache you added to protect the database just aimed a thundering herd at it — the cache stampede.
The naïve implementation — "check cache, else query and store" — answers question 1 and ignores 2 and 3. It works in the demo and pages you at 3 a.m. in production, because the demo has no writes and no concurrency. The pattern is not the four-line read path; it is the discipline around invalidation and stampedes that the four-line version omits.
When to use it
Cache-Aside earns its keep under a specific shape of workload:
- Reads dominate writes, heavily. The pattern amortizes one expensive read across many cheap ones. A 100:1 read/write ratio is where it shines; near 1:1 it mostly adds invalidation bugs for little gain.
- Staleness is tolerable and bounded. You must be able to say "a reader seeing data up to N seconds old is acceptable," and mean it. Product descriptions, user profiles, and rendered pages usually qualify. Account balances at the moment of a transfer usually do not.
- The cached data is expensive to produce. A single indexed primary-key lookup is already fast; caching it saves little. Caching pays when the source read is a heavy join, an aggregation, a cross-service call, or a rendered fragment — work you genuinely do not want to repeat.
- The working set fits, and access is skewed. Real traffic is Zipfian: a small set of hot keys serves most reads. Cache-Aside exploits exactly that skew — cache the hot minority, let the cold majority miss. If access is uniform across a huge key space, the hit ratio collapses and so does the benefit.
The archetypal fit is a read-model or query side — which is why Cache-Aside composes so naturally with CQRS, where the read model is already a denormalized, cache-friendly projection.
When NOT to use it
A cache is a second source of truth you have promised to keep in sync, and that promise has a cost. Skip it when:
- Writes are frequent relative to reads. If a key is written almost as often as it is read, you invalidate almost as often as you populate. The cache thrashes, the hit ratio is poor, and you have added a distributed-consistency problem to buy nearly nothing.
- Reads must be strongly consistent. If a reader must never see a value older than the last committed write — a stock count at checkout, a permission that was just revoked — Cache-Aside's window of staleness is a correctness bug, not a tuning knob. Read from the source, or use a strategy with synchronous write-through and read-your-writes guarantees.
- The source is already fast enough. A cache in front of a sub-millisecond keyed lookup adds a network hop, a serialization cost, a memory bill, and an invalidation surface to shave time off something that was not the bottleneck. Measure first; cache the query that actually hurts.
- You cannot answer "how does this key get invalidated?" for every cached key. If there is no clear write path or event that removes or refreshes the entry, your only correctness mechanism is the TTL, and you are betting the whole system's correctness on a timeout being short enough. Sometimes that bet is fine; make it on purpose.
A useful test: if you cannot state the maximum staleness a reader may observe and show the path that bounds it, you do not yet understand your cache — you have just deferred a consistency bug.
Architecture
The application mediates between two stores: a fast cache and an authoritative database. Reads try the cache first and fall back to the database, populating the cache on the way back. Writes go to the database and then invalidate the cache. The cache is never the source of truth — it is a disposable, reconstructable accelerator, and every entry can be dropped at any time without data loss.
flowchart TD
App[Application]
Cache[(Cache)]
DB[(Database — source of truth)]
App -- 1: GET key --> Cache
Cache -- 2a: hit, return value --> App
Cache -- 2b: miss --> App
App -- 3: read on miss --> DB
DB -- 4: row --> App
App -- 5: populate with TTL --> Cache
The write path is the half that separates a correct implementation from a broken one, and its ordering is not arbitrary.
sequenceDiagram
participant App
participant DB
participant Cache
App->>DB: 1. write new value (commit)
App->>Cache: 2. DELETE key (invalidate)
Note over Cache: next reader misses,<br/>repopulates from DB
The essential rule is write the database first, then invalidate the cache — and invalidate by deletion, not by update. Two subtle reasons. First, ordering: if you invalidated the cache before committing the write, a concurrent reader could miss, read the old committed row from the database, and repopulate the cache with the stale value milliseconds before your write lands — re-poisoning the cache you just cleared. Second, delete-don't-update: writing the new value into the cache directly seems efficient but reopens a race between two concurrent writers where the slower database write can leave the faster writer's value in the cache. Deleting sidesteps it — the next reader reconstructs from whatever the database actually committed, which is by definition the truth. The cache holds only values it has read back from the source.
Code walkthrough
Here is a correct, dependency-free Cache-Aside loader in TypeScript. It does the read-and-populate path, but crucially it also collapses concurrent misses on the same key into a single database read — the stampede defense that the naïve version omits. Start with the ports.
interface Cache {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttlMs: number): Promise<void>;
delete(key: string): Promise<void>;
}
interface Source<T> {
load(key: string): Promise<T | null>;
}
class NotFoundError extends Error {
constructor(key: string) {
super(`no record for "${key}"`);
this.name = "NotFoundError";
}
}
Cache is a port satisfied by a Redis adapter or an in-memory fake in tests; Source is whatever authoritative read you are accelerating — a repository, a heavy query, a downstream call. Keeping both as interfaces is what makes the loader pure domain logic: it orchestrates a caching policy and knows nothing about Redis or SQL.
Next, the loader. The field that makes it correct under load is inFlight — a map of the reads currently in progress, so a burst of concurrent misses on one key shares a single Source.load instead of a thousand of them.
class CacheAsideLoader<T> {
private readonly inFlight = new Map<string, Promise<T>>();
constructor(
private readonly cache: Cache,
private readonly source: Source<T>,
private readonly ttlMs = 60_000,
) {}
async get(key: string): Promise<T> {
const cached = await this.cache.get(key);
if (cached !== null) return JSON.parse(cached) as T;
return this.loadOnce(key);
}
get is the hot path. On a hit it deserializes and returns — no Source, no inFlight, sub-millisecond. Only a miss falls through to loadOnce, and that is where the stampede is contained.
private loadOnce(key: string): Promise<T> {
const pending = this.inFlight.get(key);
if (pending) return pending;
const load = (async () => {
const value = await this.source.load(key);
if (value === null) throw new NotFoundError(key);
await this.cache.set(key, JSON.stringify(value), this.ttlMs);
return value;
})();
this.inFlight.set(key, load);
try {
return await load;
} finally {
this.inFlight.delete(key);
}
}
The first caller to miss a key installs its in-progress promise in inFlight; every concurrent caller that arrives before it resolves gets that same promise and awaits the one shared database read. When it settles, the finally clears the entry so the next genuine miss starts fresh. A thousand simultaneous misses on a hot key become one query against the source instead of a thundering herd — the single most important line in the file is if (pending) return pending.
The write path is deliberately tiny, and its shape is the whole lesson.
async put(key: string, write: () => Promise<void>): Promise<void> {
await write(); // 1. commit to the source of truth first
await this.cache.delete(key); // 2. then invalidate — delete, never update
}
}
Commit first, then delete. Never write the new value into the cache here — let the next reader repopulate it from the committed truth via the same stampede-safe path. Usage is unremarkable, which is the point: the discipline lives inside the loader, not scattered across every call site.
const products = new CacheAsideLoader(redis, productRepo, 300_000);
const product = await products.get(sku); // <1 ms on a hit
await products.put(sku, () => productRepo.save(edited)); // commit + invalidate
Two production refinements the toy version omits. First, the TTL is not optional even with explicit invalidation — it is the safety net for the invalidation you missed (a write that bypassed put, a dropped delete, a bug). Treat the TTL as the maximum time a stale entry can survive undetected, and set it deliberately. Second, an in-process inFlight map collapses stampedes only within one instance; across a fleet you still get one miss per instance per hot key. When that matters, promote the guard to a distributed lock (a short-lived SET NX in Redis) so exactly one instance repopulates and the rest briefly serve stale or wait — the same idea, one tier up.
Performance characteristics
The metrics in the panel above are indicative — sensible defaults and order-of-magnitude figures to build intuition, not measurements from a specific benchmark.
The hit ratio is the only number that matters, and a miss is not free. A cache hit skips the database entirely; a cache miss is strictly more expensive than having no cache at all, because it pays the full source read plus a cache write plus the serialization on both. The pattern is a bet that hits vastly outnumber misses. Below roughly a 90% hit ratio the miss penalty and the memory bill stop paying for themselves, and you should question whether this key belongs in the cache at all. This is not a figure to assume — instrument hits and misses per key class and look at the real distribution, because a handful of never-hit keys can quietly poison the average.
TTL is the staleness dial, and it trades freshness against load. A short TTL keeps data fresh and bounds how wrong a reader can be, at the cost of more expirations and therefore more misses hammering the source. A long TTL is cheap and calm but widens the window in which readers see stale data. There is no correct TTL — there is the maximum staleness your domain tolerates, and you set the TTL to that. Explicit invalidation on writes tightens the typical staleness far below the TTL; the TTL remains the worst-case bound for every write path you forgot.
Stampede protection changes the failure mode, not the happy path. Under steady traffic the inFlight collapse is invisible — misses are rare and rarely concurrent. Its entire value is at the tail: the moment a hot key expires under load, request-collapsing turns a thousand-query spike into one, which is the difference between a smooth p99 and a database that briefly falls over every time a popular entry ages out. Cache design is dominated by these tail events; provision for the stampede, not the average.
Live demo
An interactive Cache-Aside playground is coming soon. You will be able to drive read traffic against a simulated slow database, watch the hit ratio climb as hot keys populate, expire a key under concurrent load to see the stampede — then toggle request-collapsing on and watch a thousand misses fold into a single source read, and edit a row to trace the commit-then-invalidate write path. Check back shortly.
Related patterns
CQRS is Cache-Aside's most natural home. CQRS splits reads from writes and gives you a denormalized read model built exactly for querying — which is precisely the expensive-to-produce, read-dominated, staleness-tolerant data Cache-Aside was made to accelerate. The read side caches its projections; the write side, already separate, becomes the clean place to invalidate them. The two patterns answer adjacent questions — CQRS shapes the read model, Cache-Aside serves it fast — and they compose without friction.
Publish/Subscribe is how you fix Cache-Aside's weakest seam: invalidation across a fleet. Local delete-on-write only clears the cache on the instance that handled the write; every other instance keeps serving its own copy until the TTL saves it. Publishing an invalidation event on write and having every instance subscribe turns "eventually, when the TTL expires" into "within a message-propagation delay," collapsing the staleness window without shortening the TTL and paying more misses. It is the standard upgrade path from single-node correctness to fleet-wide correctness.
A note on the family of strategies. Cache-Aside is one of several caching topologies, distinguished by who owns the read-and-populate logic. In read-through and write-through, the cache itself sits inline and loads or persists on the application's behalf, so the app talks only to the cache — simpler call sites, but you need a cache that supports it and you cede control of the load path. Write-behind (write-back) acknowledges the write to the cache and flushes to the database asynchronously, trading durability risk for write latency. Cache-Aside keeps the application in explicit control of both paths, which is why it is the most widely implemented: it works with any dumb key-value store, demands nothing special of the cache, and makes the invalidation policy visible in your own code — for better, when you get it right, and for worse, when you forget that invalidation was the hard part all along.
Related patterns
- Composes with: CQRS
- Composes with: Publish/Subscribe