CachingGENERALSIMPLIFIEDFRAMEWORK-SPECIFIC

Cache-Aside

Read the cache, miss, read the database, write the cache — and the four things that go wrong in those four steps.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

What is the standard read-through pattern, and where exactly does it leak?

The requirement

Product pages should not re-run the same catalogue query for every visitor, but an editor's change should show up without a deploy.

The obvious build

Wrap the query: check the cache, and if it is empty, run the query and store the result. Four lines, done.

Why it breaks

The four lines are correct until two things happen at once. A concurrent write between the read and the populate leaves a stale value in the cache with a fresh TTL — the write is effectively undone from the reader's point of view.

How it breaks in production
  • The four lines are correct until two things happen at once. A concurrent write between the read and the populate leaves a stale value in the cache with a fresh TTL — the write is effectively undone from the reader's point of view.
  • A cold key under load means every concurrent request takes the miss path at the same time and all of them run the query (Cache Stampede).
  • A row that does not exist is never cached, so a key that is repeatedly requested and repeatedly absent hits the database every single time. That is the cheap half of a cache-penetration attack.
  • When the cache write fails — timeout, memory limit, connection reset — the naive code either throws (turning a cache problem into a request failure) or silently never caches, and hit rate quietly sits at zero.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • Cache-aside means the application, not the cache, owns the loading. The cache is a dumb key/value store that knows nothing about your database. Read, miss, load, populate, return.
  • The alternative shapes differ in who does the loading and when the write happens. Read-through puts the loader inside the cache layer, so the application only ever calls the cache. Write-through writes to the cache and the database together on every write. Write-behind writes to the cache and flushes to the database asynchronously, which trades durability for write latency.
  • Cache-aside is the default in application code because it needs nothing from the cache product and degrades to "just query the database" when the cache is unavailable. The cost is that four steps are open-coded at every call site unless you factor them into one helper.
  • The populate step is the interesting one. It happens *after* the read, so between reading the database and writing the cache, anything can have changed. The window is small and it is real (Backend Races).
  • Negative caching — storing a marker for "this key does not exist" with a short TTL — closes the repeated-miss hole. It has to be a distinguishable marker, not a stored null that your code reads as a miss.

The four steps, and what each one can do wrong

Each step has one job and one characteristic failure. Learning the pattern as a pipeline rather than as a code snippet is what makes the failure modes predictable instead of surprising.

The step that surprises people is the last one. Populating is not "storing the answer" — it is asserting that the value you read a moment ago is still current, for the whole duration of the TTL you attach to it.

Cache-aside read
  1. 1
    1. Lookup

    Reads the key from the cache.

    fails by Cache unreachable or slow. Must be treated as a miss, with a timeout, not as a request error.

  2. 2
    2. Hit path

    Deserializes and returns.

    fails by Value written by an older deploy deserializes into the current type with missing fields.

  3. 3
    3. Miss path

    Runs the loader against the database.

    fails by Every concurrent miss runs it at once (Cache Stampede); a missing row caches nothing and misses forever.

  4. 4
    4. Populate

    Serializes and stores with a TTL.

    fails by The row changed between step 3 and step 4, so a stale value is stored with a fresh lifetime.

  5. 5
    5. Return

    Returns the value to the caller.

    fails by A failed populate propagates as an exception, turning a cache problem into a 500.

Steps 1 and 4 must never fail the request. Steps 3 and 4 must be coalesced under concurrency, or step 3 runs N times.

The helper worth writing once

The version that survives production is not shorter than the naive one — it is the same shape with the failure handling made explicit. Cache errors become misses. The absent row becomes a stored sentinel. The value carries its schema version.

Written framework-free, it is portable to any cache client. The important part is that the three try boundaries are separate: a failure to read, a failure in the loader, and a failure to write are three different events with three different responses.

