Redis Design Patterns: A Practical Guide to Locks, Counters, CAS, and Lua

A field guide to Redis design patterns that actually hold up in production: distributed locks, atomic counters, rate limiters, compare-and-swap, cache-aside, pub/sub — with real redis-cli commands, Lua scripts, interactive diagrams, and the antipatterns that quietly take down clusters.

Rahul Bisht

Founder, CrawlPilot

·
Jul 15, 2026
·Engineering·
20 min read
·
Redis Design Patterns: A Practical Guide to Locks, Counters, CAS, and Lua

Redis looks simple enough that it's easy to reach for it without a plan: SET, GET, EXPIRE, done. Then production happens — two processes think they hold the same lock, a rate limiter lets through triple the allowed traffic, KEYS * freezes the cluster during a demo — and the gap between "Redis is easy" and "Redis is easy to misuse" becomes obvious.

This is a practical, pattern-by-pattern guide to the ways Redis actually gets used in real systems: distributed locks, counters, rate limiters, compare-and-swap, caching, pub/sub, and the Lua scripts that make several of those patterns safe instead of merely fast. Every pattern below ships with the actual redis-cli commands or Lua script, not pseudocode.


The One Fact That Explains Most of Redis's Behavior

Redis executes one command at a time, on a single thread, from an in-memory dataset. That single fact is why SET key value NX is atomic (nothing else can run between the existence check and the write), why KEYS * freezes every other client (the one thread is busy scanning), and why persistence (RDB snapshots, AOF logs) happens around that thread rather than blocking it.

Two consequences fall directly out of this, and both explain why the patterns below look the way they do:

  • Any single command is automatically atomic — no other client's command can interleave with it. This is why SET lock NX EX 30 is safe on its own, but SETNX followed by a separate EXPIRE is not: two commands means a gap another client's command can land in.
  • A slow command blocks everyoneKEYS * on a 10-million-key database, HGETALL on a hash with a million fields, or a Lua script with a loop over an unbounded collection all hold the one thread hostage for their entire duration. Every antipattern in this guide traces back to forgetting this.

The Data Structures, at a Glance

TypeShapeWhat it's actually for
Stringbytes / numbercounters, flags, JSON blobs, locks, idempotency keys
Hashfield → value mapobjects with named fields (sessions, cached rows) without one key per field
Listordered sequencesimple queues, recent-activity feeds (bounded with LTRIM)
Setunordered unique membersmembership tests, tag sets, deduplication
Sorted Set (ZSet)member → score, ordered by scoreleaderboards, sliding-window rate limiters, priority queues
Bitmapstring treated as a bit arrayBloom-filter-style membership, feature flags per user id, daily active user tracking
Streamappend-only log with consumer groupsdurable event queues (unlike Pub/Sub — see the caveat later)

Pattern 1: Distributed Lock

The problem: several processes across different machines need to agree that only one of them runs a critical section at a time — booking the last seat, processing a payment webhook exactly once, running a scheduled job on exactly one replica.

Acquire — one atomic command, not two

bash
# SET with NX (only if not exists) + EX (expiry) in a single atomic command SET lock:invoice:8842 "worker-7f3a" NX EX 30 # → OK lock acquired, expires in 30s even if the holder crashes # → nil someone else already holds it

worker-7f3a must be a value unique to the caller (a UUID, or pod name + thread id) — not a constant like "1". That value is what makes a safe release possible.

lua
-- NEVER do this to release a lock: DEL lock:invoice:8842 -- If your lock already expired and someone else acquired it, this deletes -- THEIR lock. You just handed the critical section to two processes at once.

Release — verify ownership and delete atomically

bash
EVAL " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end " 1 lock:invoice:8842 "worker-7f3a" # → 1 released (you were the owner) # → 0 not the owner — your lock already expired and was re-acquired

This has to be Lua, not two separate commands (GET then DEL), because a GET-then-DEL from the client leaves a gap: another process's lock can be acquired in between your check and your delete. Lua runs the whole thing as one atomic step inside the server.

That sequence is the actual failure mode behind the well-known Redlock controversy: even a "correct" locking protocol can't stop a paused process from acting on a lock it no longer holds. The fix isn't a cleverer lock — it's a fencing token: hand out a monotonically increasing number with the lock, and make the protected resource itself reject any write carrying an older token than the last one it saw. The lock tells you who should be acting; the fencing token is what the resource checks to make sure a stale acquirer's write can't land after a newer one already did.

Extend the lock (heartbeat) while the work is still running

bash
EVAL " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end " 1 lock:invoice:8842 "worker-7f3a" 30
flow
acquire → SET NX EX 30 — only one process gets OK hold → run the critical section heartbeat → extend TTL periodically via Lua, only if still owner release → Lua GET+DEL, only if still owner — back to acquire for the next run

