Cache-Aside Pattern
The cache-aside pattern (also called lazy loading) is the most common way applications use Redis alongside a primary database. Instead of keeping Redis and the database perfectly in sync automatically, your application code checks Redis first on every read; if the value is missing, it reads from the database, stores a copy in Redis with an expiration, and returns it. On writes, the application updates the database and then removes the corresponding Redis key so the next read repopulates the cache with fresh data. It’s called “aside” because the cache sits beside your data-access code rather than being transparently managed by Redis itself — your application is fully responsible for keeping it correct.
Overview / How it works
Cache-aside is not a single Redis command — it’s an access pattern built out of ordinary commands (GET, SET, DEL, EXPIRE). The read path looks like this:
- The application calls
GETon the cache key. - If the reply is a value, that’s a cache hit — return it immediately. The database was never touched.
- If the reply is
(nil), that’s a cache miss — query the database, then callSETwith an expiration (EX seconds) to populate the cache for next time, then return the value.
The write path is simpler and deliberately conservative: write to the database first (the database is always the source of truth), then DEL the cache key rather than trying to update it in place. Deleting is safer than overwriting, because if the database write and the cache write are ever reordered by concurrent requests, an in-place cache update can leave stale data sitting in Redis indefinitely. A deleted key just causes the next reader to take the miss path and repopulate it correctly.
Two Redis internals make this pattern work well. First, Redis is single-threaded for command execution — each individual command (a single GET, a single SET) runs to completion with no other command interleaving, so you never see a half-written value. This does not make your whole cache-aside sequence atomic (the check-then-set is still two round trips from the application’s point of view), but it does mean each Redis call itself is safe from corruption. Second, key expiration is enforced two ways: lazily, when a client accesses an expired key Redis notices the TTL has passed and deletes it before replying (so you’ll get (nil) instead of stale data); and actively, a background cycle periodically samples keys with TTLs and evicts ones that have expired, so memory isn’t wasted holding expired keys nobody ever reads again. This is exactly why a TTL is central to cache-aside: it’s your safety net against ever-growing staleness, even if your application forgets to invalidate a key on write.
Syntax
The pattern combines these commands. There’s no dedicated “cache-aside” command — this is the general shape your application code follows:
GET <key>
# if reply is (nil):
# value = read from the database
SET <key> <value> EX <ttl-seconds>
# return value to the caller
#
# on write:
# 1. write <value> to the database
DEL <key>
# next GET on <key> will miss and repopulate
| Command | Purpose | Time complexity |
|---|---|---|
GET key |
Read the cached value | O(1) |
SET key value EX seconds |
Populate the cache with an expiration | O(1) |
SET key value KEEPTTL |
Overwrite a value without disturbing its existing TTL | O(1) |
DEL key |
Invalidate a cached key after a write | O(1) per key removed |
EXPIRE key seconds |
Attach a TTL to an existing key | O(1) |
TTL key |
Check remaining seconds (-1 = no TTL, -2 = key doesn’t exist) |
O(1) |
SCAN cursor MATCH pattern |
Non-blocking iteration to find cache keys | O(1) per call, O(N) to fully iterate |
Examples
Example 1: Basic cache miss, then a hit
The first read finds nothing, so the application would normally query the database; here we simulate that by populating the cache ourselves with a five-minute expiration.
GET user:501:profile
SET user:501:profile "Ada Lovelace,Mathematician and writer" EX 300
GET user:501:profile
TTL user:501:profile
Output:
(nil)
OK
"Ada Lovelace,Mathematician and writer"
(integer) 300
The first GET returns (nil) — a miss. The application would fetch the real profile from its database at this point; we simulate that by writing it straight into Redis with EX 300, giving it a 300-second lifetime. The second GET is now a hit, and TTL confirms the countdown is running.
Example 2: Caching a structured record with a hash
Real records usually have multiple fields. A Redis hash lets you cache them as one object instead of serializing to a string yourself.
HSET product:2001 name "Mechanical Keyboard" price "89.99" stock "42"
EXPIRE product:2001 600
HGETALL product:2001
TTL product:2001
Output:
(integer) 3
(integer) 1
1) "name"
2) "Mechanical Keyboard"
3) "price"
4) "89.99"
5) "stock"
6) "42"
(integer) 600
HSET creates the hash and reports 3 new fields. EXPIRE attaches a TTL after the fact (useful when the hash is built across several commands). HGETALL returns the whole record as a flat field/value array, and TTL confirms the 600-second expiration is active — this is the same lazy/active expiration mechanism as any other key, hashes aren’t special.
Example 3: Invalidating the cache after a database write
On the write path, the safe move is to update the database, then delete the cache entry rather than trying to patch it in place.
SET order:789:status "pending" EX 120
GET order:789:status
DEL order:789:status
GET order:789:status
Output:
OK
"pending"
(integer) 1
(nil)
The cache holds "pending" until the underlying order is updated in the database. At that point the application calls DEL, which removes the stale entry — DEL replies (integer) 1 to confirm one key was removed. The following GET correctly misses, and the next reader will pull the fresh status from the database and repopulate the cache.
How it works step by step
For a typical read in a cache-aside setup:
- The application issues
GET key. Redis looks the key up in its in-memory keyspace hash table. - If the key exists but its TTL has already elapsed, Redis’s lazy-expiration check fires first: it deletes the key and treats the lookup as a miss, replying
(nil). (Independently, a background active-expiration cycle also sweeps sampled keys with TTLs so expired data doesn’t linger even if nothing ever reads it again.) - On a miss, the application falls back to its database, which is slower but authoritative.
- The application calls
SET key value EX ttlto place the fresh value back into Redis with a new expiration, so the next request for the same key is served from memory in microseconds instead of hitting the database again. - On a write, the database is updated first, then
DELremoves the cache entry — never the reverse order, since deleting before the database write finishes could let another request repopulate the cache with the old value.
Common Mistakes
Mistake 1: Forgetting the TTL entirely
If you cache without an expiration, a value that’s never explicitly deleted sits in memory forever — and if your invalidation logic ever misses a code path, that entry silently goes stale permanently instead of self-healing.
SET report:daily:2026-08-10 "generated"
TTL report:daily:2026-08-10
Output:
OK
(integer) -1
TTL returning -1 means the key exists but has no expiration — it will live in Redis until something explicitly deletes it. Always attach a TTL when caching:
SET report:daily:2026-08-10 "generated" EX 86400
TTL report:daily:2026-08-10
Output:
OK
(integer) 86400
Mistake 2: Refreshing a value with plain SET and losing the TTL
A plain SET on an existing key clears any TTL that was set on it, even if you only meant to update the value.
SET session:token:abc123 "user:501" EX 1800
TTL session:token:abc123
SET session:token:abc123 "user:501-refreshed"
TTL session:token:abc123
Output:
OK
(integer) 1800
OK
(integer) -1
The second SET silently turned a 30-minute session cache into a permanent key. Use KEEPTTL when you want to overwrite the value without disturbing the expiration:
SET session:token:abc123 "user:501" EX 1800
SET session:token:abc123 "user:501-refreshed" KEEPTTL
TTL session:token:abc123
Output:
OK
OK
(integer) 1800
Mistake 3: Using KEYS to find cache entries to invalidate
KEYS pattern scans the entire keyspace in one shot and, because Redis is single-threaded, blocks every other client’s commands until it finishes — on a production dataset with millions of keys that can stall your whole application for seconds. SCAN does the same pattern matching but walks the keyspace incrementally with a cursor, never blocking for more than a tiny slice of time per call.
SET cache:product:1 "a"
SET cache:product:2 "b"
KEYS cache:product:*
SCAN 0 MATCH cache:product:* COUNT 100
Output:
OK
OK
1) "cache:product:1"
2) "cache:product:2"
1) "0"
2) 1) "cache:product:1"
2) "cache:product:2"
KEYS works fine on this tiny test dataset, but never reach for it in production code — always use SCAN, checking the returned cursor and repeating until it comes back 0.
Mistake 4: Mixing data types on the same key
Every key has exactly one type. If a cache key was written as a string, a hash command against it fails outright instead of quietly coercing.
SET cart:9001 "3 items"
HSET cart:9001 items "3"
Output:
OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value
Decide on one representation per key namespace up front (a plain string, or a hash for structured records) and be consistent, or you’ll hit WRONGTYPE errors as soon as two code paths disagree.
Mistake 5: No protection against cache stampedes
If a popular key expires and a burst of concurrent requests all GET it at once, every one of them sees a miss and hammers the database simultaneously — a “thundering herd.” A common fix is a short-lived lock so only one request repopulates the cache while the others wait or serve slightly stale data:
SET lock:product:1001 "1" NX EX 10
SET lock:product:1001 "1" NX EX 10
Output:
OK
(nil)
NX means “only set if the key doesn’t already exist.” The first caller acquires the lock and gets OK; every other concurrent caller gets (nil) and knows someone else is already rebuilding the cache entry, so it can back off instead of also hitting the database.
Best Practices
- Always attach a TTL (
EX/PX) when caching — never rely on remembering to invalidate manually. - On writes, invalidate (
DEL) the cache key after the database write succeeds, rather than trying to update the cached value in place. - Use
KEEPTTLwhen you deliberately need to overwrite a value without resetting its expiration. - Namespace cache keys clearly (
product:2001,session:token:abc123) so invalidation and monitoring by pattern stay predictable. - Use
SCAN, neverKEYS, for any pattern-based lookup against a live production dataset. - Pick a data structure that matches the shape of what you’re caching — a hash for multi-field records, a plain string for opaque blobs or serialized payloads.
- Guard hot keys against stampedes with a short
SET ... NX EXlock so only one request rebuilds an expired cache entry. - Treat the database as the source of truth at all times — Redis should be safely disposable; if it were flushed, your application should still work correctly, just slower until the cache warms back up.
Practice Exercises
- Simulate a cache-aside read for a key
article:3300:title: first confirm it’s a miss withGET, then populate it as if loaded from a database with a 120-second TTL, then confirm the secondGETis a hit and check its remaining TTL. - Cache a user’s shopping cart as a hash under
cart:4400with fields foritem_countandtotal, attach a 10-minute expiration withEXPIRE, then simulate a checkout by deleting the key and confirming a subsequentHGETALLcomes back empty. - Simulate an update-without-KEEPTTL bug:
SETa key with a TTL, overwrite it with a plainSET, and check the TTL to see it reset. Then fix it usingKEEPTTLand confirm the original expiration survives the overwrite.
Summary
- Cache-aside means the application checks Redis first, falls back to the database on a miss, and writes the result back to Redis with a TTL.
- On writes, update the database first, then
DELthe cache key rather than updating it in place. - A plain
SETclears any existing TTL on a key — useSET key value KEEPTTLto overwrite a value without losing its expiration. TTLreturns-1for a key with no expiration and-2for a key that doesn’t exist — always attach a TTL to cached data so it self-heals even if invalidation logic has gaps.- Use
SCAN, notKEYS, to enumerate cache keys in production, sinceKEYSblocks the single-threaded server for the whole scan. - A key holds exactly one data type; mixing string and hash commands on the same key raises a
WRONGTYPEerror. - A short
SET ... NX EXlock prevents a thundering herd of concurrent requests from all missing the cache and hitting the database at once.
