Redis 📂 Caching · 1 of 1 29 min read

Redis Caching Patterns — Cache-Aside, Write Strategies, Eviction, and Stampede

A practical Redis caching guide. Learn why caching is needed, the cache-aside pattern step by step, write-through vs write-behind, safe cache invalidation, eviction policies with maxmemory (LRU, LFU, TTL), and how to avoid cache stampede, thundering herd and hot keys. Includes animated diagrams, code, and golden rules.

Section 01

What a Cache Really Is

The Notepad on Your Desk
Your files live in a locked cabinet down the hall. Safe, but slow to reach. So for the phone numbers you use all day, you copy them onto a notepad on your desk. Reading the notepad is instant. The cabinet is still the real record; the notepad is just a fast copy.

A cache is that notepad. Redis holds a fast copy of the data your app reads most. The database stays the source of truth. The whole art of caching is keeping the notepad useful without letting it go out of date. This tutorial covers the patterns that get it right.

Section 02

Why Caching Is Needed — The Read/Write Problem

A database is great at keeping data safe. It is not great at answering the same read a million times a second. Every read means a disk touch, a query plan, maybe a row lock. Under load, the database becomes the slow point for the whole app.

🔥
Repeated reads
same query, again
A product page, a profile, a home feed — read again and again by many users. The database does the same work over and over.
🧠
Expensive queries
joins & sorts
Some results take joins, sorting, and grouping to build. Running that on every request is slow and wasteful when the answer rarely changes.
⏳
Latency at scale
connections fill up
Disk reads and a limited pool of connections add up. As traffic grows, response times climb and the database struggles to keep up.

Without a cache, every read lands on the database. Popular data means the same query runs thousands of times a second and the database heats up.

🔥 Without a Cache, the Database Drowns
same read, over and over app server A app server B app server C app server D Database (overloaded) reads: 50,000 / second CPU: 100% response time: rising
💡
The Fix Is a Fast Copy in Front

Put Redis between the app and the database. Most reads are then answered from memory in about a millisecond, and only the first read (or a rare miss) touches the database. The database load drops sharply. The rest of this tutorial is how to do that safely.


Section 03

Cache-Aside (Lazy Loading), Step by Step

Cache-aside is the most common pattern. The app manages the cache itself. It looks in Redis first; on a miss it reads the database and copies the result into Redis. The cache fills "lazily", only with data that is actually asked for.

01
Look in the cache
The app asks Redis for the key. If the value is there (a hit), return it. Done in about a millisecond.
02
On a miss, read the database
If Redis has nothing, read the real value from the database.
03
Fill the cache with a TTL
Write the value into Redis with an expiry, then return it. The next read is a fast hit.

A hit returns straight from Redis. A miss reads the database, then fills Redis so the next read is fast.

🔄 Cache-Aside — Hit and Miss
App cache-aside logic Redis (cache) GET key → value or nil SET key value EX 600 Database (source of truth) read only on a miss ① GET key ②a HIT → return (fast) ②b MISS → read DB ③ SET (fill cache)
# cache-aside read, in pseudo-code
function get_user(id):
    val = GET user:{id}              # 1. look in Redis
    if val is nil:                     # miss
        val = db.query("SELECT ... WHERE id = ?", id)   # 2. read DB
        SET user:{id} val EX 600     # 3. fill cache, 10 min TTL
    return val
✅
Why Cache-Aside Is the Default

It is simple, and the cache only holds data that is actually used. If Redis goes down, the app still works — it just reads the database more. That safety net is why most teams start here.


Section 04

Write-Through and Write-Behind

Cache-aside fills the cache on reads. But what about writes? Two patterns keep the cache fresh when data changes: write-through and write-behind.

Write-through saves to cache and database together, then returns. Write-behind saves to the cache now and to the database a moment later, in the background.

💾 Write-Through vs Write-Behind
Write-through — save both, then return (always fresh) App Redis (cache) Database write write Write-behind — save to cache now, database later (fast, but riskier) App Redis (cache) Database write now flush later (async)
🔄
Cache-aside
App fills the cache on a read miss. Simple and safe. Cache holds only what is used. The default for read-heavy data.
reads · lazy
💾
Write-through
Every write updates cache and database together. Cache is always fresh. Writes are a little slower because they wait for both.
fresh · slower writes
⏳
Write-behind
Write to cache now, flush to the database later in the background. Very fast writes, but a crash can lose the not-yet-saved data.
fast · risk of loss
⚠️
Write-Behind Trades Safety for Speed

Write-behind is the fastest for writes, but if Redis crashes before the flush, those writes are gone. Use it only where losing a few recent writes is acceptable, such as counters or metrics — never for orders or payments.


Section 05

Cache Invalidation — The Hard Part

There is an old joke: the two hardest things in computing are naming things, cache invalidation, and off-by-one errors. Invalidation is hard because a cached copy can quietly go stale after the database changes. Here are the three safe ways to handle it.

