Full-Stack Questions
Redis and Caching Interview Questions
Cache-aside and the other patterns, TTL and invalidation, stampedes, data types, persistence, eviction, rate limiting, locks and client settings that matter.
On this page
- Words used in this lesson
- Why a cache, and what it costs
- The four caching patterns
- TTL and invalidation
- Stampede (thundering herd)
- Redis data types
- Saving data to disk: RDB and AOF
- Eviction: what happens when memory is full
- Common uses: rate limits, sessions, queues
- Pub/sub vs streams
- Distributed locks, and why Redlock has caveats
- Using Redis from Node: ioredis and node-redis
- Caching in Next.js and HTTP
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: a cache is a fast copy of data that is slow to get, and Redis is the place most teams keep that copy. Every interview question comes back to three things: how the copy gets filled, how it gets thrown away, and what happens when it is wrong or gone.
At 4+ years you are not asked "what is Redis?". You are asked "your product page is slow, what do you cache, what is the TTL, how does it get updated, and what happens when Redis is down?". This lesson gives you the words and the answers. It also covers the Redis features that come up most: data types, saving data to disk, eviction, rate limits, sessions, messaging and locks. Every code sample was run against a real Redis server (7.0) from Node 22, with ioredis 6.0.0 and redis (node-redis) 6.3.0.
Related lessons: the larger design view is in Design Problems, Lesson 01 (URL shortener) and Lesson 02 (rate limiter). Browser and CDN caching is in Browser, Lesson 05. Next.js caching is in Next.js interview questions. Session auth is in Backend, Lesson 06.
Words used in this lesson
- A cache is a small, fast store that keeps answers you already got, so you do not ask the slow source again. Like a notebook on your desk: you do not walk to the library for the same fact twice.
- Redis is a data store that keeps its data in memory (RAM), so reads and writes take well under a millisecond on the server. Many app servers can share it. It is a server you connect to over the network, not a library inside your process.
- A key is the name of a value in Redis (such as
user:42). A value is the data under that name. Redis keys are strings. - A hit is a read that found the value in the cache. A miss is a read that did not. The hit rate is hits divided by all reads.
- A TTL (time to live) is the number of seconds a key may live before Redis deletes it.
- Eviction is Redis deleting keys to make room when memory is full. Expiry is Redis deleting a key because its TTL ended. They are different: one is about space, the other about time.
- Invalidation is removing or replacing a cached value because the real data changed.
- A cache stampede (also called a thundering herd) is what happens when a popular key expires and many requests miss at the same moment. They all go to the database together.
- Atomic means a step that cannot be cut in half. No other command can run in the middle. Redis runs one command at a time, so every single command is atomic.
- Persistence means saving data to disk, so it survives a restart. RDB and AOF are the two ways (below).
- Idempotent means doing it twice has the same effect as once.
Why a cache, and what it costs
A database read may take 5 to 50 ms and uses a connection. A Redis read inside the same data centre takes roughly 0.5 ms. If 90 of every 100 reads are hits, the database sees only 10 percent of the load. That is the whole idea.
The cost is that you now have two copies of the truth. Two copies can disagree. Almost every cache question is really "how long may they disagree, and who pays when they do?".
Difference in one line: a cache may lose data at any time and can be rebuilt from the source. A database is the source. If you cannot rebuild a value, it does not belong only in a cache.
The four caching patterns
| Pattern | Who fills the cache | Read path | Write path | Best for |
|---|---|---|---|---|
| Cache-aside (lazy loading) | Your app code, on a miss | App asks cache, then DB on a miss, then sets cache | App writes DB, then deletes the cache key | Most web apps. Read-heavy data |
| Read-through | The cache layer itself | App only talks to the cache; the cache loads from the DB | Same as cache-aside | When a library or product does the loading for you |
| Write-through | App writes to cache, and the cache writes to the DB, in the same step | Always hits | Cache and DB updated together; the write is slow but both agree | Data read right after it is written |
| Write-behind (write-back) | App writes to the cache only; the DB is updated later in the background | Always hits | Very fast writes; the DB lags | Counters, view counts, data you can afford to lose a little of |
Cache-aside is the one you will use. In Node there is no built-in read-through, so you write a small helper. The code below also adds three habits I always want: a TTL with a small random extra time (jitter), a short cached "not found" answer, and JSON in and out.
import Redis from "ioredis";const redis = new Redis(); // localhost:6379async function getUser(id) { const key = `user:${id}`; const hit = await redis.get(key); if (hit !== null) return JSON.parse(hit); // the string "null" is a cached "not found" const row = await db.getUser(id); // slow path const ttl = row ? 300 + Math.floor(Math.random() * 60) : 30; // jitter; short TTL for "not found" await redis.set(key, JSON.stringify(row), "EX", ttl); return row;}I ran this. The second call for the same id did not reach the database, and TTL showed 313 seconds for a user and 30 seconds for the cached "not found".
Write-behind needs care, because if Redis dies before the background writer runs, you lose writes. A safer shape uses a Redis stream (below) as a durable queue between the cache and the database:
async function writeBehind(id, value) { await redis.set(`u:${id}`, JSON.stringify(value)); await redis.xadd("db-writes", "MAXLEN", "~", 100000, "*", "id", String(id), "json", JSON.stringify(value)); // a worker reads "db-writes" with a consumer group and updates the database}Write-behind trades safety for speed. Use it only if losing the last few seconds of writes is acceptable, and make the background writer idempotent, so a retry does not apply a change twice.
What to cache (and what not to)
- Good: data that is read much more than it is written, costs a lot to build (joins, aggregations, calls to other services), and can be a little stale. Examples: product pages, public profiles, config, permissions, rendered fragments.
- Bad: data that must be exact every time (account balance, stock for the last item), data that is different for every user and is read once, and very large values.
- Key names should say what they are and include everything that changes the answer:
product:42:en:v3. If the language changes the result, language must be in the key. Forgetting this is a classic bug: one user sees another user's data.
TTL and invalidation
"There are only two hard things in computer science: cache invalidation and naming things." This old joke is popular because it is true.
TTL: the safety net
A TTL makes every wrong value fix itself eventually. Choose it by asking "how stale may this be?". A news home page: 30 seconds. A product description: 5 to 15 minutes. A permission list: very short, or invalidate on change.
Facts worth knowing, all tested:
TTL keyreturns-1if the key exists with no expiry, and-2if the key does not exist.- A plain
SET key valueon an existing key removes its TTL (the TTL became-1in my test). UseKEEPTTLto keep it, or set the TTL again. - Redis deletes expired keys in two ways: when a client touches the key (passive), and by regularly testing a few random keys that have a TTL (active). So an expired key may use memory for a short while, but you will never read it.
- Add jitter (a small random amount) to TTLs. If you fill 100,000 keys at the same second with the same TTL, they all expire at the same second. This is called a cache avalanche.
Three ways to invalidate
- Delete on write (most common). After the database write succeeds,
DELthe key. The next read fills it again. - Update on write (write-through). Faster reads after the write, but you must build the exact same value the reader would, in two places.
- Version in the key. Keep a counter
ver:products. Put it in the key:products:v7:42. To clear all products,INCR ver:products. Old keys are never read again, and they expire by TTL. I tested this: after theINCR, the old key returnednull.
Delete or update first? Order matters. If you delete the cache, then write the database, a reader can sneak in between, read the old database value, and put the old value back into the cache. It stays there until the TTL ends. The safer order is write the database first, then delete the cache key. It still has a tiny race (a slow reader that loaded the old value before your write, then sets it after your delete), which is why you also keep a TTL as the last safety net.
Stampede (thundering herd)
A hot key expires. In the next 50 ms, 500 requests miss. All 500 run the same slow query. The database slows down, the queries take longer, so even more requests pile up. This can take a site down.
I tested the simple case: 50 concurrent requests for one missing key caused 50 database calls.
Ways to stop it, from simple to strong:
| Fix | How it works | Limit |
|---|---|---|
| Single-flight (in one process) | Keep a Map of loads in progress. Later callers get the same promise | Each app server still loads once. 10 servers = 10 loads |
| Lock on a miss (across servers) | The first miss takes a short lock with SET lock:key token NX PX 10000. Others wait a moment and re-read the cache | The waiting code is more complex. The lock needs a TTL |
| Probabilistic early refresh | Before the TTL ends, each request has a small, growing chance to refresh the key early | Needs to know how long a load takes |
| Serve stale while refreshing | Store a "soft" expiry inside the value. After it, return the old value and refresh in the background | Users may see old data for a short time |
| Never expire hot keys, refresh on a timer | A background job rewrites them | Not for large key sets |
Here is the lock version. I ran it with 5 separate Node processes asking for the same missing key at the same time, and the database was called once.
import { randomUUID } from "node:crypto";const RELEASE = `if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) end return 0`;const sleep = (ms) => new Promise((r) => setTimeout(r, ms));async function cached(key, ttlSec, load) { const hit = await redis.get(key); if (hit !== null) return JSON.parse(hit); const token = randomUUID(); const gotLock = await redis.set(`lock:${key}`, token, "PX", 10_000, "NX"); if (gotLock !== "OK") { for (let i = 0; i < 20; i++) { // someone else is loading: wait, then re-read await sleep(25); const again = await redis.get(key); if (again !== null) return JSON.parse(again); } } try { const value = await load(); // if we waited too long, we load anyway await redis.set(key, JSON.stringify(value), "EX", ttlSec); return value; } finally { if (gotLock === "OK") await redis.eval(RELEASE, 1, `lock:${key}`, token); }}Probabilistic early refresh (the "XFetch" idea): with ttlLeft and the time a load takes (delta), refresh early when -delta * Math.log(Math.random()) >= ttlLeft. In my run, with 2 seconds left and a 100 ms load, nobody refreshed. With 100 ms left, about 36 percent of calls refreshed, so one of the first requests renews the key before it dies.
Two related problems with similar names:
- Cache penetration: requests for keys that never exist (
/user/-1) always miss and hit the database. Fix: cache the "not found" answer for a short time, and validate the input. - Cache avalanche: many keys expire together, or Redis restarts empty. Fix: jitter, and protect the database with a limit on concurrent loads.
Redis data types
A Redis value is not only a string. Choose the type that matches the question you will ask.
| Type | What it is | Typical use | Example |
|---|---|---|---|
| String | Text or bytes (up to 512 MB), or a number you can INCR | Cached JSON, counters, locks | SET page:1 "<html>…" |
| Hash | A small map of field to value inside one key | A session, a user's fields | HSET session:abc userId 7 role admin |
| List | A list ordered by insertion, fast at both ends | A simple queue, a recent-items list | RPUSH q job1, LPOP q |
| Set | Unique values, unordered | Tags, "who liked this" | SADD tags a b a gives 2 members |
| Sorted set | Unique values, each with a score, kept in score order | Leaderboards, delayed jobs, sliding-window limits | ZADD lb 80 bo, ZREVRANGE lb 0 1 WITHSCORES |
| Stream | An append-only log with consumer groups | Event queues with acknowledgements | XADD orders * id 1 |
| Bitmap, HyperLogLog | Bits in a string; a tiny counter of approximate distinct items | "Was the user active on day N", unique visitors | PFADD hll u1 u2 u1 then PFCOUNT hll = 2 |
All of these were run in my tests. Notes that interviewers like:
- A hash lets you change one field without reading and rewriting a whole JSON string. A JSON string is simpler when you always read the whole thing.
- Sorted set vs list for a queue: a list is a plain FIFO queue. A sorted set lets you pick by score (time, priority).
- HyperLogLog counts unique items in about 12 KB per key, with a small error (under 1 percent), no matter how many items. It cannot list them.
- Big keys (a hash with 5 million fields) and hot keys (one key getting most traffic) are common production problems, because one command on a big key blocks everyone else.
Never use KEYS in production
KEYS user:* walks the whole keyspace in one command. While it runs, every other client waits. Use SCAN, which returns a few keys at a time and a cursor to continue. With ioredis, scanStream does the looping for you:
let count = 0;for await (const keys of redis.scanStream({ match: "tmp:*", count: 10 })) count += keys.length;SCAN can return a key twice and may miss keys added while it runs, so your code must allow duplicates.
Saving data to disk: RDB and AOF
Redis keeps data in memory, so a restart loses everything unless you turn on persistence. There are two methods, and you can use both.
| RDB (snapshot) | AOF (append-only file) | |
|---|---|---|
| What it saves | The whole dataset at one moment, as a compact file | A log of every write command |
| How | Redis forks a child process that writes the file | Each write is appended; the file is later rewritten smaller |
| Data you may lose | Everything since the last snapshot (minutes) | With the default appendfsync everysec: about 1 second |
| Restart speed | Faster | Slower (replays the log) |
| File size | Smaller | Larger |
forkis an operating system call that makes a copy of a running process. Redis uses it so the child can write the file while the parent keeps serving clients. On a big dataset, theforkitself can pause Redis for a short time.appendfsynchas three settings:always(safe, slow),everysec(the default: fast, loses up to about a second),no(the operating system decides).- In the default config of the server I tested,
save 3600 1 300 100 60 10000is on (RDB snapshots), andappendonlyisno. - If both are on, Redis uses the AOF at restart, because it is the most complete.
- The Redis docs say: use both if you want data safety similar to a normal database; use RDB alone if losing a few minutes is fine; use no persistence for a pure cache.
Persistence is not a backup. A disk on the same machine dies with the machine. Copy RDB files to other storage. And Redis is not your main database just because it has AOF: failover can still lose recent writes, because replication is asynchronous.
Eviction: what happens when memory is full
Set a memory limit with maxmemory (for example maxmemory 100mb). By default there is no limit (0) on 64-bit systems, and the default policy maxmemory-policy is noeviction: when full, writes fail with OOM command not allowed when used memory > 'maxmemory' while reads still work. I saw this error in my test.
| Policy | Evicts |
|---|---|
noeviction | Nothing. Writes fail |
allkeys-lru | The least recently used key, from all keys |
allkeys-lfu | The least frequently used key, from all keys |
allkeys-random | A random key |
volatile-lru, volatile-lfu, volatile-random | Same, but only among keys that have a TTL |
volatile-ttl | The key with the shortest remaining TTL |
- LRU (least recently used) removes what has not been touched for the longest time. LFU (least frequently used) removes what is touched least often.
- Redis does not track exact LRU. It samples a few keys (
maxmemory-samples, default 5) and removes the best candidate among them. This saves memory and is close enough in practice. volatile-*policies behave likenoevictionwhen no key has a TTL. I confirmed this: withvolatile-lruand keys without TTLs, writes failed.- For a cache, the Redis docs suggest
allkeys-lruas a good default. For one instance that mixes cache keys and must-keep keys,volatile-*is possible, but the docs advise two separate instances instead.
I ran a small test with a 3 MB limit: 20 hot keys read often, then 2,400 keys written once each. With allkeys-lru only 1 of the 20 hot keys survived; with allkeys-lfu all 20 survived. So a one-time scan can wash out an LRU cache, while LFU protects keys that are used a lot.
To see if your policy works, read INFO stats: hit rate is keyspace_hits / (keyspace_hits + keyspace_misses), and evicted_keys tells you how many keys were removed for space.
Common uses: rate limits, sessions, queues
Rate limiting
A rate limiter counts requests per user (or IP) in a time window and rejects the extra ones. The first idea is INCR plus EXPIRE:
const c = await redis.incr(`rl:${ip}`); // 1, 2, 3 ...if (c === 1) await redis.expire(`rl:${ip}`, 60);if (c > 100) return res.status(429).end();It works, but it is two commands. If the process dies between them, the key stays forever without a TTL, and that user is blocked for good. The fix is to make it one step with a Lua script (Redis runs a script as one unbreakable step):
redis.defineCommand("hit", { numberOfKeys: 1, lua: `local c = redis.call("INCR", KEYS[1]) if c == 1 then redis.call("PEXPIRE", KEYS[1], ARGV[1]) end return c`,});const count = await redis.hit(`rl:${ip}`, 60_000); // I got 1, 2, 3, 4 and a positive PTTLFixed windows allow bursts at the edge of two windows. Token bucket and sliding window fix that. They are covered with full code in Design Problems, Lesson 02.
Sessions
A session is the server-side record that says "this cookie belongs to user 7". Store it in a hash with a TTL: HSET session:<id> userId 7 role admin, then EXPIRE session:<id> 1800. To get a sliding timeout, call EXPIRE again on each request. Add an absolute maximum age too. The Redis server can restart, so decide: do logged-in users survive a restart? If yes, use AOF; if not, they log in again. See Backend, Lesson 06 for cookies and session safety.
Counters, leaderboards, queues
INCR gives atomic counters. A sorted set gives a leaderboard (ZADD, ZREVRANGE, ZREVRANK). Lists or streams give queues. For serious job queues in Node, people use libraries built on Redis, such as BullMQ, instead of raw lists.
Pub/sub vs streams
Pub/sub (publish/subscribe) lets one client send a message to a channel and pushes it to every client subscribed at that moment. Its delivery is at-most-once: a message arrives once, or never.
I tested this. PUBLISH with no subscribers returned 0 and the message was gone. A subscriber that was away for a moment missed the message sent in the gap. There is no history.
A stream is an append-only log stored in Redis. With a consumer group, many workers share the work, and each message must be acknowledged (XACK) when done. Messages stay in the stream after they are acknowledged (you trim it with MAXLEN), and messages that were delivered but never acknowledged can be claimed by another worker (XAUTOCLAIM). That gives at-least-once delivery.
| Pub/sub | Streams | |
|---|---|---|
| Stored | No | Yes (until trimmed) |
| If the receiver is offline | Message lost | It reads it later |
| Many receivers get every message | Yes | Yes, with separate groups |
| Workers share the work | No | Yes, with a consumer group |
| Acknowledgement and retry | No | Yes |
| Use for | Live notices, cache invalidation hints, chat typing signals | Order events, background jobs, audit trails |
A consumer group in code (all of this ran):
await redis.xgroup("CREATE", "orders", "workers", "0", "MKSTREAM");const res = await redis.xreadgroup("GROUP", "workers", "c1", "COUNT", 10, "BLOCK", 2000, "STREAMS", "orders", ">");// process each entry, then:await redis.xack("orders", "workers", entryId);Redis Cluster note: in a cluster, a command with several keys needs all keys in the same hash slot (one of 16,384 buckets; the key's name decides it). Use a hash tag like {user:7}:cart and {user:7}:orders to keep related keys together.
Distributed locks, and why Redlock has caveats
A lock makes sure only one worker does a job at a time. A distributed lock does it across many servers. In Redis the usual form is:
SET lock:invoice-7 <random-token> NX PX 30000 # NX = only if the key does not exist; PX = expire in msTo release, you must delete the key only if it still holds your token. A plain DEL could remove another worker's lock. Check and delete together in a Lua script:
const RELEASE = `if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`;await redis.eval(RELEASE, 1, "lock:invoice-7", token);I tested this: releasing with the wrong token returned 0, and the right token returned 1. Newer Redis versions (8.4 and later) also have a single DELEX ... IFEQ command for this, but the Lua script works everywhere.
Problems with a single-key lock:
- The TTL can end while you are still working. A garbage-collection pause or a slow call lets the lock expire, another worker takes it, and now two workers think they own it.
- Failover. Redis replication is asynchronous. A lock written on the primary may not have reached the replica when the primary dies. The replica becomes primary, and a second worker takes the same lock.
- Clocks. Redis expiry uses wall-clock time, and a clock jump can end a lock early.
Redlock is an algorithm from the Redis authors for this problem. The client tries to take the lock on 5 independent Redis servers (no replication between them) and counts the lock as held only if it got a majority (3) within a short time that is less than the lock's TTL. The usable time is the TTL minus the time spent taking the lock minus a clock-drift margin.
The caveats, and what I say in interviews:
- Even the Redis docs say to use fencing tokens if correctness matters. A fencing token is a number that grows with each lock grant. The resource (for example the database) rejects any write that carries an older number than one it has already seen. This works even if a paused worker wakes up late.
- A well-known critique (by Martin Kleppmann) says timing assumptions make Redlock unsafe for correctness, and the Redis author replied. The docs link to both. My conclusion: use Redis locks for efficiency (avoid doing the same job twice), and use database constraints, transactions or fencing for correctness.
Using Redis from Node: ioredis and node-redis
Both are official clients. Both support pipelines, transactions, Lua, pub/sub and streams.
| ioredis (6.0.0) | node-redis (redis 6.3.0) | |
|---|---|---|
| Create | new Redis(port) or new Redis(url) | createClient({ url }), then await client.connect() |
| Commands | Lowercase, positional: redis.set(k, v, "EX", 60, "NX") | Camel case, options object: client.set(k, v, { EX: 60, NX: true }), hSet, hGetAll |
| Pipeline | redis.pipeline()...exec() returns [err, result] pairs | Commands sent together are pipelined automatically; client.multi() for transactions |
| Subscribe | A subscriber needs its own connection | client.duplicate() for a subscriber, subscribe(channel, handler) |
| Lua | defineCommand (adds a method) | client.eval(script, { keys: [...] }) |
| Close | quit() (graceful) or disconnect() | close() (graceful) and destroy() (immediate) |
Things that surprise people (I checked each one):
- ioredis 6 uses RESP3 by default (RESP is the wire format between client and server). Version 5 used RESP2. Set
protocol: 2to keep the old behaviour. In RESP3 a subscribed connection may run normal commands such asGET; in RESP2 it throws "Connection in subscriber mode". ioredis 6 needs Node 20+. - node-redis renamed some methods.
quit()is deprecated in favour ofclose(), anddisconnect()becamedestroy(). Old tutorials still use the old names. - A cache must not make your site slower than no cache. With default ioredis options, a command to a dead server kept waiting and failed with
MaxRetriesPerRequestErrorafter about 21 seconds in my test (20 retries). For a cache, set a shortcommandTimeout, and considerenableOfflineQueue: falseso commands fail at once (it failed in 2 ms in my test). Then catch the error and go to the database. - A pipeline sends many commands in one network trip. It is faster, but it is not atomic.
MULTI/EXECruns commands as one block with nothing in between, but there is no rollback: a command that fails inside does not undo the others. - Create one client per process and reuse it. Do not make one per request.
- Handle the
errorevent on the client. An unhandlederrorevent on a NodeEventEmittercan crash the process.
const redis = new Redis(process.env.REDIS_URL, { commandTimeout: 200, // ms: do not let a cache call hang a request enableOfflineQueue: false, // fail fast while disconnected maxRetriesPerRequest: 1,});redis.on("error", (err) => log.warn({ err }, "redis error")); // log it, do not crashasync function safeGet(key) { try { return await redis.get(key); } catch { return null; } // treat any Redis failure as a miss}Caching in Next.js and HTTP
Caching exists in layers, and Redis is only one of them. A request may be answered by the browser cache, a CDN, Next.js, Redis, or the database.
| Layer | Controlled by | Invalidate with |
|---|---|---|
| Browser | Cache-Control: max-age, ETag | A new URL (hashed file name) or a short max-age |
| CDN / shared cache | Cache-Control: s-maxage, stale-while-revalidate | A purge or tag invalidation |
| Next.js server | "use cache" with cacheLife and cacheTag | revalidateTag, updateTag |
| Redis | Your own code, TTL | DEL, version in the key |
| Database | Its own buffer cache | Not your job |
- In Next.js 16,
"use cache"stores a function's result in server memory by default. With several servers, each has its own copy. The Next.js docs say a custom cache handler can keep the cache in Redis so all instances agree. Also,revalidateTag()is local to one instance unless the handler writes the invalidation to shared storage (updateTags()) and reads it again (refreshTags()). - The details of
"use cache",cacheLifeandrevalidateTagare in the Next.js lesson. The headers are in Browser, Lesson 05. - Rule of thumb: cache as close to the user as the data allows. Public, same-for-everyone data belongs at the CDN. Private, per-user data belongs in Redis or the server, never in a shared cache without a per-user key.
Common mistakes
- No TTL on cache keys, so memory grows until eviction or an error.
- A key that leaves out something that changes the answer (user, language, tenant). Result: users see each other's data.
- Caching errors. A timeout from the database stored as "no data" for 5 minutes.
- Using
KEYSin production. - A cache write on the request path that can fail the request. Cache failures should mean "slower", not "down".
- Treating a Redis lock as a guarantee of mutual exclusion.
- Using Redis pub/sub as a job queue.
- Storing huge JSON blobs. They cost memory and network time on every read.
- Not measuring. Without hit rate and eviction numbers you are guessing.
Interview questions
(Must know) Explain cache-aside, read-through, write-through and write-behind. Which one do you use by default?
In cache-aside, my code checks the cache first. On a miss it reads the database and then fills the cache. On a write I update the database and delete the cache key. In read-through, the cache layer does the loading itself, so the app only talks to the cache. In write-through, every write goes to the cache and the database together, so the cache is always fresh but writes are slower. In write-behind, the app writes to the cache and a background job updates the database later. Writes are very fast, but I can lose data if the cache dies first. My default is cache-aside with a TTL, because it is simple, the cache only holds data that was actually asked for, and if Redis is down the app still works from the database. I use write-behind only for data like view counts, where a small loss is fine.
(Must know) How do you choose a TTL?
I ask "how stale may this data be, and how bad is it when it is?". A TTL is my safety net: even if I forget an invalidation, the data fixes itself after the TTL. For public pages that can be old, I use minutes. For permissions or prices I use a very short TTL or I invalidate on change. I add a small random extra to each TTL (jitter), so keys filled at the same time do not expire at the same time. I give "not found" results a short TTL, such as 30 seconds. I check the hit rate and stale-data complaints, and then adjust.
(Must know) After I update a record, do I update the cache or delete the key? In what order?
I delete the key, after the database write succeeds. Deleting is simpler than rebuilding the exact cached value in the write path, and the next read fills the cache with the correct data. The order matters: if I delete the key first and write second, a reader can load the old row in between and put the old value back. Write first, then delete, makes that window much smaller. A tiny race still exists (a slow reader that loaded the old row before my write and sets it after my delete), so I always keep a TTL as the last safety net. If the data must be exact at all times, I do not cache it, or I add a version column and check it.
(Trap) A product key expires at peak time and the database falls over. What happened and how do you fix it?
That is a cache stampede. The key expired, hundreds of requests missed together, and all of them ran the same slow query. I measured 50 database calls for 50 concurrent misses. The fixes, from simple to strong: share one in-flight load per process with a Map of promises (single-flight); across servers, take a short lock with SET lock:key token NX PX 10000 so only one request loads while the others wait and re-read the cache (I ran this with 5 processes and the database was called once); refresh keys a little before they expire, with a probability that grows as expiry comes closer; or serve the old value and refresh in the background. I also add jitter to TTLs, so many keys do not expire in the same second. The follow-up is: what if the lock holder crashes? The lock has a TTL, so waiters time out and load the data themselves.
(Senior signal) What is the difference between cache penetration, cache avalanche and cache stampede?
Penetration: requests for keys that do not exist, so every request misses and hits the database (an attacker can do this on purpose). I cache the "not found" answer for a short time and validate the input first. Avalanche: many keys expire at the same time, or Redis restarts empty, and the database gets everything at once. I use jittered TTLs, warm up important keys, and limit how many loads may run at once. Stampede: a single hot key expires and many requests rebuild it at once. I use a lock or single-flight. In one line: penetration is about keys that never exist, avalanche is about many keys, stampede is about one hot key.
(Must know) Which Redis data type do you pick for a session, a leaderboard, a "who liked this" list, and unique visitors?
A session is a hash: fields like userId and role live under one key, and I can change one field without rewriting everything, plus I put a TTL on the key. A leaderboard is a sorted set: ZADD sets a score, ZREVRANGE ... WITHSCORES gives the top N, and ZREVRANK gives a user's position, all fast. "Who liked this" is a set, because members are unique and I can check membership quickly. Unique visitors can use a HyperLogLog: about 12 KB per key and a small error of under 1 percent, no matter how many visitors, but I cannot list them. If I need the exact number or the list, I use a set and accept more memory. For a plain cached page or a JSON blob I just use a string.
(Must know) RDB vs AOF: how does Redis save data, and which do you choose?
RDB is a snapshot. Redis forks a child process that writes the whole dataset to a compact file every so often. It restarts fast and is good for backups, but I can lose everything since the last snapshot. AOF logs every write command. With the default appendfsync everysec, I lose about one second at most. The file is bigger and restart is slower, because Redis replays the log. I can use both, and then Redis uses the AOF at restart because it is more complete. For a pure cache I use no persistence, since the data can be rebuilt. For data I care about, I use both. I also say clearly that persistence is not a backup, and that replication is asynchronous, so failover can still lose the last writes.
(Trap) You set maxmemory and the policy is volatile-lru. Writes start failing with an OOM error. Why?
volatile-* policies only evict keys that have a TTL. If most keys have no TTL, there is nothing allowed to be evicted, so Redis behaves like noeviction and rejects writes. I reproduced it: writes failed with "OOM command not allowed when used memory > 'maxmemory'". Also, the default policy is noeviction itself, and a fresh install has no maxmemory at all. For a pure cache I set maxmemory and allkeys-lru (or allkeys-lfu). I make sure every cache key has a TTL anyway. I also leave some RAM free, because the buffers for replication and the AOF are not counted against maxmemory.
(Senior signal) LRU or LFU? Redis does not do exact LRU. What does it do?
LRU evicts the key that was not used for the longest time; LFU evicts the key used least often. Redis samples a few keys (default maxmemory-samples is 5) and evicts the best candidate among them. That uses far less memory than exact tracking, and it is close in practice. I pick LRU when recent use predicts future use. I pick LFU when there is a stable set of popular keys and I fear one-time scans. In a small test with a 3 MB limit, I read 20 keys many times, then wrote 2,400 keys once. LRU kept 1 of the 20 popular keys; LFU kept all 20. I watch INFO stats: the hit rate and evicted_keys tell me if the policy fits.
(Senior signal) Design a rate limiter on Redis for an API running on 10 servers.
The counters must be shared, so they live in Redis. The simplest is a fixed window: INCR rl:<user>:<window> and set an expiry on the first hit. The trap is that INCR and EXPIRE are two commands. If the process dies between them, the key never expires. I put both in one Lua script, which Redis runs as one step. For smoother limits I use a token bucket or a sliding window (a sorted set of timestamps), also as a Lua script, using the Redis TIME and not each server's clock. I return 429 with a Retry-After header. I also decide on failure: if Redis is down, a login route fails closed (or uses a local limit), while a public read route fails open. I keep a coarse limit at the edge, too. The full design is in the rate limiter lesson.
(Must know) Pub/sub vs streams: when do you use each?
Pub/sub pushes a message to whoever is subscribed right now. It stores nothing and delivers at most once. I tested it: publishing with no subscribers returned 0 and the message was gone, and a subscriber that was away missed messages. It is fine for live signals where a missed message does not matter (a typing indicator, "cache key changed, drop your local copy"). A stream stores messages in a log. A consumer group shares the work between workers, each message must be acknowledged with XACK, and messages that were delivered but never acknowledged can be claimed by another worker with XAUTOCLAIM. That gives at-least-once delivery, so my handlers must be idempotent. I use streams for order events and background jobs, and I trim them with MAXLEN. For a full job system I use a library such as BullMQ on top of Redis.
(Trap) Why can a Redis lock like SET key token NX PX 30000 still let two workers run at once?
Three reasons. First, the work may take longer than the TTL (a long garbage-collection pause or a slow call), so the lock expires and another worker takes it while the first one still runs. Second, Redis replication is asynchronous: if the primary dies after granting the lock but before the replica got it, the promoted replica has no lock and a second worker can take it. Third, expiry uses wall-clock time, so a clock jump can end a lock early. For release I always check the token and delete in one Lua script, so I never delete someone else's lock. I use these locks for efficiency, like "do not run this report twice". For correctness, I add a fencing token or use database constraints.
(Senior signal) What is Redlock, and would you use it?
Redlock takes the same lock on 5 independent Redis servers (no replication between them). The lock counts as held only if the client got a majority (3) quickly, within less than the lock's TTL. The usable time is the TTL minus the time spent taking the lock minus a clock-drift margin. It fixes the "single primary fails over" problem. But it still depends on timing, and the Redis docs themselves say that if correctness matters, you should use fencing tokens, and that Redis TTLs do not use a monotonic clock. Martin Kleppmann wrote a well-known critique, and the Redis author replied. I would not run 5 Redis servers just for a lock. For "avoid duplicate work", one Redis with SET NX PX is enough. For "never corrupt data", I use a database transaction, a unique constraint, or a fencing token checked by the resource, not a lock alone.
(Trap) Is Redis single-threaded? Does that mean there are no race conditions?
Redis executes commands one at a time, so each single command is atomic. That is the accurate way to say it. (Modern Redis uses extra threads for some network I/O and background jobs, so "single-threaded" is not quite right.) But my code still has races: GET then SET is two commands, and another client can run in between. For read-modify-write I use one atomic command (INCR, SET NX, HSETNX), a Lua script, or WATCH with MULTI/EXEC. Also note that MULTI/EXEC is not a database transaction: if one command fails inside, the others still run, and there is no rollback. And a slow command (KEYS *, a huge SMEMBERS) blocks every other client, so one bad query can slow the whole server.
(Must know) How do you use Redis safely from Node with ioredis or node-redis? Name three things to configure.
First, one shared client per process, created once, with an error handler, so a connection problem is logged and does not crash the process. Second, timeouts and failure mode. With ioredis defaults, a command to a dead server waited about 21 seconds before failing in my test. For a cache I set a short commandTimeout, turn off the offline queue (enableOfflineQueue: false), and wrap cache calls in try/catch so any Redis failure is treated as a miss. Third, batching: a pipeline for many independent commands (it is not atomic), MULTI for a block, and Lua for read-modify-write. Also, a subscriber needs its own connection. And on shutdown I close the client cleanly, with quit() in ioredis and close() in node-redis.
(Trap) You upgrade ioredis from 5 to 6 and some pub/sub code behaves differently. What changed?
ioredis 6 uses RESP3 by default, and it needs Node.js 20 or newer. RESP is the protocol between client and server. In RESP2 a subscribed connection can only run subscribe commands; any other command throws "Connection in subscriber mode". In RESP3 the same connection can run normal commands such as GET. I tested both. If the old behaviour matters, I pass protocol: 2. This is also why old blog posts can disagree with what you see today. I always check the client's changelog before a major upgrade. In the same way, node-redis has renamed quit() to close() and disconnect() to destroy().
(Senior signal) Your Next.js app runs on 4 instances with "use cache". After you call revalidateTag, users still see old data for a while. Why, and what do you do?
By default each instance keeps its own cache in memory, and revalidateTag() only invalidates the cache of the instance that handled the call. The other three keep serving the old entry until their own lifetime ends. The Next.js docs say to use a custom cache handler backed by shared storage such as Redis: updateTags() writes the invalidation to Redis, and refreshTags() is called before requests to read recent invalidations into the local state. Then all instances agree within a short time. I also keep cacheLife short enough that a missed invalidation is not a disaster, and I catch errors inside refreshTags(), so a Redis outage does not fail every request. Details of "use cache" are in the Next.js lesson.
(Senior signal) Redis is down. What should happen to your app?
It should get slower, not fail. Every cache call has a short timeout, and an error is treated as a miss, so reads go to the database. The danger is the database overload: all traffic now hits it, so I protect it with a limit on concurrent loads, a small in-process cache for the hottest keys, and rate limits or load shedding. Writes that only update the cache (such as write-behind) are the real risk, so I avoid putting must-keep data only in Redis. For sessions, if they are only in Redis, users are logged out, so I decide in advance whether that is acceptable. For a lock or rate limiter, I decide fail-open or fail-closed per route. I monitor the hit rate and the Redis errors, and I run a Redis replica with automatic failover (Sentinel or a managed service), accepting that a failover can lose the last few writes.
Key takeaways
- A cache is a copy that may be wrong or gone. Plan how it is filled, how it is removed, and what the app does without it.
- Default to cache-aside: read the cache, then the database, then fill; on write, update the database first, then delete the key. Always have a TTL, with jitter.
- Stop stampedes with single-flight, a short lock with
NX PX, early refresh, or serving stale data. Cache "not found" for a short time. - Pick the data type for the question: hash for sessions, sorted set for ranking, set for membership, stream for reliable queues, HyperLogLog for approximate unique counts.
- RDB is a snapshot, AOF is a log (
everysecby default). Use both for safety, none for a pure cache. Neither is a backup. - The default policy is
noeviction;volatile-*policies do nothing without TTLs. Useallkeys-lruorallkeys-lfufor a cache, and watch hit rate andevicted_keys. - Pub/sub is at-most-once with no history; streams keep messages and add acknowledgements.
- A Redis lock is for efficiency. For correctness add fencing tokens or use the database. Release with a token check in Lua.
- A cache call must never take your site down: short timeouts, fail fast, treat errors as misses.
What's next
Back to the full list of full-stack lessons, or revisit the design view of caching in Design Problems, Lesson 01. When you practise, say each answer out loud with one number or one trade-off, as in the questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Cost, Latency and Caching for LLM Features · AI Engineering
- HTTP Caching: Cache-Control, ETags and the CDN in Between · The Browser Contract
- “The UI Is Fast but the API Takes Too Long”: How to Solve It · Scenario Debugging
Part of the study guide
Related series