All lessons
DataIntermediate11 min readIn my stack

Redis & caching

Make slow things fast — and keep them correct when the data changes.

  • Cache-aside
  • TTL
  • Invalidation
  • Pub/sub
  • Rate limits
Step 1/4
No cache: every request goes to the database. 120 milliseconds, every time.

In 30 seconds

Redis is a very fast database that keeps data in memory instead of on disk. Teams use it to remember the answers to slow questions (caching), to hold sessions, to count requests for rate limits and to pass jobs and messages between processes. The hard part is not speed — it is deciding how long cached data may live and how to throw it away when the truth changes.

Why I use it

  • I use Redis because it removes the most expensive work from hot paths: the same menu, price list or settings request should not hit the database thousands of times a minute.
  • I use it as the backbone for background work and real-time features — queues for jobs and pub/sub so every server instance can push the same event to connected clients.
  • I use it for counters and short-lived state (rate limits, OTP attempts, locks) because atomic in-memory operations are simpler and safer than doing the same with SQL rows.
  • I treat caching as a correctness problem first: a fast wrong answer is worse than a slow right one, so every cache I add has a clear owner, a TTL and an invalidation rule.

How it works

01What Redis actually is

Think of Redis as a giant shared dictionary that lives in RAM. Reading from memory takes microseconds, while a typical database query takes milliseconds, so Redis is often 100 times faster for simple lookups. But Redis is more than key → string. It offers data structures: strings (and counters), hashes (an object with fields), lists (queues), sets (unique members), sorted sets (leaderboards, time-ordered items), streams (an append-only log with consumer groups), plus bitmaps, HyperLogLog and geo indexes. Every command runs atomically on a single main thread, which is why INCR on a counter is safe even with many clients. Licensing note for 2026: Redis 8 is available under AGPLv3 (alongside its source-available licences), and Valkey is a BSD-licensed, Linux Foundation fork that most clouds offer as a drop-in replacement.

  • String: cached JSON, counters, flags
  • Hash: a user session or a settings object
  • List / Stream: job queues and event logs
  • Sorted set: rankings, scheduled items, sliding-window rate limits
bash
SET product:42 '{"name":"Latte","price":1500}' EX 300
GET product:42
INCR rate:203.0.113.10:2026-11-05T10:01
HSET session:abc user_id 7 role cashier
EXPIRE session:abc 1800
ZADD leaderboard 980 player:1 1200 player:2
ZREVRANGE leaderboard 0 9 WITHSCORES
TTL product:42

02Caching patterns: cache-aside, write-through, write-behind

Cache-aside (lazy loading) is the default: the app asks Redis first; on a miss it reads the database, stores the result with a TTL and returns it. Only data that is actually requested gets cached, and if Redis dies the app still works, just slower. Write-through means every write goes to the database and the cache at the same time, so reads are always warm — good for small, read-heavy data like settings. Write-behind writes to the cache first and flushes to the database later; it is fast but can lose data, so I avoid it for anything financial. Whatever the pattern, the database stays the source of truth. A cache is a copy, and you must be able to delete all of it without losing anything.

ts
async function getMenu(branchId: string) {
  const key = `menu:v3:${branchId}`;
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);
 
  const menu = await db.menu.findForBranch(branchId);
  // TTL with jitter so many keys do not expire at the same moment
  const ttl = 600 + Math.floor(Math.random() * 60);
  await redis.set(key, JSON.stringify(menu), "EX", ttl);
  return menu;
}
 
async function updateMenu(branchId: string, data: MenuInput) {
  await db.menu.update(branchId, data);
  await redis.del(`menu:v3:${branchId}`); // invalidate after the write commits
}

03TTL, invalidation and the cache stampede