🧰 Three Ways to Keep the Cache Honest
TTL
Give every cache key a time to live. Even if you forget to update it, it fixes itself within seconds: SET key val EX 300.
Delete on write
When the database changes, delete the cache key. The next read misses and rebuilds it: DEL user:1.
Versioned key
Put a version in the key. Bumping the version instantly points reads at a fresh key: user:1:v2.
# when the user is updated, invalidate the cache
db.query("UPDATE users SET city = ? WHERE id = ?", city, id)
DEL user:{id}                 # drop the stale copy; next read rebuilds it

# safest default: short TTL as a backstop, even if you also DEL
SET user:{id} val EX 300
💡
Prefer Delete Over Update

On a write, delete the cache key rather than trying to write the new value into it. Deleting is simpler and avoids a race where two writers leave a wrong value behind. Let the next read rebuild the key from the database.

⚠️
Always Have a TTL as a Backstop

Even with delete-on-write, bugs happen and an event can be missed. A short TTL guarantees any stale key clears itself. TTL turns "wrong forever" into "wrong for a few seconds".


Section 06

Eviction Policies and maxmemory

Memory is limited. You set maxmemory, and when Redis is full it must remove something to store new data. The maxmemory-policy decides which key to drop. Picking the right policy keeps the useful data and evicts the rest.

When memory is full, a new key needs room. The policy chooses a victim — here the least-recently-used key — and evicts it to make space.

📦 Eviction When maxmemory Is Reached
memory (maxmemory 2 GB — full) home:feedused 2s ago user:57used 1s ago cart:88used 3s ago old:pageused 40m ago tag:x evict the coldest 🗑️ a new key needs room → policy allkeys-lru drops the least-recently-used key LRU = least recently used · LFU = least frequently used · volatile-ttl = nearest to expiry
PolicyWhat it dropsGood for
noevictionNothing — new writes errorWhen losing data is worse than failing
allkeys-lruLeast recently used, any keyA general cache (most common)
allkeys-lfuLeast frequently used, any keyWhen some keys are always popular
volatile-lruLeast recently used, but only keys with a TTLMixing cache + permanent data
volatile-ttlThe key closest to expiringDropping soon-to-die keys first
# set a memory limit and a policy
CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru

# check what is set
CONFIG GET maxmemory-policy
📈
LRU for Most, LFU for Sticky Favourites

allkeys-lru is the safe default for a pure cache. If a few keys are always hot and should survive quiet spells, allkeys-lfu keeps the frequently used ones. Use noeviction only when Redis holds data you cannot drop.


Section 07

Cache Stampede, Thundering Herd, and Hot Keys

A popular key expires. In the same instant, thousands of requests look for it, all miss, and all rush to the database at once. This is a cache stampede (or thundering herd), and it can knock the database over — the very thing the cache was meant to prevent.

The moment a hot key expires, every waiting request misses together and stampedes to the database.

🐀 A Stampede When a Hot Key Expires
Redis product:9 — EXPIRED (TTL = 0) many requestsreq 1req 2req 3... 5000 more all miss at once → stampede Database 5000 identical queries at once

How to prevent it

🛡️ Four Fixes for Stampedes and Hot Keys
Lock
Let only one request rebuild the key. Others wait or serve the old value. Use SET lock:key 1 NX EX 5 as a short "I am rebuilding" flag.
Jitter
Add a random few seconds to each TTL so keys do not all expire at the same moment: EX 600 + rand(0..60).
Early rebuild
Refresh a popular key before it expires, in the background, so it never goes cold.
Hot key copy
For a very hot key, keep a short-lived copy in the app's own memory too, so most reads never even reach Redis.
# single-flight: only the first request rebuilds; others back off
val = GET product:9
if val is nil:
    got = SET lock:product:9 1 NX EX 5   # try to grab the rebuild lock
    if got == "OK":
        val = db.query("SELECT ... WHERE id = 9")   # only one query hits the DB
        SET product:9 val EX (600 + rand(0,60))     # jittered TTL
        DEL lock:product:9
    else:
        sleep(50 ms); val = GET product:9        # wait for the rebuild, then read
🎯
One Rebuild, Not Five Thousand

The lock means only one request rebuilds the key while the rest wait a moment and then read the fresh value. The database sees one query instead of a flood. Jittered TTLs stop many keys expiring together in the first place.


Section 08

Golden Rules

⚡ Redis Caching — Non-Negotiable Rules
1
The database is the source of truth. The cache is a fast copy you can always rebuild. Design so losing the cache only means slower reads for a moment.
2
Start with cache-aside. Look in Redis, read the database on a miss, fill the cache with a TTL. Simple, safe, and it works if Redis is down.
3
Every cache key gets a TTL. It is your safety net against stale data and runaway memory. No TTL means "wrong forever" is possible.
4
On a write, delete the key. Deleting is safer than updating in place. The next read rebuilds it fresh from the database.
5
Set maxmemory and a policy. Use allkeys-lru for a general cache. Without a limit, Redis can eat all the memory on the box.
6
Plan for the stampede. Jitter your TTLs and use a rebuild lock so a hot key expiring sends one query to the database, not thousands.
7
Use write-behind with care. It is fastest but can lose recent writes on a crash. Keep it for counters and metrics, never for money.
You have completed Caching. View all sections →