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 GET on 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 call SET with 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 ttl to 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 DEL removes 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 KEEPTTL when 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, never KEYS, 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 EX lock 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 with GET, then populate it as if loaded from a database with a 120-second TTL, then confirm the second GET is a hit and check its remaining TTL.
  • Cache a user’s shopping cart as a hash under cart:4400 with fields for item_count and total, attach a 10-minute expiration with EXPIRE, then simulate a checkout by deleting the key and confirming a subsequent HGETALL comes back empty.
  • Simulate an update-without-KEEPTTL bug: SET a key with a TTL, overwrite it with a plain SET, and check the TTL to see it reset. Then fix it using KEEPTTL and 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 DEL the cache key rather than updating it in place.
  • A plain SET clears any existing TTL on a key — use SET key value KEEPTTL to overwrite a value without losing its expiration.
  • TTL returns -1 for a key with no expiration and -2 for 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, not KEYS, to enumerate cache keys in production, since KEYS blocks 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 WRONGTYPE error.
  • A short SET ... NX EX lock prevents a thundering herd of concurrent requests from all missing the cache and hitting the database at once.