There are two ways cached data goes away: it expires (TTL) or you delete it on purpose (invalidation). Use both: invalidate on writes you control, and keep a TTL as a safety net for the writes you forgot. Put a version in key names (menu:v3:…) so a change in data shape never reads old blobs. Delete after the database transaction commits, not before, or a concurrent reader can refill the cache with stale data. A cache stampede (thundering herd) happens when a hot key expires and hundreds of requests miss at once and all hit the database. Fixes: add random jitter to TTLs, let only one request rebuild the value using a short lock (SET key NX EX), serve the stale value while one worker refreshes it, or refresh popular keys ahead of expiry in the background.

ts
// One rebuilder per key; others wait briefly or serve stale data
const lock = await redis.set(`lock:${key}`, "1", "NX", "EX", 10);
if (lock === "OK") {
  try {
    const fresh = await loadFromDb();
    await redis.set(key, JSON.stringify(fresh), "EX", 600);
  } finally {
    await redis.del(`lock:${key}`);
  }
}

04Sessions, rate limiting and locks

Redis is a natural home for short-lived shared state. Sessions: store the session in a hash with a TTL, so any app server can read it and logging out is a single DEL. Rate limiting: a fixed window is INCR on a key like rate:{ip}:{minute} with EXPIRE; it is simple but allows bursts at window edges. A sliding window uses a sorted set of timestamps, or a token bucket in a small Lua script, for smoother limits. Use the same idea to cap OTP attempts and login failures per phone or account, not only per IP. Locks: SET lock:order:42 token NX EX 30 gives one worker exclusive work for 30 seconds; release it only if the token still matches, otherwise you may delete someone else's lock. For real money, a database constraint or idempotency key is still the final guard.

lua
-- Fixed-window limit: 100 requests per 60 s, atomic in one call
-- KEYS[1] = rate key, ARGV[1] = limit, ARGV[2] = window seconds
local n = redis.call("INCR", KEYS[1])
if n == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if n > tonumber(ARGV[1]) then
  return 0
end
return 1

05Queues and pub/sub

