A caching strategy is the rule for how data gets into a cache and what happens to the cached copy when the data changes. Read patterns (cache-aside, read-through, refresh-ahead) decide who fills the cache on a miss; write patterns (write-through, write-behind, write-around) decide whether a write updates the cache, skips it, or lands in the cache first. Most production systems run cache-aside with delete after commit, and most of the subtlety lives in the few milliseconds where a read and a write overlap.
Picture it first: the canteen chalkboard
A college canteen keeps its official price register in the manager’s office upstairs. At the counter, a chalkboard shows the prices people ask about most. Reading the board takes a second; climbing the stairs takes two minutes.
- The cashier climbs the stairs when a price is missing, and chalks it up for the next customer: cache-aside.
- A board-keeper does the climbing, so cashiers only read the board: read-through. If the keeper re-checks popular lines before the chalk fades, that is refresh-ahead.
- When a price changes, the manager can update register and board before announcing it (write-through), update the board now and the register at closing time (write-behind: if the cleaner wipes the board at 5 pm, today’s change is gone), or update the register and rub the line off the board (write-around with invalidation).
Now the trap. At 10:00 a slow cashier goes upstairs and reads the samosa price: ₹15. At 10:01 the manager raises it to ₹18 and rubs the line off. At 10:02 the slow cashier returns and chalks up ₹15, which stays wrong until the evening wipe.
Map it back: the register is the database, the board the cache, the climb a miss, the evening wipe the TTL, and the slow cashier the stale-set race at the heart of this article.
Read patterns: cache-aside, read-through, refresh-ahead
Every strategy pairs a read pattern, which decides who fills the cache on a miss, with a write pattern, which decides what a write does to the cached copy. The common pairs are cache-aside with delete-on-write (Facebook’s memcache, most Redis setups) and read-through with write-through (inline caches such as DynamoDB Accelerator, DAX). Whether caching pays off at all is the hit-ratio question in Caching 101.
Cache-aside (lazy loading)
The application owns the logic: ask the cache, and on a miss read the database and store the result with a TTL. Facebook calls its memcache a “demand-filled look-aside cache”; AWS calls it lazy loading.
You gain three things: only requested data takes memory, the cache can be a plain key-value store, and an empty or failed cache node isn’t fatal, because every caller already knows how to read the database. You pay with a three-trip miss (cache GET, database read, cache SET), logic that every reading service must repeat correctly, and no freshness from the read path itself: that is the write path’s job. Filling asynchronously, as Uber’s CacheFront does, takes the SET off the user’s critical path.
Read-through
Read-through moves the same logic behind the cache’s API. You register a loader (Oracle Coherence calls it a CacheStore); the application only calls get(k), and on a miss the cache loads, stores and returns the value. DAX does this for DynamoDB, a caching reverse proxy for an origin server. Uber’s CacheFront is cache-aside inside the Docstore query engine, but read-through to the services calling Docstore, which never see the cache.
You gain one code path instead of one per service, and a cache that can coalesce concurrent misses for a key into one database query (see hot keys and cache stampedes). You pay in availability: Amazon’s Builders’ Library notes that if an inline caching fleet goes down, it is as if the dependency itself went down, with no chance for the client to compensate. You also need a loader for your data source, and a less visible code path when debugging.
Refresh-ahead
Refresh-ahead reloads a hot entry before it expires. Coherence expresses it as a fraction of the TTL: with a 60-second expiry and a refresh-ahead factor of 0.5, reading an entry older than 30 seconds returns it at once and schedules a background reload, while a read after 60 seconds is an ordinary miss. Memcached’s meta protocol offers a client-side version: a get with the R flag lets exactly one client “win” the right to recache an item whose remaining TTL is below a threshold.
You gain steady latency on hot keys, because their expiry never lands on a user. You pay with database reads for keys that weren’t needed again; Coherence warns that poor predictions cost throughput and can even raise latency if the database falls behind. And it does nothing for freshness after a write. Use it for a known set of hot, read-mostly keys: a sale’s top few thousand products, configuration, a live score page.
Write patterns: write-through, write-behind, write-around
Write-through
A write goes to the database and the cache in one synchronous operation, and the caller is acknowledged only when both have it. In an inline cache, the cache layer does the work: DAX sends the write to DynamoDB, waits for success, updates its item cache and only then replies; if the database write fails, nothing is cached. In application code, you commit and then SET the new value.
You gain fresh reads: the next read after a write is a hit with the new value. You pay on every write, which waits for both stores (a 5 ms database write plus a 0.5 ms cache call is about 5.5 ms). The cache also fills with values nobody may read (AWS calls it cache churn), and a restarted node stays empty until keys are rewritten, so you still need lazy loading and TTLs. And write-through covers only writes that go through it: DAX warns that an item updated directly in DynamoDB stays stale in DAX until its TTL expires.
Write-behind (write-back)
The write lands in the cache and the caller is acknowledged at once; the cache flushes to the database later. Coherence lets you set the delay from seconds to days, and lists the upside: nobody waits for the database, repeated changes to a key within the delay are coalesced into one write, and writes to many keys are combined into one batch. A view counter during an IPL final taking 20,000 increments a second, flushed every 5 seconds, sends one UPDATE every 5 seconds instead of 20,000 × 5 = 100,000.
The price is durability. Until the flush, the cache holds the only copy, so a node that dies loses up to one flush interval: for that counter, up to 100,000 increments. Redis persistence narrows the gap without closing it: with appendfsync everysec you can lose about a second of writes, and asynchronous replication means a failover can drop acknowledged ones. Coherence adds the rest: database writes happen outside the cache transaction, so they must never fail or you must handle rollbacks; updates can reach the database out of order; other writers to the same rows can conflict; and the business must accept “cluster-durable” data.
Write-around
The write goes straight to the database and skips the cache. On its own that leaves any cached copy stale until its TTL, so in practice write-around comes with a delete of the key, which is exactly what cache-aside does on a write. You gain a cache that isn’t flooded with data written once and rarely read; DAX recommends writing around it for bulk loads for that reason. You pay with a miss on the first read after every write.
Names vary between vendors
Some write-ups use “write-around” without the delete, and some call app-side “commit, then SET” write-through, while Coherence and DAX mean the cache layer writing the database. In an interview, describe the steps and when the caller is acknowledged.
Delete, don’t update, on a write
With cache-aside a write can SET the cache to the new value or DELETE the key and let the next reader fill it. Facebook chose delete “because deletes are idempotent”, and the reason is obvious once two writers overlap.
Two admins edit the same product during a sale. W1 sets the price to ₹499 and is then paused (a garbage-collection pause, a slow hop, a retry). W2 sets ₹449 and updates the cache. W1 wakes up and writes ₹499 into the cache. The database says ₹449, the cache says ₹499, and nothing raises an error.
Deleting has two more things going for it:
- No wasted work. Facebook found that only 4% of the deletes it broadcast hit a cached item at all; setting values instead would mostly have cached data nobody had asked for.
- Safe retries. A retried delete is harmless; a retried SET can overwrite a newer value.
The cost is a miss after every write, which on a very hot key can send a herd of readers to the database at once; leases and request coalescing handle that. Updating is fine only when updates can’t be reordered: one writer per key, or a version on every value so the cache refuses older ones.
The order matters as much as the verb: commit first, then delete. Delete first and a reader can miss before your commit, read the old row and put it back, a window as long as your whole transaction, as the Azure Architecture Center warns. Here is cache-aside in the right order:
TTL_S = 600 # the backstop: no stale value outlives this
def get_product(pid):
key = f"product:v2:{pid}"
cached = cache.get(key)
if cached is not None:
return decode(cached) # hit
row = db.query_one("SELECT * FROM products WHERE id = %s", pid)
cache.set(key, encode(row), ex=TTL_S) # fill on a miss
return row
def update_price(pid, price):
with db.transaction() as tx: # 1. change the source of truth
tx.execute("UPDATE products SET price = %s WHERE id = %s", price, pid)
# 2. the transaction has committed; only now drop the cached copy
try:
cache.delete(f"product:v2:{pid}")
except CacheError:
log.warning("invalidation failed for %s", pid) # CDC and the TTL cover this
Don’t delete inside the transaction either
Deleting before COMMIT, inside the transaction, is the same mistake: other readers see the old row until the commit lands, and one of them can re-cache it. Delete only after the commit has returned.
The stale-set race, and how to close it
Delete-after-commit still leaves one classic race. A reader misses and reads the row before the writer commits; the writer commits and deletes; then the slow reader stores the old row it was holding. The delete was on time; the fill simply outlived it. Facebook’s memcache paper calls this a stale set, and Meta’s 2022 write-up on cache consistency opens with the same interleaving: a fill carrying x = 42 lands after the invalidation for x = 43, and the cache keeps 42 indefinitely.
How often does it bite? Rarely per request, steadily across a fleet. Suppose, as an illustration, that 1 write in 10,000 loses this race at 5,000 writes a second: 0.5 new stale entries a second. Each lives until its TTL, so at any moment you serve rate × lifetime of them: 0.5 × 3,600 = 1,800 stale keys with a one-hour TTL, or 0.5 × 60 = 30 with a one-minute TTL. The TTL bounds the damage; it doesn’t prevent it.
Leases
Facebook’s fix is the lease. On a miss, memcached hands the client a 64-bit token bound to that key; the client must present it to set the value, and a delete for the key voids it, so a fill that started before a write cannot land after it. The same mechanism tames herds: each server issues at most one token per key every 10 seconds and tells other clients to wait briefly, which cut the peak database query rate for a set of herd-prone keys from 17K/s to 1.3K/s.
Open-source memcached exposes the pieces in its meta commands: a get with N tells exactly one client it has “won” the right to fill a missing key, a delete with I marks an item stale and bumps its CAS value, and a set with C succeeds only if the CAS still matches. With Redis you build it from SET … NX and a short Lua script:
import time, uuid
LEASE_MS = 5_000
# KEYS[1] = value key, KEYS[2] = lease key; ARGV = token, value, ttl
SET_IF_LEASE_HELD = """
if redis.call('GET', KEYS[2]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
redis.call('DEL', KEYS[2])
return 1
end
return 0
"""
def keys_for(pid):
tag = "{product:%d}" % pid # same hash slot in Redis Cluster
return tag, tag + ":lease"
def get_product(pid, attempts=3):
key, lease_key = keys_for(pid)
cached = cache.get(key)
if cached is not None:
return decode(cached)
token = uuid.uuid4().hex
if not cache.set(lease_key, token, nx=True, px=LEASE_MS):
if attempts > 0: # someone else is filling it
time.sleep(0.02)
return get_product(pid, attempts - 1)
return db.get_product(pid) # give up waiting; don't fill
row = db.get_product(pid)
cache.eval(SET_IF_LEASE_HELD, 2, key, lease_key, token, encode(row), TTL_S)
return row
def update_price(pid, price):
db.update_price(pid, price) # commit first
cache.delete(*keys_for(pid)) # drops the value and voids any lease
The writer’s single DEL removes both keys, so a reader holding the old token finds its lease gone and its SET does nothing. The cost is an extra round trip per miss, brief waits for the losers, and a lease timeout to tune; a reader that stalls past LEASE_MS just loses its lease, which is the safe direction.
Versioned and compare-and-set writes
The other family stores a version with every value (a row version, an update counter, the commit timestamp) and accepts a write only if it is newer. Uber’s CacheFront fills Redis from both its read path and its invalidation path, so it writes through a Lua script that compares the row’s MySQL timestamp with the cached one and keeps the newer row. Versions also fix the reordered writers, because W1’s ₹499 carries an older version than W2’s ₹449. Memcached’s cas is the primitive form: “store this data but only if no one else has updated since I last fetched it.”
The catch: a version check needs something to compare against. A delete erases the newer version, so a stale fill arriving afterwards finds an empty slot and wins; Meta notes the same hole when the newer entry is evicted. Pair versions with upserts of the new value (as Uber’s CDC consumer can do) or a short-lived tombstone that remembers “deleted at version 8”.
Delayed double delete, and its limits
A popular low-tech fix: delete after commit, then delete again a few hundred milliseconds later, once any in-flight fill should have landed. It narrows the race without closing it:
- The delay is a guess. A reader stalled by a two-second GC pause or a lagging replica outlives it.
- Stale reads still happen between the stale SET and the second delete.
- It doubles invalidation traffic and adds a miss per write on hot keys.
- The second delete can fail too, and a timer in a crashed process is gone.
Use it only when leases and versions aren’t available.
A short TTL as the safety net
Every fix has a failure mode, so every key still gets a TTL, as short as your hit ratio allows and no shorter. Uber notes that lowering CacheFront’s default 5-minute TTL would have cut the hit rate without meaningfully improving consistency, which is why it invested in invalidation instead.
When the delete never happens: dual writes
The database and the cache share no transaction, so “commit, then delete” is a dual write. The commit can succeed and the delete fail: the cache node times out, the network blips, a deploy kills the process between the two lines. Retrying is safe because deletes are idempotent, but an in-memory retry dies with the process. The durable answer is to derive invalidations from something the database already records:
- Transactional outbox: write an “invalidate product:42” row in the same transaction as the update; a relay reads the outbox and deletes, retrying until it succeeds.
- Change data capture: tail the database’s own log (the MySQL binlog, or the PostgreSQL write-ahead log through logical decoding) and turn each committed row change into a cache delete or a versioned upsert.
Both large systems in this article ended up here. Facebook embeds the memcache keys to invalidate in its SQL statements; a daemon called mcsqueal on each database reads what committed and broadcasts the deletes, and because they sit in the database’s reliable log, lost ones can be replayed. Uber’s Flux tails the MySQL binlog, and a consumer invalidates or upserts rows in Redis. That made CacheFront consistent within seconds rather than minutes, covered conditional updates whose affected rows the write path couldn’t know, and kept uncommitted transactions out of the cache.
CDC pipelines usually deliver at least once, which is harmless for deletes (see idempotency). Keep the app-side delete too: CDC lags by a few seconds, and the direct delete is what shows a user their own write immediately.
Replicas and regions widen the window
If misses read from a replica, a delete right after the primary commits is premature: the next reader misses, reads a replica that hasn’t applied the write, and caches the old row. The memcache paper hits exactly this across regions and fixes it by running the invalidation daemon on each replica database, so a key is deleted only once that region’s copy has the change. A write from a non-primary region also sets a remote marker that sends misses on that key to the primary region until replication catches up. The lesson: invalidate from the log of the copy your misses read. More in distributed caching.
Measure staleness instead of assuming it
Both Meta and Uber treat cache consistency as a metric. Uber compares a sample of cached reads with the database and reports 99.99% consistency with binlog invalidation. Meta’s checker, Polaris, tracked TAO going from six to ten nines of cache writes consistent within five minutes. A sampled “read both, compare, emit a metric” job is cheap and catches the races you didn’t think of.
All six patterns side by side
| Pattern | Who loads the cache | Read on a miss | Write latency | Consistency | Durability risk | Complexity | Best for |
|---|---|---|---|---|---|---|---|
| Cache-aside | App code, on a miss | Cache GET + DB read + SET; first reader waits | DB write + DEL | Stale until the DEL lands; stale-set race | None: the DB is the truth | Low, but repeated in every caller | Read-heavy data; the default |
| Read-through | The cache’s loader, on a miss | Same trips, made by the cache; duplicate misses can coalesce | Set by the write pattern | Set by the write pattern | None | Medium: a loader or an inline product | Many services reading one source |
| Refresh-ahead | The cache, shortly before expiry | Hot keys rarely miss; cold keys as read-through | Set by the write pattern | Up to one TTL old | None | Medium; wasted reloads if guesses are poor | Predictable hot keys: config, top products |
| Write-through | Every write, plus misses | A hit right after a write | DB + cache, synchronous (≈ 5.5 ms) | Fresh for writes that go through it | None if the DB is written first | Medium | Read-after-write data: profiles, carts |
| Write-behind | Every write; the DB is flushed later | A hit | Cache only (≈ 0.5 ms) | The DB lags by the flush interval | High: unflushed writes are lost | High: queue, retries, ordering | Counters, likes, metrics |
| Write-around | Reads only | The first read after a write misses | DB only | Stale unless you also DEL | None | Low | Data written once, read rarely |
Cold starts and cache outages, pattern by pattern
The patterns differ most on bad days. Warming, and whether your database survives an empty cache, are covered in Caching 101; here is how each wiring behaves.
| Pattern | Cold start | Cache down: reads | Cache down: writes |
|---|---|---|---|
| Cache-aside | Every read misses until warm | App reads the DB, if the DB can carry it | DB writes succeed; DELs fail, so surviving entries may be stale |
| Read-through | Same, but duplicate misses can coalesce | Fail unless the client can route around the cache | Depends on the write pattern |
| Refresh-ahead | No help: it only refreshes cached entries | As read-through | Not applicable |
| Write-through | Only recently written keys present; add lazy loading | As read-through | Fail, or skip the cache and invalidate later |
| Write-behind | Replay unflushed writes before serving | Unflushed data is unreadable | Stop; a dead node’s unflushed writes are lost |
| Write-around | Reads warm it, as in cache-aside | As cache-aside | Unaffected |
The cache-aside row hides a trap. A node that was unreachable rather than restarted returns with its old entries, and the deletes sent to it meanwhile are gone. Flush such nodes, replay invalidations from a log, or rely on short TTLs.
Choosing by workload
Start from the cost of a stale read and the cost of a lost write; the pattern follows.
| Workload | Read pattern | Write pattern | Why | Watch out for |
|---|---|---|---|---|
| Product catalogue in a Big Billion Days-style sale | Cache-aside; refresh-ahead for top SKUs | Commit, DEL, plus CDC; TTL of minutes | Reads dwarf edits; a minute stale is fine | Hot SKUs stampeding after a price change |
| View and like counters during IPL streaming | Read the counter from Redis | Write-behind: INCR, flush every few seconds |
Thousands of writes become one | Losing one flush interval on a crash |
| User profile, where users must see their own edits | Cache-aside | Write-through for the owner, or DEL plus brief primary reads | Read-your-writes | A lagging replica re-caching the old row |
| Live fantasy-league leaderboard | A Redis sorted set serves rankings | Write-through: DB update plus ZINCRBY |
Ranking needs every score in memory | Rebuilding after losing Redis |
| Session store | Redis is the store, not a cache | Replicated writes; TTL equals session lifetime | Every request reads it | Eviction logs people out: use noeviction |
| Wallet balance before a UPI debit | None on the decision path | A database transaction | Stale means money lost | Cache only a display copy |
| Train seat availability while browsing | Cache-aside, TTL of seconds | Write-around with DEL | Thousands browse per booking | Booking re-checks the database |
| Audit logs and bulk imports | Rarely cached | Write-around | Written once, seldom read | Imports evicting the hot set |
In the interview
Name the pair and the safety net in one breath: “Cache-aside with delete-after-commit, leases on fills, CDC as the guaranteed invalidation path, and a ten-minute TTL as the backstop.” Then name the one thing you won’t cache: the value a payment or a booking decision depends on.
Cheat sheet
| Idea | Remember it as |
|---|---|
| Cache-aside | App reads the cache; on a miss reads the DB and SETs with a TTL |
| Read-through | The same logic inside the cache; a cache outage becomes a data outage |
| Refresh-ahead | Reload hot keys before expiry: factor 0.5 of a 60 s TTL refreshes after 30 s |
| Write-through | DB and cache in one synchronous write: fresh reads, slower writes |
| Write-behind | Ack from the cache, flush later; coalesces writes; can lose a flush interval |
| Write-around | Write the DB only, and DEL the key |
| On a write | Commit, then DEL. Never DEL first; prefer DEL to SET because it is idempotent and order-free |
| Stale-set race | Reader reads the old row, writer commits and DELs, reader SETs the old row |
| Fixes | Leases; version checks with upserts or tombstones; TTL backstop. Double delete only narrows it |
| Failed DEL | Retry it; outbox or CDC for a durable path; invalidate from the log your misses read |
| Never cache | The value a money or booking decision depends on |
How to say it in the interview
“For the catalogue I’d use cache-aside on Redis: read Redis, and on a miss read Postgres and set the row with a ten-minute TTL. On a write we commit first, then delete the key rather than update it, because two updates can land in the wrong order but two deletes can’t. Two gaps remain. A slow reader can put an old row back after our delete, so fills carry a lease the delete voids. And the delete can fail after the commit, so a CDC consumer tailing the WAL deletes the key again, safely, because deletes are idempotent. The TTL caps whatever slips through. View counters are write-behind; the wallet balance on the payment path isn’t cached at all.”
Delete or update the cache on write? Delete. Two concurrent updates can reach the cache in the opposite order to the database and leave a wrong value until the TTL; two deletes in any order leave the same empty key. Deletes are also safe to retry. The cost is a miss per write, so protect hot keys with leases or coalescing. Update only when writes to a key can’t be reordered: one writer, or versioned values.
What if the cache delete fails? Retry with backoff; it’s idempotent. But an in-memory retry dies with the process, so the guarantee must be durable: an outbox row in the same transaction, or CDC from the binlog or WAL, which replays until the delete lands. The TTL bounds the rest; a sampled consistency check tells you how often it happens.
When is write-behind acceptable? When losing the last flush interval is tolerable or recoverable: view counts, likes, metrics, presence, rebuildable game state. It also needs one writer for those rows and readers that accept a lagging database. Replication and AOF shrink the loss window. Never for orders, payments or bookings.
How do you get read-your-writes with a cache? Delete the key after the commit and before responding, and make sure the user’s next miss reads the primary rather than a lagging replica, for example by routing their reads to the primary for a few seconds after a write, much as Facebook’s remote markers do. For keys only that user writes, write-through also works. Uber’s CacheFront added a dedicated API for explicitly invalidating rows after a write for exactly this.
Which pattern for a product catalogue versus a payment balance? The catalogue tolerates a minute of staleness: cache-aside, delete after commit, CDC invalidation, a TTL of minutes, refresh-ahead for bestsellers. The balance is a decision input where staleness costs money: read it from the database inside the debit transaction. A display copy on the home screen can be cached, but it never authorises a payment.
Where to go next
- Caching 101: hit-ratio arithmetic, the cache hierarchy and TTLs.
- Hot keys, cache stampede and invalidation: herds, coalescing and invalidation strategies in depth.
- Cache eviction policies: what the cache drops when it’s full.
- Distributed caching: partitioning, replication and multi-region caches.
- Change data capture: how binlog and WAL tailing works.
- Transactional outbox: the other durable fix for dual writes.
- Strong vs eventual consistency: what a cached read can promise.
- Redis: Lua scripts,
SET NX, persistence and replication. - Scaling reads: caching next to replicas and denormalised views.
- Idempotency: why retryable operations make every fix here simpler.
References and further reading
- Scaling Memcache at Facebook (Nishtala et al., NSDI 2013)usenix.orgDemand-filled look-aside caching, delete instead of update because deletes are idempotent, leases against stale sets and herds (17K/s to 1.3K/s), mcsqueal invalidation from committed SQL, remote markers.
- Cache made consistent (Meta Engineering, 2022)engineering.fb.comThe fill-versus-invalidation race, why versions can be lost to eviction, and TAO’s consistency going from six to ten nines within five minutes.
- How Uber serves over 40 million reads per second from online storage using an integrated cache (Uber Engineering)uber.comCacheFront: cache-aside inside the query engine, binlog-driven invalidation (Flux), timestamp-versioned writes via a Lua script, an explicit invalidation API for reading your own writes, 99.99% measured consistency.
- Caching challenges and strategies (Amazon Builders’ Library)aws.amazon.comInline (read-through/write-through) versus side caches, and why an inline cache becomes part of the dependency’s availability.
- Cache-Aside pattern (Azure Architecture Center)learn.microsoft.comUpdate the store before removing the cached item, and the stale window opened by doing it the other way round.
- Caching strategies: lazy loading, write-through and TTL (Amazon ElastiCache documentation)docs.aws.amazon.comThe three trips of a lazy-loading miss, write-through’s write penalty, missing data on new nodes, and cache churn.
- Oracle Coherence: Caching Data Sourcesdocs.oracle.comFormal read-through, write-through, write-behind and refresh-ahead semantics: write coalescing and combining, out-of-order updates, cluster-durable data, the refresh-ahead factor.
- DAX and DynamoDB consistency models (Amazon DynamoDB Developer Guide)docs.aws.amazon.comA real write-through cache: write order, writes that bypass the cache staying stale until TTL, and write-around for bulk loads.
- Memcached protocol (doc/protocol.txt)github.comThe cas check-and-set command, and meta commands that let one client “win” a recache and mark items stale with a bumped CAS.
- Redis persistenceredis.ioAOF with fsync every second can lose about one second of writes: the durability floor under a write-behind cache.