Redis as a Cache
Redis is one of the most popular caching layers in modern application architecture because it stores data entirely in memory, making reads and writes measured in microseconds rather than the milliseconds a disk-backed database needs. Using Redis as a cache means storing a copy of expensive-to-compute or expensive-to-fetch data under a key, giving it a time-to-live (TTL), and reading from Redis first before falling back to the slower source of truth. Done well, this pattern (often called cache-aside or lazy loading) can cut database load by orders of magnitude and shave hundreds of milliseconds off response times. Done poorly — no TTL, a race condition on cache fill, the wrong eviction policy — it leaks memory or serves duplicated, wasted work. This lesson covers exactly how to do it well.
Overview: How Caching Works in Redis
The most common caching pattern with Redis is cache-aside (also called lazy loading). An application first issues a GET against Redis. If the key exists (a cache hit), the value is returned immediately and the slow source — a SQL query, a third-party API call, an expensive computation — is never touched. If the key is missing (a cache miss, represented by a (nil) reply), the application fetches or computes the value itself, writes it into Redis with SET and a TTL, and returns it. The next request for that same key is now a fast hit. A less common alternative is write-through, where every write to the primary datastore also updates the cache immediately, keeping Redis and the source of truth in lockstep at the cost of extra write latency.
Internally, every Redis key lives in a hash table (the main keyspace dictionary). Keys that have a TTL are also tracked in a second, separate hash table called the expires dictionary, which maps the key to an absolute expiration timestamp in milliseconds. Redis does not sweep the whole keyspace the instant a key’s TTL runs out; instead it combines two mechanisms. Lazy (passive) expiration happens on access: whenever a command touches a key, Redis first checks the expires dictionary, and if the key is past its expiration time, it is deleted on the spot and treated as if it never existed. Active expiration runs on a background cycle roughly ten times per second: Redis samples a batch of keys from the expires dictionary, deletes any that have expired, and if more than a quarter of the sample was expired, it immediately samples again. This combination means an expired key is guaranteed to disappear either the moment something asks for it, or shortly afterward in the background — you never have to expire keys yourself.
Because Redis is single-threaded — one command runs to completion before the next begins — every individual command, including a cache-filling SET, is atomic. No other client’s command can interleave in the middle of it. This is what makes patterns like SET key value NX EX seconds (described below) safe to use as a lightweight lock without any extra coordination.
Caching also interacts with Redis’s eviction policy. If a Redis instance has a maxmemory limit configured and that limit is reached, the configured maxmemory-policy decides what happens next: noeviction rejects further writes with an error, while policies like allkeys-lru, allkeys-lfu, volatile-lru, volatile-lfu, volatile-ttl, and the random variants evict existing keys to make room. The volatile-* policies only ever evict keys that have a TTL set — another reason cache entries should always carry an expiration. Keep in mind Redis is not a general-purpose database replacement: it has no query language for ad-hoc relational lookups, so it works best sitting in front of your real datastore as a cache, not instead of one.
Syntax
The two commands that matter most for caching are SET (with its expiration flags) and EXPIRE:
SET key value [EX seconds | PX milliseconds | EXAT unix-seconds | PXAT unix-ms | KEEPTTL] [NX | XX] [GET]
EXPIRE key seconds [NX | XX | GT | LT]
TTL key
PERSIST key
| Option | Meaning |
|---|---|
EX seconds |
Set the key to expire after this many seconds |
PX milliseconds |
Same as EX, but in milliseconds |
EXAT / PXAT |
Expire at an absolute Unix timestamp instead of a relative offset |
KEEPTTL |
Overwrite the value but keep the key’s existing TTL instead of clearing it |
NX |
Only set the key if it does not already exist |
XX |
Only set the key if it already exists |
GET (on SET) |
Return the previous value as part of the SET reply |
| Command | Time Complexity |
|---|---|
SET |
O(1) |
GET |
O(1) |
EXPIRE |
O(1) |
TTL |
O(1) |
PERSIST |
O(1) |
EXISTS |
O(1) per key |
SCAN |
O(1) per call (O(N) to iterate the whole keyspace) |
KEYS |
O(N) — scans the entire keyspace in one blocking call |
A crucial rule to internalize: a plain SET on a key that already has a TTL clears that TTL unless you add KEEPTTL. This trips people up constantly when refreshing cached values.
Examples
Example 1: Basic cache-aside write
SET product:1001:price "49.99" EX 60
GET product:1001:price
TTL product:1001:price
Output:
OK
"49.99"
(integer) 60
This is the simplest possible cache entry: a computed price is stored under a namespaced key with a 60-second TTL. The GET confirms the value is retrievable exactly as a cache hit would work, and TTL confirms the countdown is active — after 60 seconds this key will disappear on its own and the next read will be a miss that falls back to the real price source.
Example 2: Simulating a cache miss, then a hit
GET session:abc123:user
EXISTS session:abc123:user
SET session:abc123:user "42" EX 1800
GET session:abc123:user
EXISTS session:abc123:user
Output:
(nil)
(integer) 0
OK
"42"
(integer) 1
The first GET against a key that was never set returns (nil) — a normal, error-free reply representing a cache miss, and EXISTS confirms it with 0. After the application does its expensive work (here, looking up which user owns session abc123) it writes the result with a 30-minute TTL (1800 seconds). The next GET is now a hit.
Example 3: Refreshing a cache entry without resetting its TTL, plus a stampede lock
SET cache:homepage:html "rendered-homepage-v1" EX 300
TTL cache:homepage:html
SET cache:homepage:html "rendered-homepage-v2" KEEPTTL
TTL cache:homepage:html
SET cache:homepage:html "rendered-homepage-v3"
TTL cache:homepage:html
SET lock:homepage:refresh "1" NX EX 10
SET lock:homepage:refresh "1" NX EX 10
Output:
OK
(integer) 300
OK
(integer) 300
OK
(integer) -1
OK
(nil)
The rendered homepage is cached for 300 seconds. Updating it with KEEPTTL replaces the content but leaves the countdown untouched — still 300. Updating it again with a plain SET (no KEEPTTL) silently clears the TTL, so TTL now returns -1, meaning the key will live forever unless something expires it explicitly. The last two commands show a common stampede-prevention trick: many concurrent requests might notice the homepage cache is cold at the same moment and all try to regenerate it at once. By racing to SET a short-lived lock key with NX, only the first caller gets OK back and does the expensive regeneration; everyone else gets (nil) and should simply wait and retry the GET instead of duplicating the work.
How It Works Step by Step
When a client issues SET cache:key value EX 60, Redis performs, in order, on its single command-processing thread: (1) it computes an absolute expiration timestamp by adding 60000 milliseconds to the current time; (2) it inserts or overwrites the key/value pair in the main keyspace dictionary; (3) it inserts an entry for that key into the separate expires dictionary pointing at the absolute timestamp; (4) it replies +OK. Because this all happens as one atomic step with no other command able to interleave, there is no window where a concurrent client could see the key without its TTL.
When a later GET cache:key arrives, Redis first looks the key up in the main dictionary. If found, it checks whether the key also has an entry in the expires dictionary and, if so, whether that timestamp has already passed. If it has passed, Redis deletes the key from both dictionaries right then (lazy expiration) and replies (nil), exactly as if the key had never existed. If the timestamp has not passed, or there is no TTL at all, Redis returns the stored value. Independently of any client traffic, a background cycle wakes up roughly ten times per second, samples a batch of keys from the expires dictionary, evicts any that have already passed their deadline, and repeats immediately if a large share of the sample was expired — this is what reclaims memory for cache keys that are set and then never read again.
Common Mistakes
Mistake 1: Forgetting the TTL entirely
SET cache:report:daily "big-report-payload"
TTL cache:report:daily
Output:
OK
(integer) -1
A TTL of -1 means the key has no expiration at all. If this key represents a cached report that gets regenerated hourly, and nothing ever calls DEL on the old one, every version you’ve ever cached under different keys (or worse, every distinct report you generate) sits in memory forever, silently growing your memory footprint until Redis starts evicting or refusing writes. Always attach an EX/PX to cache writes:
SET cache:report:daily "big-report-payload" EX 86400
TTL cache:report:daily
Output:
OK
(integer) 86400
Mistake 2: GET-then-SET instead of an atomic check
GET cache:user:99:profile
SET cache:user:99:profile "profile-data-99" EX 300
Output:
(nil)
OK
This looks harmless in isolation, but under real concurrency it is a race condition: if two requests both see a cache miss on the GET at nearly the same instant, both will go compute the expensive profile data and both will SET it — wasted duplicate work, and with an expensive enough computation, a full-blown cache stampede under load. Because SET ... NX is a single atomic command, use it to let only one caller \”win\” the right to populate the cache:
SET cache:user:99:profile "profile-data-99" NX EX 300
Output:
OK
If a second client had raced in with the same command microseconds later, it would receive (nil) instead of OK, telling it that someone else already populated the value.
Mistake 3: Assuming EXPIRE on a missing key does something
EXPIRE cache:report:weekly 3600
Output:
(integer) 0
EXPIRE returns 0, not an error, when the target key does not exist — it silently does nothing. It’s easy to write code that calls EXPIRE right after a conditional write and never notices the write didn’t happen. Prefer setting the TTL at creation time with SET ... EX rather than as a separate follow-up step:
SET cache:report:weekly "weekly-report-data" EX 3600
TTL cache:report:weekly
Output:
OK
(integer) 3600
Mistake 4: Using KEYS to inspect the cache
SET cache:product:1:name "Widget"
SET cache:product:2:name "Gadget"
KEYS cache:product:*
Output:
OK
OK
1) "cache:product:1:name"
2) "cache:product:2:name"
KEYS works fine here because the test dataset is tiny, but it is an O(N) operation that walks the entire keyspace and, because Redis is single-threaded, blocks every other client from being served until it finishes. On a production instance with millions of keys this can freeze the server for seconds. Use the cursor-based SCAN instead, which returns a small batch per call and never blocks the server for more than that batch:
SET cache:product:1:name "Widget"
SET cache:product:2:name "Gadget"
SCAN 0 MATCH cache:product:* COUNT 100
Output:
OK
OK
1) "0"
2) 1) "cache:product:1:name"
2) "cache:product:2:name"
The first element of the reply is the next cursor to pass back in; a cursor of "0" means the iteration is complete.
Best Practices
- Always set a TTL on cache keys unless you have an explicit, deliberate invalidation strategy — untimed cache entries accumulate forever and can exhaust
maxmemoryor crowd out useful data. - Use
SET key value NX EX secondsas an atomic cache-fill lock instead of a separateGET/EXISTScheck followed bySET, to avoid stampedes. - Prefer
SCANoverKEYSfor any pattern match over the keyspace in production;KEYSblocks the single-threaded server for the whole scan. - Use
KEEPTTLwhen refreshing a value under its existing key so a routine content update doesn’t accidentally cancel the TTL and turn the entry permanent. - Namespace cache keys clearly (
cache:,session:,lock:) so you can reason about, and safelySCAN, one category of data at a time. - Configure
maxmemorywith avolatile-*eviction policy in production so Redis proactively evicts old cache entries under memory pressure instead of erroring on writes. - Add small random jitter to TTLs on similar keys so a large batch doesn’t expire at the exact same instant and cause a simultaneous wave of cache misses.
Practice Exercises
- Cache a product’s inventory count under
inventory:sku:8842with a 120-second TTL, and confirm withTTLthat the countdown is active. Then think through: what should your application do when a laterGETon that key returns(nil)? - Build a stampede-safe cache fill for an expensive monthly report: acquire a lock on
lock:report:monthlyusingNXand a short TTL before doing the recomputation, and only proceed with the expensive work if you receiveOKback. - Store a session under
session:<token>with a 30-minute TTL, then simulate \”keeping the user logged in\” by rewriting the same key’s value usingKEEPTTL, and confirm withTTLthat the expiration countdown was not reset.
Summary
- Cache-aside works by checking Redis with
GETfirst, and only falling back to the slow source on a miss, then writing the result back with a TTL. - Redis tracks TTLs in a separate expires dictionary and clears expired keys two ways: lazily on access, and actively via a background cycle.
- A plain
SETclears any existing TTL on a key; useKEEPTTLto preserve it when refreshing a value. TTLreturns-1for a key with no expiration and-2for a key that does not exist.EXPIREon a nonexistent key is a silent no-op that returns0, not an error.SET key value NX EX secondsgives you an atomic, single-thread-safe cache-fill lock that prevents stampedes.- Use
SCAN, neverKEYS, for pattern matching over a production keyspace. - Because Redis is single-threaded, every individual command — including the ones above — is atomic, which is the foundation these caching patterns rely on.