Lists and streams make Redis a capable job broker. Libraries such as BullMQ (Node) and Laravel queues (PHP) store jobs in Redis and give you retries with backoff, delays, priorities and dead-letter handling. The web request stays fast because sending an email or syncing with an ERP happens in a worker. Pub/sub is different: a message is broadcast to everyone subscribed right now and is not stored. It is perfect for fan-out of real-time events across several WebSocket servers (socket.io's Redis adapter does exactly this), but if no one is listening the message is gone. When you need delivery guarantees or replay, use Streams with consumer groups or a real queue. Rule of thumb: pub/sub for 'tell everyone now', queues for 'make sure this happens'.

06Persistence, memory and eviction

Redis keeps data in RAM but can save it to disk. RDB writes periodic snapshots: compact and fast to restore, but you lose writes since the last snapshot. AOF (append-only file) logs every write; with appendfsync everysec you lose at most about a second. Many setups use both. For a pure cache, persistence can be off. For queues and sessions, turn AOF on. Always set maxmemory and an eviction policy: allkeys-lru or allkeys-lfu for a cache, noeviction for a queue (you want errors, not silently deleted jobs). Ideally keep the cache and the queue on separate instances or databases with different policies. Never expose port 6379 to the internet: bind to localhost or a private network, require a password or ACL users, and use TLS across networks.

bash
# /etc/redis/redis.conf (cache + small queue on a private host)
bind 127.0.0.1
protected-mode yes
requirepass change_me_long_random
maxmemory 1gb
maxmemory-policy allkeys-lfu
appendonly yes
appendfsync everysec
save 900 1

07When not to cache

Caching adds a second copy of the truth, and every copy can be wrong. Do not cache before you measure: often an index or a fixed N+1 query solves the problem with no new moving parts. Avoid caching data that must be exact at the moment of reading — stock levels at checkout, wallet balances, payment status — or cache it only for display and re-check from the database inside the transaction. Do not cache per-user responses under a shared key (a classic leak of one customer's data to another). Be careful caching things that change on almost every request: the hit rate will be near zero and you pay for the writes. Finally, if the dataset is tiny and the database is idle, an in-process memory cache or HTTP caching headers may be all you need.

Use it when

  • Read-heavy data that changes rarely: menus, catalogues, settings, translations, exchange rates.
  • Expensive computations or third-party API results that are fine to be a few minutes old.
  • Shared short-lived state across servers: sessions, OTP codes, rate-limit counters, locks.
  • Job queues and real-time fan-out between several app or WebSocket instances.

Skip it when

  • As the only copy of important data such as orders, payments or balances.
  • When a missing index or N+1 query is the real cause of slowness.
  • For values that must be exact at decision time, unless you re-check against the database.

Alternatives

  • Valkey

    BSD-licensed fork of Redis; drop-in compatible and offered by most cloud providers.

  • Memcached

    Simple, multi-threaded key-value cache with no data structures or persistence.

  • In-process cache (LRU map)

    Fastest possible, but each server has its own copy and invalidation is harder.

  • HTTP / CDN caching

    Cache whole responses at the edge with Cache-Control; great for public, non-personal pages.

Best practices

  1. Treat the database as the source of truth; you must be able to flush the cache at any time safely.
  2. Name keys with a prefix and version (app:menu:v3:{id}) and always set a TTL.
  3. Invalidate after the write commits, and add TTL jitter to avoid synchronized expiry.
  4. Protect hot keys from stampedes with a rebuild lock or stale-while-revalidate.
  5. Set maxmemory and the right eviction policy; use noeviction for queues.
  6. Keep Redis private (localhost or VPC), with a password/ACL, never on a public port.
  7. Monitor hit rate, memory, evictions and slow commands; avoid KEYS * in production (use SCAN).

Common mistakes

  • Caching user-specific data under a shared key and showing one user another user's data.
  • Deleting the cache before the database transaction commits, so stale data is written back.
  • Using pub/sub for jobs that must not be lost; messages vanish when nobody is subscribed.
  • Running with no maxmemory so Redis grows until the server runs out of RAM.
  • Storing huge values (megabyte JSON blobs) that block the single thread on every read.

Deep dive

08The single thread: why one slow command hurts everyone

Redis executes commands one at a time on its main thread. Since Redis 6, optional I/O threads (io-threads) read and write network sockets in parallel, but the commands themselves still run in sequence. That design is why every command is atomic, and also why one O(N) command stalls every other client. KEYS * on ten million keys, SMEMBERS on a huge set, LRANGE 0 -1 on a long list or DEL on a big hash can block for hundreds of milliseconds, and to the application that looks like a Redis outage. Iterate with SCAN, HSCAN and SSCAN in small batches. Delete big keys with UNLINK, which frees the memory in a background thread. Find offenders before they hurt you: SLOWLOG records commands above a threshold, LATENCY DOCTOR explains spikes, and redis-cli --bigkeys and --memkeys sample the keyspace without blocking it.

bash
# Find big keys and memory hogs (samples with SCAN, safe in production)
redis-cli --bigkeys
redis-cli --memkeys
 
# Commands slower than slowlog-log-slower-than (microseconds)
redis-cli SLOWLOG GET 10
redis-cli LATENCY DOCTOR
 
# Memory health and cache efficiency
redis-cli INFO memory | grep -E 'used_memory_human|mem_fragmentation_ratio'
redis-cli INFO stats  | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'
 
# Remove one big key without blocking the main thread
redis-cli UNLINK report:2026:full

09Replication, Sentinel and Cluster

A replica copies the primary asynchronously: the primary answers the client first and ships the write afterwards. If the primary dies just after acknowledging a write, failover can promote a replica that never saw it. The WAIT command blocks until N replicas confirm, which narrows that window but does not make Redis a strongly consistent database. Sentinel watches a primary and its replicas, votes on failure and promotes a replica; clients ask Sentinel where the current primary is. Cluster solves a different problem — data larger than one machine — by splitting the keyspace into 16,384 hash slots spread across several primaries. Multi-key commands, transactions and Lua scripts only work when all keys hash to the same slot, otherwise you get a CROSSSLOT error. Hash tags fix this: only the part inside {braces} is hashed, so related keys land together.

bash
# Only "user:42" inside the braces is hashed -> same slot
SET {user:42}:profile '{"name":"Ana"}'
SET {user:42}:cart '{"items":3}'
MGET {user:42}:profile {user:42}:cart
 
CLUSTER KEYSLOT {user:42}:cart
# Without a shared hash tag, MGET across slots returns CROSSSLOT

10The race condition every cache-aside system has

Cache-aside has a classic race. Reader A misses the cache and reads the old row. Writer B updates the row and deletes the key. Reader A, slightly slow, now writes the old value back into the cache, where it stays until the TTL expires. Deleting after commit makes the window small, not zero. Several defences, from cheap to strong: keep TTLs short for data where staleness matters; delete again a moment later (delayed double delete) from a queue job; store a version or updated_at with the cached value and refuse to overwrite a newer one, using a small Lua script; or drive invalidation from the database itself with a transactional outbox or change data capture, so no code path can forget it. For very hot reads, Redis client-side caching (CLIENT TRACKING, since Redis 6) lets the server push invalidation messages to application-level near caches.

11Memory internals: eviction, encodings and per-field TTL

Eviction is approximate. Instead of tracking a perfect LRU order, Redis samples a few keys (maxmemory-samples, default 5) and evicts the best candidate; raising the sample size gets closer to true LRU at some CPU cost. LFU keeps a small logarithmic access counter per key that decays over time, so a key that was popular yesterday eventually becomes evictable. Small hashes, lists, sets and sorted sets are stored in compact listpack encodings, which is why many small hashes often use less memory than many separate string keys. Check one key with MEMORY USAGE and watch mem_fragmentation_ratio; active defragmentation (activedefrag yes) can reclaim fragmented memory online. Since Redis 7.4 hash fields can expire individually with HEXPIRE, useful for sessions where an OTP field should vanish after 60 seconds while the session itself lives on. A key-level TTL still removes the whole hash, whatever the field TTLs are.

Real-world scenarios

01

A deploy turned every cache key cold

Problem

A release changed the key prefix from menu:v3 to menu:v4. Within a minute every request missed the cache, database CPU hit 100% and checkout latency went from 80 ms to several seconds.

How to solve it

Short term, roll back to the old prefix or scale read traffic onto a replica so the database recovers. Then fix the process. Key-version changes must be warmed up before traffic switches: a job iterates active branches and fills menu:v4 while the old code still reads v3. Add request coalescing (one rebuild per key behind SET lock NX EX) so concurrent misses never multiply into database load. Add TTL jitter so the new keys do not expire together later. Finally, put cache hit rate and database connections on the deploy dashboard with an alert, so a cold cache is visible in seconds rather than through customer complaints.

02

Background jobs vanished under memory pressure

Problem

One Redis instance held both the cache and the job queue with maxmemory-policy allkeys-lru. During a traffic spike Redis evicted queue keys, and some confirmation emails and webhooks were never sent.

How to solve it

Split the workloads. Move the queue to its own instance (or a separate managed database) with maxmemory-policy noeviction and AOF enabled, so a full queue produces errors you can alert on instead of silently losing jobs. Keep the cache on allkeys-lfu. Size both from real metrics: used_memory, evicted_keys and queue depth. Because emails and webhooks were lost, replay them from the source of truth: query orders in the affected window whose notification status is still pending and enqueue them again with idempotency keys, so customers who did get a message do not get it twice.

03

Customers saw old prices after an update

Problem

Admins changed prices, the API deleted the cache key after commit, yet a few branches showed the old price for ten minutes. It happened only under load and could not be reproduced locally.

How to solve it

This is the cache-aside race: a slow reader loaded the old row before the update and wrote it back after the delete. Confirm it by logging cache writes with the row's updated_at. Then make writes conditional: store the value together with its version and set it through a short Lua script that refuses to overwrite a newer version. Add a delayed second delete from a queue job a few seconds after the update as a cheap safety net, and shorten the TTL for price data. At checkout, read prices from the database inside the transaction anyway, so a stale cache can only affect display, never a charge.

Cheat sheet

Iterate keys without blocking
SCAN 0 MATCH menu:v3:* COUNT 200
Delete a big key in the background
UNLINK key
Lock with owner token and timeout
SET lock:order:42 <token> NX EX 30
Expire one hash field (Redis 7.4+)
HEXPIRE session:abc 60 FIELDS 1 otp
Check remaining lifetime
TTL key (-1 = no TTL, -2 = missing)
Memory used by one key
MEMORY USAGE key
Find big keys safely
redis-cli --bigkeys
See slow commands
SLOWLOG GET 10
Cache hit rate
INFO stats -> keyspace_hits / (keyspace_hits + keyspace_misses)
Which cluster slot a key uses
CLUSTER KEYSLOT {user:42}:cart

In my real projects

  • In a multi-tenant restaurant POS SaaS, Redis pub/sub fans out real-time events to socket.io rooms per company, branch, terminal and user.
  • The same platform runs about 20 BullMQ queues on Redis, fed by a transactional outbox, including HMAC-signed webhooks delivered via the queue.
  • On that product's dedicated server, Redis runs in Docker Compose next to PostgreSQL, BullMQ workers and a Prometheus, Grafana and Loki observability stack.

Questions & answers

Q1What is the difference between cache-aside and write-through?

With cache-aside the application owns the logic: read from the cache, and on a miss load from the database, store the value with a TTL and return it. Writes go to the database and then invalidate the key. It caches only what is used and survives a cache outage. With write-through every write updates the database and the cache together, so reads are always warm, at the cost of writing data nobody may read. I default to cache-aside with explicit invalidation plus a TTL, and use write-through for small, very hot configuration data.

Q2How do you prevent a cache stampede?

A stampede happens when a popular key expires and many requests rebuild it at once, overloading the database. I combine several defences. TTLs get random jitter so keys do not expire together. Rebuilding is guarded by a short lock with SET NX EX, so one request recomputes while the others wait briefly or get the previous value. For the hottest keys I use stale-while-revalidate: keep serving the old value and refresh it in the background before it expires. On the database side, sensible connection pool limits stop one bad minute from becoming an outage.

Q3Redis is in memory. What happens if it restarts?

It depends on persistence settings. With RDB snapshots you lose writes since the last snapshot; with AOF and appendfsync everysec you lose about one second at most; with persistence off you lose everything. For a pure cache that is acceptable, because the database is the source of truth and the cache warms up again — though you should expect extra load during warm-up. For queues and sessions I enable AOF, and for critical workloads use a replica with automatic failover through Sentinel or a managed service. Important business data never lives only in Redis.

Q4How would you implement rate limiting with Redis?

The simplest version is a fixed window: INCR a key such as rate:{client}:{minute} and set EXPIRE when the counter is first created, all in one Lua script so it is atomic. If the count exceeds the limit, return HTTP 429 with a Retry-After header. Fixed windows allow bursts at the edges, so for stricter limits I use a sliding window with a sorted set of timestamps or a token bucket. I limit by the right identity — IP, API key, account or phone number for OTP — and apply tighter limits to sensitive endpoints like login and payments.

Q5When would you choose Redis pub/sub versus a queue?

Pub/sub broadcasts a message to every subscriber connected at that moment and stores nothing. That suits real-time fan-out, for example sending an order-status event to every WebSocket server so the right client gets it. A queue stores the job until a worker processes it, with retries, backoff and failure tracking. That suits work that must happen exactly as asked: emails, webhooks, ERP sync, receipts. If I need replay or several consumer groups reading the same log, I use Redis Streams or a dedicated broker such as Kafka.

Q6What eviction policy do you set, and why?

For a cache I set maxmemory and usually allkeys-lfu or allkeys-lru, so Redis removes the least useful keys when memory is full instead of failing writes. LFU keeps frequently used items even if they were not touched in the last second. For queues, sessions and locks I use noeviction, because silently deleting a job or a session is worse than getting an error you can alert on. Since one instance has one policy, I prefer separate instances or logical separation for cache and queue workloads, and I monitor the evicted_keys metric.

Senior-level questions

S1Is a Redis lock safe enough to protect a payment or stock decrement?

Not on its own. A SET NX EX lock is a good efficiency lock: it stops two workers doing the same expensive job most of the time. It is not a correctness guarantee. A process can pause — garbage collection, a slow network, a VM freeze — past the lock's expiry, while another worker takes the lock and both write. Failover can also lose a lock that was never replicated. Redlock across several instances reduces some failure modes but still depends on timing assumptions. For correctness the final guard belongs in the system of record: a unique constraint, an idempotency key, a conditional update such as UPDATE stock SET qty = qty - 1 WHERE qty > 0, or a fencing token checked by the database. I keep Redis locks to prevent duplicate work, and let the database enforce the invariant.

S2Sentinel, Cluster or a managed service — how do you choose?

Start from the problem. If the data fits comfortably in one machine's memory and you need high availability, a primary with replicas and Sentinel, or a managed equivalent, is simpler: every command works, Lua scripts and transactions have no slot rules, and clients stay simple. Choose Cluster when you need more memory or write throughput than one node can provide; the price is hash-slot design, CROSSSLOT limits, cluster-aware clients and harder operations such as resharding. Managed services remove patching, backups and failover drills, at higher cost and less control over versions and modules. For most product backends I have seen, a single primary sized correctly, with persistence and a replica, goes very far; sharding is a decision driven by measurements, not by fashion.

S3How do you design caching for a multi-tenant SaaS?

Three concerns: isolation, fairness and invalidation. Isolation: the tenant ID is always part of the key, built by one helper function, never by string concatenation in random places, and per-user data includes the user ID. A missing tenant prefix is a data leak, so I test key builders directly. Fairness: one large tenant can fill memory and evict everyone else's keys, so I watch memory per key prefix, cap the size of cached values and, for very large tenants, consider dedicated instances. Invalidation: tenant-wide changes such as a new menu or price list should bump a per-tenant version number stored in Redis and embedded in keys, so one INCR invalidates everything for that tenant without scanning or deleting thousands of keys; old entries then simply age out by TTL.

S4When would you replace pub/sub with Redis Streams, and what changes?

When losing a message is no longer acceptable or consumers need to catch up. Pub/sub is fire-and-forget: a subscriber that reconnects after a deploy misses everything published meanwhile. Streams store entries in an append-only log; consumer groups track what each consumer has read, entries stay pending until acknowledged with XACK, and XAUTOCLAIM lets another consumer take over messages a crashed worker never finished. The costs are real: you must trim the stream (MAXLEN or MINID) or memory grows forever, design idempotent handlers because redelivery happens, and monitor pending-entry lists. For live UI fan-out across WebSocket servers I still use pub/sub because a missed event is repaired by the next fetch. For anything that triggers business actions, I want Streams, a job queue or a dedicated broker.

Glossary

TTL
Time to live: how many seconds a key exists before Redis deletes it automatically.
Hit ratio
Share of reads answered from the cache instead of the database; the main measure of a cache's usefulness.
Eviction
Redis removing keys to stay under maxmemory, chosen by the configured policy such as LRU or LFU.
Cache stampede
Many requests missing the same expired key at once and all rebuilding it from the database.
Hot key
A single key read or written so often that it concentrates load on one thread or one cluster node.
Big key
A key holding a very large value or collection, which makes commands on it slow and blocking.
Hash slot
One of 16,384 buckets in Redis Cluster; each key maps to one slot, each slot to one node.
Hash tag
The {part} of a key name that alone is hashed, forcing related keys into the same cluster slot.
Sentinel
A Redis process group that monitors a primary, detects failure and promotes a replica automatically.
Fencing token
An increasing number issued with a lock, so storage can reject writes from a holder whose lock expired.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.