Redis cache patterns compared: cache-aside, read-through, write-through, write-behind and refresh-ahead
Each Redis cache pattern with code, guarantees and failure modes, then the stale-set race, why delete beats update, versioned writes, outbox and CDC invalidation, and what to use for which data.
The five Redis cache patterns differ in two things: who loads data into the cache (the application, or a cache layer), and when (on a read miss, on a write, or ahead of expiry). Cache-aside with delete-on-write is the right default for most data. Read-through is the same thing behind an interface, write-through keeps the cache warm for data that is read right after it is written, write-behind trades durability for write speed, and refresh-ahead removes miss latency on hot keys. None of them makes the cache consistent with the database. They only change how large the window of inconsistency is and what happens when a step fails.
Small when it works; until TTL if the cache write fails
Concurrent writers leave the older value
Write-behind
Write path; database updated later
On every write
The cache is ahead of the database
Acknowledged writes lost before the flush
Refresh-ahead
Background task
Before the TTL runs out
Up to one TTL, as with any TTL cache
Refresh fails silently; cold keys are not helped
The examples use redis-py and assume r = redis.Redis(decode_responses=True), a load_product(product_id) function that queries the database and returns a dict or None, and .
What to cache, string vs hash, key naming, how TTLs and expiry work, maxmemory and every eviction policy, a working cache-aside example, and the commands to measure and secure a Redis cache.
Read:GET the key. On a hit, return it. On a miss, query the database, SET the result with a TTL, return it. Write: commit to the database, then DEL the key.
defget_product(product_id):
key = f"shop:product:v1:{product_id}"
raw = r.get(key)
if raw isnotNone:
return json.loads(raw)
product = load_product(product_id)
if product isnotNone:
r.set(key, json.dumps(product), ex=300 + random.randint(0, 30))
return product
defupdate_price(product_id, price_cents):
save_price(product_id, price_cents) # database commit
r.delete(f"shop:product:v1:{product_id}")
Guarantees: only requested data is cached, and a Redis outage degrades to reading the database. There is no guarantee that the cache matches the database. Staleness is bounded by the TTL and is usually far shorter.
Failure modes: the first read after every invalidation pays the full query cost, concurrent misses on a hot key all run that query, the DEL can fail after the commit, and a reader can write back an old value after the DEL. The last two are covered below.
Read-through
The sequence is identical to cache-aside, but the application asks a cache object for the value and that object calls the loader. Redis has no server-side hook that queries your database, so with Redis read-through is always a library or a class in your own process:
classReadThroughCache:
def__init__(self, client, prefix, loader, ttl=300):
self.r, self.prefix, self.loader, self.ttl = client, prefix, loader, ttl
defget(self, entity_id):
key = f"{self.prefix}:{entity_id}"
raw = self.r.get(key)
if raw isnotNone:
return json.loads(raw)
value = self.loader(entity_id) # the cache layer calls the databaseif value isnotNone:
self.r.set(key, json.dumps(value),
ex=self.ttl + random.randint(0, self.ttl // 10))
return value
definvalidate(self, entity_id):
self.r.delete(f"{self.prefix}:{entity_id}")
products = ReadThroughCache(r, "shop:product:v1", load_product)
Guarantees: exactly those of cache-aside. What you gain is one place for key naming, TTLs, metrics, error handling and stampede protection.
Failure modes: the same races, plus an organizational one: callers stop seeing that a database query hides behind get(), and call it in loops. Give the wrapper a batch method.
Write-through
Write: commit to the database, then SET the new value in the cache. Read: as cache-aside, and nearly always a hit.
Guarantees: a read that follows a successful write sees the new value without a miss. The two writes are not atomic, because they go to two systems.
Failure modes:
Two writers can commit in one order and reach the cache in the other, leaving the older value cached until the TTL. See "Why delete beats update".
If the SET fails after the commit, the cache keeps the old value. A failed write-through SET must fall back to DEL, and the entry still needs a TTL.
Every write is cached whether or not anyone reads it.
Write-behind
Write: update the cache and append the change to a queue in Redis, then acknowledge. Later: a worker drains the queue into the database. A Redis stream with a consumer group is a suitable queue:
STREAM = "wb:cart"defsave_cart(user_id, cart):
payload = json.dumps(cart)
pipe = r.pipeline(transaction=True) # MULTI/EXEC: both or neither
pipe.set(f"cart:{user_id}", payload)
pipe.xadd(STREAM, {"user_id": user_id, "cart": payload})
pipe.execute()
defflush_carts(consumer="worker-1"):
try:
r.xgroup_create(STREAM, "db-writers", id="0", mkstream=True)
except redis.ResponseError as e:
if"BUSYGROUP"notinstr(e): # the group already existsraise
batches = r.xreadgroup("db-writers", consumer, {STREAM: ">"}, count=100, block=1000)
for _stream, entries in batches or []:
for entry_id, fields in entries:
upsert_cart(fields["user_id"], fields["cart"]) # must be idempotent
r.xack(STREAM, "db-writers", entry_id)
r.xdel(STREAM, entry_id)
Guarantees: fast writes, and many changes to one row can collapse into one database write. Until the flush, Redis holds the only copy.
Failure modes:
This instance is no longer a cache. It needs maxmemory-policy noeviction and AOF persistence. Under an allkeys-* policy, Redis can evict the stream itself.
Replication is asynchronous, so a failover can lose writes that were acknowledged to the client.
The database can reject a write (constraint violation, deadlock) after the user was told it succeeded. You need a dead-letter path and a way to tell someone.
A worker that crashes between the upsert and XACK leaves the entry pending. Reclaim it with XAUTOCLAIM. It will be applied twice, hence the idempotent upsert.
Other readers of the database see old data until the flush.
Use write-behind where losing the last few seconds is acceptable (view counters, last-seen timestamps, carts), never for orders or payments.
Refresh-ahead
Read: return the cached value. If its remaining TTL is below a threshold, also start a background reload, so the key is rewritten before it expires and readers never see the miss.
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=4)
defrefresh(key, loader, ttl):
try:
r.set(key, json.dumps(loader()), ex=ttl)
finally:
r.delete(f"refreshing:{key}")
defget_refresh_ahead(key, loader, ttl=300, refresh_below=60):
pipe = r.pipeline(transaction=False)
pipe.get(key)
pipe.ttl(key)
raw, remaining = pipe.execute()
if raw isNone: # cold: load inline
value = loader()
r.set(key, json.dumps(value), ex=ttl)
return value
if0 <= remaining < refresh_below:
# one refresher per key; the marker expires if the worker diesif r.set(f"refreshing:{key}", "1", nx=True, ex=30):
executor.submit(refresh, key, loader, ttl)
return json.loads(raw)
Guarantees: keys that are read at least once per refresh window stay warm, so the expensive rebuild happens off the request path and once per key. Staleness is the same as a plain TTL cache.
Failure modes: a key nobody reads inside the window still expires and the next read is a normal miss. If the loader fails, the old value is served until its TTL and then misses, so count refresh failures. A refresh also races with invalidation exactly as a cache-aside reader does.
The stale-set race in cache-aside
Delete-on-write looks safe and is not quite. Reader R and writer W interleave like this:
R: GET shop:product:v1:42 returns nil.
R: reads the row from the database and gets price 3499.
W: updates the price to 2999 and commits.
W: DEL shop:product:v1:42. The key does not exist, so nothing happens.
R: SET shop:product:v1:42 with price 3499.
The cache now holds 3499, the database 2999, and no further invalidation is coming. The entry stays wrong for its whole TTL.
The race needs R's gap between its database read and its SET to span W's commit and delete. On one fast primary that gap is very short. Three things widen it in production: a pause in the reader (garbage collection, a slow network hop, a busy event loop), a slow loader that builds the value from several queries, and read replicas. With replicas the reader does not even have to start early: it can miss after W's DEL, read a replica that has not yet applied W's commit, and cache the old row.
Why delete beats update
Suppose the write path updates the cache instead of deleting. Writers A and B change the same row:
A commits price 10.
B commits price 20. The database says 20.
B: SET price 20.
A: SET price 10. The cache says 10.
Nothing orders the two cache writes the way the database ordered the commits. With deletes, both orders end in the same state: the key is absent and the next reader loads 20. Deletes are idempotent and commutative, so retries and duplicates are harmless. The price is one miss per write, which is why write-through is tempting, and why it needs versioning to be correct under concurrent writers.
Deleting before the commit does not help. A reader that misses between the delete and the commit caches the old value, and no delete follows.
Narrowing or closing the race
Delayed double delete. Delete after the commit, then delete again a moment later to remove a stale value written in between:
The delay must exceed the longest reader gap you expect, including replica lag. It narrows the window and guarantees nothing: a reader slower than the delay still wins, and the timer is lost if the process dies. A durable delayed job is better than this in-process timer.
Versioned writes. The real fix is to make the cache reject old data. Give each row a version that only increases (a counter incremented in the same UPDATE, or a commit sequence number), store it with the cached value, and make the cache write conditional in a Lua script, which Redis runs atomically:
SET_IF_NEWER = r.register_script("""
local current = redis.call('HGET', KEYS[1], 'ver')
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0
end
redis.call('HSET', KEYS[1], 'ver', ARGV[1], 'data', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1
""")
defcache_put(product, ttl=300):
return SET_IF_NEWER(keys=[f"shop:product:v2:{product['id']}"],
args=[product["version"], json.dumps(product), ttl])
With this in place the write path calls cache_put with the committed row instead of deleting, and readers use the same function on a miss. A late reader carrying version 7 cannot overwrite version 8, and two writers cannot leave the older value. Note that a plain DEL discards the stored version, so the stale-set race returns if you mix deletes with versioned puts. Either always put, or have the delete path write a tombstone that carries the version.
Versioned keys. If the version is known before the read (a per-tenant generation, or an updated_at you already hold), put it in the key name: shop:product:v1:42:8. Nothing ever overwrites an entry, old versions simply stop being requested and expire, and there is no race. This only works when readers can learn the current version cheaply.
Invalidation from the database log
Invalidation in application code misses every write that bypasses that code: another service, an admin script, a migration, a manual UPDATE. Two designs move invalidation next to the data.
Transactional outbox. In the same transaction as the change, insert a row into an outbox table naming the key to invalidate. A relay process reads the table, deletes the keys and marks the rows done. The invalidation is committed if and only if the change is, and it is retried until Redis accepts it.
Change data capture. A consumer reads the database's replication log (PostgreSQL logical decoding, the MySQL binlog), usually through a tool such as Debezium, and deletes the keys for changed rows. It sees every committed change whatever its source, in commit order per row.
Both deliver at least once, which is fine because deletes are idempotent. Both are asynchronous, so the cache lags the commit by the pipeline delay, and a user who writes and immediately reads may see the old value. Handle that in the application: after a write, keep the same delete in the write path as a fast first attempt, or read that entity from the primary for a few seconds.
When the cache write fails after the commit
The database commit succeeded, and the DEL or SET raised a timeout. The row is new and the cache is old. The options, from weakest to strongest:
Rely on the TTL. This is acceptable only if the TTL is the staleness you promised.
Retry in process with a short backoff. It covers blips, not outages or a process that dies.
Enqueue the invalidation durably in the outbox, so a relay retries it for as long as needed.
Do not depend on the application at all and let CDC issue the delete.
Do not roll back the database transaction because the cache was unreachable. That turns a cache outage into a write outage. A timeout is also ambiguous: the command may have been applied. Idempotent deletes and versioned puts are safe to retry, which is another reason to prefer them.
Which pattern for which data
Data
Pattern
Why
Entities read much more than written (products, profiles, articles)
Cache-aside or read-through, delete on write
Simple, self-healing, bounded by TTL
Data read immediately after it is written (a just-saved profile, session state)
Write-through with versioned puts
No miss after the write
Small, hot reference data (feature flags, configuration, price lists)
Money, inventory at the point of sale, permissions being revoked
Read the database, or cache with versioned writes and a short TTL
Stale reads are correctness bugs
Common mistakes
Updating the cache from the write path without a version. It works in every test and loses to concurrency in production.
No TTL because "we invalidate". Every invalidation mechanism in this article can drop a delete. The TTL is the bound on that.
Filling the cache from a lagging replica right after an invalidation.
Write-behind on an instance with an eviction policy or without AOF.
Invalidating one key when several contain the row. A product appears in its own key, in category lists and in search results. Track those dependencies or give derived keys short TTLs.
Rolling back the transaction when Redis times out.
Trimming a write-behind stream with MAXLEN to save memory. That deletes unflushed writes. Alert on stream length and pending entries instead.
Checklist
Start with cache-aside or read-through, delete after commit, and a jittered TTL on every key.
Decide the staleness each data type tolerates, and set the TTL to that.
If misses read from replicas, account for lag: read from the primary on a miss, or use versioned puts.
Use write-through only with a version check, and fall back to delete when the put fails.
Use write-behind only for loss-tolerant data, on a noeviction instance with AOF, with idempotent upserts and monitoring on the pending list.
Add refresh-ahead to hot keys whose misses are expensive, and count refresh failures.
Make invalidation durable with an outbox or CDC where staleness after a failed delete is not acceptable.
Test the races deliberately: pause a reader between its database read and its cache write, and check what the cache holds afterwards.
References
Key eviction (Redis docs): why a write-behind queue must not share an instance with an eviction policy
Writes fail and reads still work. Understand Redis eviction policies, find what's filling memory, and pick the right fix depending on whether Redis is a cache or a datastore.