Locking several resources at once, all-or-nothing

lua
-- Lock N keys atomically or lock none — no partial-lock deadlocks local owner = ARGV[1] local ttl = tonumber(ARGV[2]) for i = 1, #KEYS do if redis.call('EXISTS', KEYS[i]) == 1 then return 0 -- at least one is taken; acquire none end end for i = 1, #KEYS do redis.call('SET', KEYS[i], owner, 'EX', ttl) end return 1

[!WARNING] A lock that's never released on the failure path is a lock that hangs the system, not a bug that just affects one request. Always set an EX on acquire — a lock with no TTL turns one crashed worker into a permanent outage for that resource.


Pattern 2: Atomic Counters & Rate Limiting

The problem: many concurrent requests need to increment a shared count and make a decision (allow/deny) based on it — without two requests both reading "9" and both incrementing to "10" when the real answer should have been "11".

The counter itself is already atomic

bash
INCR metrics:orders:today # → 1, 2, 3... — no lost updates, ever INCRBY metrics:revenue:today 4999 # → atomic add, useful for totals in minor units

INCR alone is safe because it's one command on one thread. The moment you need "increment, and check a limit, and set an expiry the first time" — that's three decisions, and it needs Lua to stay atomic.

Fixed window rate limiter

lua
local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) -- only the first request sets the TTL end if current > tonumber(ARGV[2]) then return {0, redis.call('TTL', KEYS[1])} -- blocked, seconds until reset end return {1, 0} -- allowed
bash
EVAL "<script above>" 1 rate:user:42:api 60 100 # ARGV: window_seconds=60, max_requests=100 # → {1, 0} allowed # → {0, 45} blocked, resets in 45s

Fixed windows are simple but let through up to 2x the intended rate right at the boundary — 100 requests in the last second of one window, then 100 more in the first second of the next, both technically "within limit."

Sliding window (sorted set) — no boundary burst

lua
local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) local req_id = ARGV[4] redis.call('ZADD', KEYS[1], now, req_id) redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) -- drop anything older than the window local count = redis.call('ZCARD', KEYS[1]) redis.call('EXPIRE', KEYS[1], math.ceil(window / 1000) + 1) if count > limit then return 0 end return 1
flow
Requests hitting the endpoint (raw, unfiltered): every call, before any check → 10000 Still inside the 60s window (after trimming stale entries): ZREMRANGEBYSCORE drops anything older → 6200 Compared against the configured cap (ZCARD ≤ limit): the strict sliding-window check → 5000 Final result: 5000 allowed in this window, 1200 rejected with a 429 — no boundary double-burst like a fixed window would allow
StrategyBurst behaviorCostWhen to pick it
Fixed windowUp to 2x limit at window edges1 key, cheapestRough limits, internal tools, low stakes
Sliding window (ZSET)Smooth, no burst1 ZSET, one entry per requestPublic APIs, billing-adjacent limits
Token bucketAllows planned bursts, refills steadily1 key + a bit of client-side mathUser-facing APIs where occasional bursts are fine

Pattern 3: Compare-And-Swap (Optimistic Locking)

The problem: update a value only if it hasn't changed since you last read it — the classic "read balance, check it's sufficient, deduct" sequence, where two concurrent deductions must never both succeed against a balance that only covers one of them.

Redis gives you two different tools for this, and picking the right one matters.

Option A: WATCH / MULTI / EXEC — client-driven, retry on conflict

bash
WATCH account:42:balance GET account:42:balance # → "500" — read once, decide client-side # client checks: is 500 >= withdrawal amount (300)? yes, proceed. MULTI DECRBY account:42:balance 300 EXEC # → if account:42:balance changed since WATCH, EXEC returns nil (transaction aborted) # → the client must detect the nil and retry the whole read-check-write sequence
flow
watch → WATCH the key, then GET its current value decide → client-side: is the precondition still true? commit → MULTI + the write + EXEC conflict → EXEC returned nil — another client changed it first — retry from watch

This is true CAS: optimistic, cheap when contention is low, but it costs a full round trip per attempt and can retry indefinitely under high contention on the same key.

Option B: Lua — server-driven, no retry loop needed

lua
local balance = tonumber(redis.call('GET', KEYS[1])) local amount = tonumber(ARGV[1]) if balance >= amount then redis.call('DECRBY', KEYS[1], amount) return 1 -- success end return 0 -- insufficient funds, nothing changed

Same guarantee, zero round trips, and no retry loop — because nothing outside the script can run between the read and the write in the first place. There's nothing to conflict with.