getOrLoad: cache-aside with the failure paths spelled out
1const VERSION = 3
2type Envelope<T> = { v: number; data: T | null }
3
4async function getOrLoad<T>(
5 key: string,
6 ttlSeconds: number,
7 loader: () => Promise<T | null>,
8 opts: { negativeTtlSeconds?: number } = {},
9): Promise<T | null> {
10 // 1. Lookup. A cache failure is a miss, never a request failure.
11 try {
12 const raw = await cache.get(key)
13 if (raw !== null) {
14 const env = JSON.parse(raw) as Envelope<T>
15 if (env.v === VERSION) {
16 metrics.inc('cache', { prefix: prefixOf(key), result: 'hit' })
17 return env.data // may be null: a cached "does not exist"
18 }
19 // Wrong schema version: treat as a miss, do not deserialize.
20 }
21 } catch (err) {
22 metrics.inc('cache', { prefix: prefixOf(key), result: 'error' })
23 log.warn({ key, err }, 'cache read failed, falling through')
24 }
25
26 metrics.inc('cache', { prefix: prefixOf(key), result: 'miss' })
27
28 // 2. Load. This one IS allowed to throw — it is the real work.
29 const value = await loader()
30
31 // 3. Populate. A write failure must not lose the value we already have.
32 const ttl = value === null ? (opts.negativeTtlSeconds ?? 10) : ttlSeconds
33 try {
34 await cache.set(key, JSON.stringify({ v: VERSION, data: value } satisfies Envelope<T>), ttl)
35 } catch (err) {
36 log.warn({ key, err }, 'cache populate failed')
37 }
38 return value
39}

Three separate failure boundaries, a distinguishable negative entry, and a version tag that makes an old value a miss rather than a wrong-shaped object. What it still does not do is coalesce concurrent misses — that is Cache Stampede.

Invalidate on write; do not update on write

The instinct after a write is to refresh the cache with the new value so the next reader gets a hit. It seems strictly better: same number of round trips, no cold key. It is not better, and the reason is ordering.

Two concurrent writers each produce a value and each push it to the cache. The database serializes their writes; the cache does not. Whichever SET arrives last wins, and there is no relationship between that order and commit order. Delete-on-write has the same race but a benign outcome: whichever delete lands last, the next read reloads from the database and gets the committed truth.

After UPDATE products SET title = ...
Update the cache with the new value
await db.update(product)
await cache.set(key(product.id), serialize(product), 300)
// writer A commits v1, writer B commits v2,
// B's SET lands first, A's SET lands second
// -> cache holds v1 for the next 300 seconds
Delete the key and let the next read reload
await db.update(product)
await cache.del(key(product.id))
// whichever DEL lands last, the key is empty
// -> next read loads the committed row

The cache has no way to order two concurrent writes, and last-write-wins on the cache is not last-write-wins on the database. Deletion is order-independent: any ordering of two deletes leaves the same (empty) state, so the next read is authoritative. The cost is one guaranteed miss after every write.

How to build it

Most important first.

  • Write the pattern once, as a getOrLoad(key, ttl, loader) helper, and use it everywhere. Open-coded cache-aside is how key formats drift and how the error handling ends up different at every call site.
  • Treat cache failures as misses, not as errors. A cache read that throws should log and fall through to the loader; a cache write that throws should log and return the value anyway.
  • Set the TTL at the call site, not in the helper. TTL is a per-data-type staleness decision and there is no sensible default (TTL and Expiry).
  • Cache negative results with a much shorter TTL than positive ones, using an explicit sentinel so "absent" and "known absent" are different states.
  • On write, invalidate rather than update. Updating the cache from the writer re-introduces the interleaving problem in a harder form, because now two writers race to populate (Cache Invalidation).
  • Serialize to a versioned envelope — { v: 3, data: ... } — so a deploy that changes the value shape treats old entries as misses instead of as the new type.

What can go wrong

Failure modes
  • Stale populate: reader loads at T0, writer commits and invalidates at T1, reader writes its T0 value at T2. The cache now holds pre-write data until the TTL expires.
  • Cache write failure swallowed, so hit rate is zero and everything still works — just at full database load, with no alert unless you monitor hit rate (A 95% Hit Rate Tells You Almost Nothing).
  • A miss storm on deploy, restart or failover, when a cold cache means every request takes the slow arm simultaneously.
  • The loader itself throws and the exception path caches nothing, so a transient database error produces a burst of retries against an already-struggling database (Retry Storms).
  • Storing an entity graph that includes a lazily-loaded relation, which serializes as a proxy or triggers a query during serialization (The N+1 Query Problem).