WATCH/MULTI/EXECLua (EVAL)
Where the "decide" logic runsClientServer
Round trips per attempt3 (WATCH, GET, EXEC)1
Behavior under conflictClient must detect nil and retryNever conflicts — it's already atomic
Best forOne-off transactions, simple precondition checksAnything you'll call often, or with nontrivial logic

The practical rule: if you're about to write a client-side retry loop around WATCH, that's usually a sign the logic belongs in a Lua script instead.


Pattern 4: Cache-Aside (and the Stampede It Can Cause)

bash
GET cache:show:8842:metadata # → nil (miss) SET loading_lock:show:8842 1 NX EX 5 # → OK this process rebuilds the cache # → nil another process is already rebuilding — wait ~100ms and retry the GET SET cache:show:8842:metadata '{"title":"Avengers","screen":"IMAX"}' EX 300

Without the loading_lock, a popular key expiring under heavy concurrent traffic causes a cache stampede — hundreds of requests all miss at once and all hammer the database to rebuild the same value simultaneously. The lock ensures exactly one rebuild happens; everyone else waits milliseconds and reads the freshly-populated cache instead.


Pattern 5: Session Store

bash
HSET session:tok_abc123 user_id 42 username alice role admin created_at 1717890000 EXPIRE session:tok_abc123 3600 HMGET session:tok_abc123 user_id role # read only the fields you need EXPIRE session:tok_abc123 3600 # sliding expiry on activity SADD user:42:sessions tok_abc123 # index for "log out everywhere" SMEMBERS user:42:sessions | xargs DEL # invalidate every session for a user

A hash beats one key per field (session:tok:user_id, session:tok:role, ...) for the same reason a struct beats five separate variables: one EXPIRE, one HGETALL, and the fields travel together.


Pattern 6: Leaderboards (Sorted Sets)

bash
ZADD leaderboard:weekly 5000 "player:42" # set absolute score ZINCRBY leaderboard:weekly 150 "player:42" # or increment by a delta ZRANGE leaderboard:weekly 0 9 REV WITHSCORES # top 10, highest first ZREVRANK leaderboard:weekly "player:42" # 0-indexed rank ZSCORE leaderboard:weekly "player:42" # current score ZCOUNT leaderboard:weekly 1000 +inf # how many players above a threshold

ZRANGE ... REV and ZREVRANK are O(log N) — this is the whole reason a sorted set, not a sorted SQL query, backs almost every real-time leaderboard: ranking a million players stays fast because the structure keeps itself ordered on every write, instead of re-sorting on every read.


Pattern 7: Pub/Sub Fan-Out

hub
seat-updates:SHOW8842 (channel, fire-and-forget) → WebSocket worker pod 1: forwards to connected browsers → WebSocket worker pod 2: forwards to connected browsers → WebSocket worker pod 3: forwards to connected browsers → Analytics subscriber: logs event, no delivery guarantee if offline
bash
PUBLISH seat-updates:SHOW8842 '{"seat":"A1","status":"LOCKED"}' # → 4 number of subscribers that received it, right now SUBSCRIBE seat-updates:SHOW8842 PSUBSCRIBE "seat-updates:*" # one connection, all shows

[!WARNING] Pub/Sub has no persistence and no replay — a subscriber that's disconnected for even a second misses every message published during that gap, permanently. If a consumer being briefly offline and missing an update is unacceptable (order events, financial updates), that's not a Pub/Sub problem — reach for Redis Streams (XADD/XREADGROUP) instead, which keeps a durable log with consumer-group offsets, or a real message queue.


Pattern 8: Bloom Filter Approximate Membership

The problem: "have I seen this before?" against a huge set, without paying the memory cost of storing every item.

bash
# With the RedisBloom module: BF.ADD dedup:emails "user@example.com" # → 1 added BF.EXISTS dedup:emails "user@example.com" # → 1 (probably yes) BF.EXISTS dedup:emails "new@example.com" # → 0 (definitely no)

The one property that makes this safe to use for gating (not just hinting): zero false negatives, some tunable rate of false positives. It will never wrongly say "definitely not seen" for something it has seen — it will occasionally say "probably seen" for something it hasn't, at a rate you control by allocating more bits. That asymmetry is exactly right for a blocklist check (a false positive costs one extra confirming lookup; a false negative would let a known-bad actor straight through) and exactly wrong for anything where a false positive silently drops real data (don't use it to decide whether to skip processing an item).


Pattern 9: Idempotency Keys

bash
SET idempotency:req_abc123 "PROCESSING" NX EX 30 # → OK first request — proceed with processing # → nil a duplicate arrived while the first is still in flight — return 202, don't reprocess # On completion, overwrite with the real result so retries get the cached response: SET idempotency:req_abc123 '{"booking_id":"BOOK123","status":"CONFIRMED"}' EX 86400

The NX on the first SET is what prevents two near-simultaneous retries (a client's own timeout-and-retry, or a webhook redelivery) from both starting to process the same request — one wins the SET, the other gets nil and knows to back off.


Where Lua Actually Earns Its Keep

Every pattern above that needed Lua needed it for the same underlying reason: the operation is "read a value, make a decision based on it, then write" — and that whole sequence has to be one atomic unit, not three separate round trips a competing client's command could land in between.

bash
# Load once, get a hash back — avoids re-sending the script body on every call SCRIPT LOAD "<lua source>" # → "e3f6b1..." (SHA1 of the script) EVALSHA e3f6b1... 1 lock:invoice:8842 "worker-7f3a" # → runs the cached script by hash — same atomicity, less bandwidth per call
SituationPlain commands enough?Needs Lua / EVAL
Increment a counter, no limit checkYes — INCR alone is atomic
Increment, and enforce a limit + set TTL only onceYes (Pattern 2)
Release a lock you might not own anymoreYes (Pattern 1)
Read a balance and conditionally deduct itWATCH/MULTI/EXEC works, with a client retry loopYes, if you want zero round trips or high contention (Pattern 3)
Read-and-reset a metric atomicallyYes — GET then SET 0 from the client has a gap
Set a key only if absent, with an expiryNo script neededSET key val NX EX ttl is already one atomic command

The recurring signal: if your code would otherwise need WATCH, or a client-side "get, check, act" sequence, or two commands where a crash or a race between them would leave bad state — that's the shape of problem Lua exists for.


Antipatterns to Avoid

AntipatternWhy it hurtsDo this instead
KEYS * in productionScans the entire keyspace on the single thread — every other client stalls until it finishesSCAN with a cursor — same result, doesn't block
SETNX then a separate EXPIRE for a lockA crash between the two commands leaves a lock with no TTL — permanentSET key val NX EX ttl — one atomic command
HGETALL / SMEMBERS on unbounded collectionsA hash or set with a million fields blocks the thread for the entire readHSCAN / SSCAN — cursor-based, doesn't block
Treating Redis as the source of truth for money/inventoryRedis optimizes for speed, not the durability guarantees a ledger needsRedis as cache/lock/counter in front of an authoritative database
Large payloads in PUBLISHThe payload is copied to every subscriber connection — a 10MB message saturates the network N times overPublish a small pointer/id; subscribers fetch the payload from a store
SELECT in cluster modeRedis Cluster only has database 0 — this silently does nothing or errorsNamespace keys with prefixes instead of numbered databases
No maxmemory-policy set, then OOM under loadDefault is noeviction — writes start failing hard the moment memory fillsSet an eviction policy (allkeys-lru, etc.) deliberately, matched to whether the data is a cache or must never be evicted
A single giant key (a hash with 5M fields, a list with 10M entries)Any command touching it — even a single-field HGET in some cases, definitely HGETALL/DEL/expiry sweeps — takes proportionally longer and blocks the threadShard the collection across multiple keys (e.g. hash-tag by user id range)
Storing secrets/PII unencryptedRedis is often reachable by anything inside the VPC, with no per-key encryption at restEncrypt sensitive fields before writing, or don't put them in Redis at all

Before You Reach for Redis: A Decision Checklist

A short checklist worth running through before committing to Redis for a new use case:

  • Durability tolerance — if the process holding this data restarted right now and lost everything, is that a shrug or an incident? Redis is happiest as the shrug case.
  • Memory economics — Redis prices your data in RAM, not disk; a dataset that's cheap in Postgres can be expensive in Redis at the same size.
  • Persistence trade-off — RDB snapshots are cheap but can lose the last few minutes of writes on a crash; AOF with fsync always loses far less but costs write throughput. Pick deliberately, not by default.
  • Eviction policy — decide explicitly whether this key space is allowed to be evicted under memory pressure (allkeys-lru) or must never be (noeviction, with capacity planned to match).
  • Blocking command exposure — audit for KEYS, unbounded HGETALL/SMEMBERS, and any Lua script with an unbounded loop; each one is a latency spike waiting to happen at scale.
  • Cluster-mode fit — multi-key operations (MULTI, most Lua scripts) need all keys in the same hash slot in cluster mode; design key names with hash tags ({user:42}:profile, {user:42}:sessions) up front if you'll ever shard.
  • Hot key risk — a single wildly popular key (a viral post's like counter, one celebrity's session) can saturate a single shard even when the cluster overall has headroom; know your access pattern's skew before it surprises you in production.

Further Reading