What can race
  • Read-load-populate versus concurrent write: the classic stale-populate interleaving described above. TTL bounds it; it does not prevent it.
  • Two concurrent misses on the same key both run the loader. Wasteful on a cheap loader, an outage on an expensive one (Cache Stampede).
  • Invalidation arriving before the populate it was meant to cancel — the delete lands on an empty key and the stale populate follows it.
  • Two writers, two invalidations, two repopulations, interleaved so that the older value is written last (Optimistic Concurrency).
Security
  • The key must contain every input that changes who is allowed to see the value: tenant id, user id, role, locale, feature-flag variant. A key derived only from the resource id serves one tenant's data to another (Multi-Tenancy).
  • Do the authorization check *before* the cache lookup, on the caller's identity — not by trusting that the value in the cache was authorized when it was written (Authorization in Backends).
  • Cache penetration: an attacker requests millions of ids that do not exist, none of which are cacheable under a naive implementation, and each one costs a database query. Negative caching plus a membership filter is the mitigation (Bloom Filter).
  • Never build keys from unsanitised user input without bounding the length and the character set. Unbounded keys are a memory-exhaustion path against the cache itself.
Misreads
  • "Cache-aside is atomic." Nothing in the four steps is atomic together. The gap between the load and the populate is where stale data enters (Cache Invalidation).
  • "A cache miss is just slower." A miss is the *uncached* cost plus the cache round trip plus the populate. Under concurrency, simultaneous misses are the dangerous case, not the slow one.
  • "I should update the cache when I write, to keep it warm." That converts a read race into a write race and is usually worse. Invalidate; let the next read repopulate (Cache Invalidation).
  • "Falling back to the database when Redis is down means we are resilient." It means the database now receives 100% of read traffic instantly. That is a capacity question, not a resilience property (When Not to Cache).

Operating it

How you see it in production
  • Emit one counter with a result label — hit, miss, error — per key prefix. Three counters or a hit-rate gauge alone will not let you see a cache that is failing every write.
  • Time the loader separately from the cache lookup. The pair tells you the real cost of a miss, which is the number that matters when hit rate drops.
  • Log at populate time with the key and the TTL, sampled. When someone asks why a value is stale, the populate log line with a timestamp answers it in one query (Structured Logging).
  • A span for the lookup and a span for the loader, so a trace shows which arm the request took (Trace, Span, Attribute, Status).
What changes at 10x and 100x
  • At 10x reads, cache-aside is exactly what you want: hit rate rises and the database sees roughly the same load it saw before.
  • At 10x reads on a *cold* key, the miss path becomes the problem rather than the hit path, and you need coalescing (Request Coalescing).
  • At 100x, the per-request cache round trip itself becomes a resource: connections to the cache are pooled too, and a cache client with a small pool becomes the new bottleneck (Connection Pools).
  • Value size matters more as traffic grows. Caching a 2 MB serialized object means the network and the deserialization cost dominate; at that point cache smaller, more specific values.
What this costs
  • Cache-aside puts four steps and their error handling in application code. Read-through hides them, at the cost of a cache layer that must know how to load your data.
  • Invalidate-on-write is simpler and safer than update-on-write, but it guarantees the next reader takes a miss — you trade a small consistency risk for a predictable latency spike after every write.
  • Negative caching costs memory proportional to the junk requested and introduces a window where a newly created record reads as missing.
  • The versioned envelope costs a few bytes per entry and one more branch, and it removes an entire class of deploy-day incidents.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe read/miss/load/populate shape is independent of language and cache product.
  • SIMPLIFIEDPresents one key holding one value. Real systems cache lists, fragments and dependency trees, where invalidating one row must invalidate every list that contained it — a materially harder problem this lesson does not solve.
  • FRAMEWORK-SPECIFICORM-level second-level caches (Hibernate, some Django and Rails setups) implement read-through and invalidate on their own writes — but only for writes that go through the ORM. A raw SQL update or a migration bypasses them entirely, which application-level cache-aside makes visible instead of hiding.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — read-your-writes and monotonic-read consistency, which is the formal name for the guarantee a cached read quietly gives up.