Interview board / backend infrastructure / v1

Redis, by the shape of the value

The commands worth memorizing, the patterns interviewers are actually listening for, and the failure modes that separate a senior answer from a staff answer. Every command is tagged by what it does to the server: read, write, or atomic.

← all topics
read, does not mutate write, mutates state atomic block, blocking, or scripted trap, say it out loud before the interviewer does

01  One key, six shapes

Redis is not a cache with extra features. It is a set of in-memory data structures behind a network port. Almost every Redis question in a system design interview reduces to one decision: which shape do I store this in, and what does that shape make cheap? Here is the same subject, one game session, stored five ways, plus a sixth structure that only earns its place at population scale.

Read these six cards as alternatives, not as a picture of one server A key holds exactly one value, and that value has exactly one type. Two of the cards below use the key name game:42 on purpose, to show the same data in two different shapes; only one of them can exist at a time. Storing it as a string means GET game:42 hands back the whole blob of bytes you put there, and Redis has no idea it is JSON. Storing it as a hash instead means GET game:42 is rejected with a wrong-type error, and you read fields with HGET game:42 players. If a design genuinely needs two shapes of the same entity, they live under two different key names, which is why the list, set, and sorted set cards below use suffixed keys. The bitmap card deliberately leaves the game session behind: one bit per member only pays off across millions of users, and at the scale of a single match a set is cheaper and simpler.

StringBlob of bytes, up to 512 MB

game:42string
{"id":42,"map":"crossroads","players":8,"state":"live"}
SET game:42 '{"id":42,"map":"crossroads","players":8,"state":"live"}'
GET game:42
-> {"id":42,"map":"crossroads","players":8,"state":"live"}
-- Redis returns the exact bytes you stored. It does not parse the JSON.

Makes cheap: whole-object get and set, one round trip. Makes expensive: reading one field, and updating one field without a read-modify-write cycle.

HashField to value map under one key

game:42hash
mapcrossroads
players8
statelive
round3
HSET game:42 map crossroads players 8 state live round 3
HGET game:42 players        -> 8
HMGET game:42 map state     -> crossroads, live
HINCRBY game:42 round 1     -> 4, one atomic command, no read first

Makes cheap: atomic single-field increment, partial reads, memory-efficient small objects. Makes expensive: nothing much, which is why hashes are the default for object caching.

ListOrdered sequence, push and pop at both ends

game:42:eventslist
round_starthead
kill:u7
join:u9
kill:u3
match_starttail
LPUSH game:42:events match_start   -- oldest, pushed first
LPUSH game:42:events kill:u3
LPUSH game:42:events join:u9
LPUSH game:42:events kill:u7
LPUSH game:42:events round_start   -- newest, now at the head
LRANGE game:42:events 0 4
-> round_start, kill:u7, join:u9, kill:u3, match_start

Makes cheap: newest-first feeds, capped history, and first in first out work queues with a blocking pop. Makes expensive: reading or removing from the middle.

SetUnordered, unique members

game:42:playersset
u3
u7
u9
u12
u18
SADD game:42:players u3 u7 u9 u12 u18
SISMEMBER game:42:players u9   -> 1, meaning yes
SISMEMBER game:42:players u4   -> 0, meaning no
SADD game:42:players u7        -> 0, already present, nothing added
SCARD game:42:players          -> 5

Makes cheap: constant time membership tests, deduplication, and intersections between two sets computed on the server. Makes expensive: any notion of order or ranking.

Sorted setUnique members, each with a float score

game:42:scorezset
u71450
u31120
u18980
u9640
ZADD game:42:score 1450 u7 1120 u3 980 u18 640 u9
ZRANGE game:42:score 0 2 REV WITHSCORES
-> u7 1450, u3 1120, u18 980
ZREVRANK game:42:score u3      -> 1, second place, ranks start at 0
ZINCRBY game:42:score 200 u9   -> 840, u9 moves up the board

Makes cheap: rank lookups, top-K queries, range-by-score scans, and anything where a number orders the world (points, timestamps, priorities). This is the single most useful structure in interviews.

BitmapA string addressed one bit at a time, offset is the user identifier

retained:2day = dau:2026-08-14 AND dau:2026-08-15bitmap
dau:2026-08-14
1
0
1
1
0
1
0
0
dau:2026-08-15
1
0
1
0
0
1
1
0
retained:2day
1u0
0u1
1u2
0u3
0u4
1u5
0u6
0u7
SETBIT dau:2026-08-15 8321004 1     -- user 8321004 was active today
BITCOUNT dau:2026-08-14             -> 4 active yesterday
BITCOUNT dau:2026-08-15             -> 4 active today
BITOP AND retained:2day dau:2026-08-14 dau:2026-08-15
BITCOUNT retained:2day             -> 3 active on both days
GETBIT retained:2day 3              -> 0, user 3 came yesterday and did not return

Makes cheap: one boolean per user at a cost of one bit, so 100 million users fit in 12.5 MB per day, and questions like two-day retention, weekly streaks, and "active on Monday but not Tuesday" become one server-side command over whole populations. Makes expensive: anything that is not a dense integer identifier, and anything at small scale, where a set is both cheaper and easier.

The move that scores points When an interviewer says "we will put that in Redis," they have told you nothing. Follow it immediately with the shape and the access pattern: "a sorted set keyed per match, member is the player identifier and score is points, so the top ten is a single logarithmic range read and a player's own rank is one command." Naming the shape is the design decision.

02  Execution model

Four facts about how the server runs. Everything else on this board follows from them.

Commands run one at a time

Redis executes commands on a single thread, one to completion before the next starts. There is no lock to take and no partial state to observe: every individual command is already atomic, including compound ones like an increment or a conditional set.

Separate input and output threads exist for reading and writing sockets, and separate background threads handle disk syncing and freeing large objects, but command execution itself is serial.

One slow command stalls everyone

Because execution is serial, a command that is linear in the size of a collection is a latency incident, not a slow query. Fetching a million-member set, deleting a huge key, or running a long script freezes every other client for that duration.

Consequences you should state unprompted: never run a full keyspace scan in production, cap collection sizes, and keep scripts short.

KEYS pattern is the classic disqualifier. SCAN cursor COUNT 100 is the answer.

Memory is the budget

The working set lives in random access memory (RAM). Capacity planning is a memory calculation, not a disk one: number of keys, bytes per value, plus roughly 50 to 100 bytes of per-key overhead, plus fragmentation.

When the memory ceiling is hit, the configured eviction policy decides between refusing writes and throwing data away. Both are user-visible, so pick deliberately.

Durability is opt-in and imperfect

Replication is asynchronous by default, so a primary can acknowledge a write and then fail before any replica has it. Snapshots and the append-only log narrow the window but do not close it.

The rule to carry into every interview: Redis is a fast copy of the truth, not the truth. If losing the last second of writes would corrupt money, ownership, or identity, that data belongs in a durable store and Redis holds a derived view of it.

Rough planning numbers A single modern node handles on the order of 100,000 simple operations per second with sub-millisecond median latency inside one availability zone, and can reach several hundred thousand when clients batch requests into one round trip. Treat these as order-of-magnitude anchors for capacity math, then say you would measure. The usual real ceiling is network bandwidth or one hot key, not total operations per second.

03  Data structures and the commands worth knowing

For each structure: the commands you should be able to write on a whiteboard, their cost, and the design situations that call for it. N is the number of elements in the collection.

10 structures

Strings and countersThe cache primitive and the atomic integer

CommandCostWhat it does
SET key value EX 300 NXO(1)Set with a 300 second expiry, only if the key does not exist. NX makes it a lock or a claim.
GET keyO(1)Fetch the value, or a null reply if absent.
MGET k1 k2 k3O(k)Batch fetch in one round trip. Cluster requires all keys in one slot.
INCR keyO(1)Atomic increment, creating the key at zero first. Also INCRBY and INCRBYFLOAT.
GETDEL keyO(1)Read and delete atomically, useful for one-shot tokens.
GETEX key EX 300O(1)Read and refresh the expiry in one command, the sliding-session primitive.

Reach for it when

  • Caching a serialized object that is always read whole.
  • Counting anything exactly: views, retries, quota consumed in a window.
  • Claiming exclusivity: locks, idempotency keys, once-only side effects, all built on the NX option.

HashesStructured objects with field-level access

CommandCostWhat it does
HSET key field valueO(1)Set one or more fields, creating the hash if needed.
HGET key fieldO(1)One field. HMGET fetches several.
HGETALL keyO(N)Every field. Safe for a 20 field object, dangerous for a 200,000 field one.
HINCRBY key field 1O(1)Atomic counter inside an object, the basis of token bucket state.
HSCAN key cursorO(1) per callCursor iteration over a large hash without blocking.
HEXPIRE key 60 FIELDS 1 fO(1)Per-field expiry, available from Redis 7.4. Before that, expiry was key-level only.

Reach for it when

  • Caching objects where writers update one attribute at a time, avoiding read-modify-write races between concurrent writers.
  • Session state, shopping carts, per-entity configuration.
  • Small objects at scale: a hash under the configured threshold is stored as a compact flat list, several times cheaper than one key per field.

ListsDeques, queues, and capped timelines

CommandCostWhat it does
LPUSH key v / RPUSH key vO(1)Push at head or tail.
LPOP key / RPOP keyO(1)Pop at head or tail, optionally several at once.
LRANGE key 0 49O(offset+n)Read a window. Cheap at the head, costly deep into a long list.
LTRIM key 0 999O(removed)Keep only the newest 1000 entries. Paired with LPUSH this caps a feed forever.
BLPOP key 5O(1)Blocking pop with a 5 second timeout. The consumer sleeps on the server, no polling.
BLMOVE src dst RIGHT LEFT 5O(1)Atomically pop from one list and push to another, blocking if empty. The reliable queue primitive; it replaces the older RPOPLPUSH pair.

Reach for it when

  • A simple first in first out job queue between services.
  • A bounded "most recent 100" list per user, where the trim command does the pruning for free.
TrapA plain pop loses the job if the worker dies between popping and finishing. Move it to a processing list atomically instead, or use a stream consumer group, which tracks unacknowledged messages for you.

SetsMembership and server-side set algebra

CommandCostWhat it does
SADD key memberO(1)Add, returning how many were new. That return value is a free deduplication test.
SISMEMBER key memberO(1)Membership test. SMISMEMBER checks a batch.
SCARD keyO(1)Cardinality, stored not computed.
SMEMBERS keyO(N)Every member. Use SSCAN for anything large.
SINTERCARD 2 k1 k2 LIMIT 10O(N×M)Size of the intersection with an early exit, for questions like mutual friends.
SPOP key 1O(1)Remove and return a random member, an atomic raffle draw or ticket claim.

Reach for it when

  • "Has this user already seen or voted on this item," where the answer must be exact.
  • Tags, allow lists, block lists, room occupancy.
  • Small intersections computed on the server instead of dragging both sides over the network.

Sorted setsThe one to know cold: a skip list plus a hash map, ordered by float score

CommandCostWhat it does
ZADD key 1450 u7O(log N)Insert or update. Modifiers matter: NX only adds, XX only updates, GT keeps the higher score, INCR makes it behave as an increment that returns the new score.
ZINCRBY key 10 u7O(log N)Atomic score delta, the leaderboard write path.
ZRANGE key 0 9 REV WITHSCORESO(log N + k)Top ten by score, highest first. Since Redis 7 this one command covers rank ranges, score ranges with BYSCORE, and lexicographic ranges with BYLEX.
ZRANGE key 1000 2000 BYSCORE LIMIT 0 100O(log N + k)Everything in a score band, paged. This is how a delayed job poller finds due work.
ZREVRANK key u7O(log N)A single player's rank, zero based, without scanning the board.
ZCOUNT key 1000 5000O(log N)How many members fall in a score band, without fetching them.
ZREMRANGEBYSCORE key 0 1755200000O(log N + k)Drop everything older than a cutoff. The sliding window eviction step.
ZPOPMIN key / BZPOPMIN key 5O(log N)Pop the lowest score, blocking variant included. A priority queue in one command.
ZUNIONSTORE dst 2 k1 k2 WEIGHTS 1 0.5O(N log N)Merge boards with weights, for example blending this week and last week into a decayed ranking.

Reach for it when the answer to "what does the score mean" is one of these

  • Points, which gives you a leaderboard with exact ranks.
  • A timestamp, which gives you a time-ordered feed, a sliding window rate limiter, or a presence list you can prune by age.
  • A due time, which gives you a delayed job scheduler where the poller reads the band from zero to now.
  • A priority, which gives you a work queue that pops the most urgent item first.
Tie-breaking, asked more often than you would expectEqual scores are ordered lexicographically by member, so two players on 1450 points are ranked by their identifier rather than by who got there first. If arrival order matters, encode it in the score: keep points in the integer part and a decreasing timestamp fraction below it, so an earlier arrival always outranks a later one at the same points. Say that the float has 53 bits of integer precision, so the composite has to fit inside that budget.

BitmapsOne bit per user, addressed by integer offset

CommandCostWhat it does
SETBIT dau:2026-08-15 40312 1O(1)Mark user 40312 active today.
GETBIT dau:2026-08-15 40312O(1)Was that user active.
BITCOUNT dau:2026-08-15O(N)How many were active, N being the byte length, so about 12.5 MB scanned for 100 million users.
BITPOS seats:UA118 0O(N)Position of the first zero bit, which is the first free slot in an allocation map.
BITOP AND dst d1 d2 d3O(N)Store the intersection of three days into a new key, giving three-day retention with one command.
BITFIELD stats INCRBY u8 8 1O(1)Treat the string as an array of small integers, here incrementing the 8-bit value at bit offset 8.

Reach for it when

  • Allocating slots. Seat maps, address and port pools, partition claims. BITPOS key 0 returns the first free slot in one command, which no other structure offers.
  • Cohort analytics over dense identifiers. Daily active users, retention, streaks, all answered by set algebra across day keys.
  • Read and unread state over a bounded space. Which lessons, pages, or notifications a user has seen, where identifiers are small and dense by construction.
  • Hand-rolled Bloom filters. Several bit writes and reads at hashed positions against one key, the standard approach when the probabilistic module is unavailable.
  • Packing many small counters. One key holding thousands of 4-bit or 8-bit values, when per-key overhead of 50 to 100 bytes would otherwise dwarf the data.

Allocation, the strongest case

BITPOS seats:UA118 0                  -> 47, the first unsold seat
-- claim it atomically, or two bookings both take seat 47
EVAL "if redis.call('GETBIT',KEYS[1],ARGV[1])==0
      then redis.call('SETBIT',KEYS[1],ARGV[1],1) return 1
      else return 0 end" 1 seats:UA118 47
BITCOUNT seats:UA118                  -> seats sold so far

One quirk to know: on a bitmap whose bits are all set, the first-free search returns the position just past the end of the string, because the string is treated as followed by infinite zeroes. Compare the result against your capacity rather than trusting it blindly.

Packing small integers

BITFIELD stats:u8321004 SET u8 0 3 INCRBY u8 8 1 GET u8 0
-- one key holding an array of 8-bit values: set the first to 3,
-- increment the second by 1, read the first back

The ceiling, and how to stay under it

Offsets are absolute, so a bitmap costs whatever its highest set bit costs, regardless of how few bits are set. Writing bit 4 billion allocates half a gigabyte immediately, even for a single user.

That is also the hard wall. A bitmap is a string, the maximum string is 512 MiB, and the offset limit is 2 to the power 32, which is 4,294,967,296 bits, exactly 512 MiB. Long before reaching it, a full population count on one key means scanning half a gigabyte on the single execution thread, and the key is copied whole on replica sync and cluster resharding.

Shard by identifier range. Total memory is unchanged, but every operation is bounded and the work spreads across nodes.

-- shard = id >> 24, which is 16,777,216 users and 2 MiB per shard
-- offset = id & 0xFFFFFF
SETBIT dau:2026-08-15:s119 8321004 1
BITCOUNT dau:2026-08-15:s119         -> count for this shard only
-- 239 shards cover 4 billion users; pipeline the counts and sum in the client

Check density before choosing the structure at all. A bitmap costs one bit per possible user, while a set costs roughly 50 bytes per actual member once it outgrows its compact encoding, so a set is cheaper below roughly a quarter of one percent activity. If only the count is needed and not the identities, a HyperLogLog holds it in about 12 KB at any scale.

Honest scopeBitmaps are the least used structure on this board. The offset must be a dense integer, and most systems identify users with random or timestamp-based identifiers, which rules the structure out unless a separate dense mapping is maintained. In an interview it is rarely the centerpiece; it is the answer to a follow-up about the memory cost of holding a hundred million identifiers.

HyperLogLogApproximate cardinality in fixed memory

CommandCostWhat it does
PFADD key elementO(1)Observe an element. Storage does not grow with the number of distinct elements.
PFCOUNT keyO(1)Estimated distinct count, with a standard error near 0.81 percent.
PFMERGE dst src1 src2O(k)Union of sketches, so daily keys roll up into a weekly unique count without double counting.

Reach for it when

  • Unique visitors per page per day across billions of events, where roughly 12 KB per counter beats a set holding every identifier.
  • Any counting question where the interviewer accepts an approximation. Offer it as the memory-efficient option and let them choose.
Limit to state before being askedIt answers "how many distinct" and nothing else. You cannot ask whether a specific element was seen, you cannot remove one, and the count is an estimate, so it is wrong for billing or compliance.

GeospatialA sorted set whose score is an encoded coordinate

CommandCostWhat it does
GEOADD drivers -122.19 47.61 d:88O(log N)Store a member's longitude and latitude, encoded into a single sortable number.
GEOSEARCH drivers FROMLONLAT -122.2 47.6 BYRADIUS 3 km ASC COUNT 10O(N+log M)Ten nearest members inside three kilometers, nearest first.
GEODIST drivers d:88 d:91 kmO(log N)Distance between two members.

Reach for it when

  • Proximity search over a live, high-write set: drivers, delivery riders, players on a shared map.

Because it is a sorted set underneath, ordinary sorted set commands work on it: remove a member with the sorted set removal command, and count members with the cardinality command.

TrapThere is no per-member expiry, so stale positions linger. Keep a parallel sorted set of member to last-update timestamp and prune by score on a timer. Also note that a single geo key is one hot key in a cluster, so shard by city or region once write volume grows.

StreamsAn append-only log with consumer groups, acknowledgements, and replay

CommandCostWhat it does
XADD events MAXLEN ~ 1000000 * type kill user u7O(1)Append an entry with field value pairs. The star asks the server for a monotonically increasing identifier of the form milliseconds-sequence. The approximate length cap trims old entries cheaply.
XGROUP CREATE events workers 0O(1)Create a consumer group starting at the beginning of the stream.
XREADGROUP GROUP workers w1 COUNT 10 BLOCK 5000 STREAMS events >O(k)Claim up to ten undelivered entries for consumer w1, blocking up to five seconds. Delivered entries move into that group's pending list.
XACK events workers 1755200000-0O(1)Acknowledge, which removes the entry from the pending list. Without this, the entry stays claimable.
XAUTOCLAIM events workers w2 60000 0 COUNT 10O(k)Reassign entries pending longer than 60 seconds to consumer w2. This is how a dead worker's in-flight work is recovered.
XRANGE events 1755200000-0 1755203600-0O(k)Read a time range directly, since identifiers are timestamps. Replay is a range read.

Reach for it when

  • You need a work queue with at-least-once delivery, per-consumer tracking, and automatic recovery of work stranded by a crashed worker.
  • Several independent consumer groups need the same events, each at its own position.
  • You want short-window replay, for example rebuilding a projection from the last hour.
Say the boundary before the interviewer draws itStreams look like a log-based message broker and are not one. Retention is bounded by memory, there is no key-based partitioning across nodes, no compaction, and no cross-node ordering. For durable multi-day retention, high fan-out, and replay from the beginning of time, name a real log system and use Redis for the hot path in front of it.

Publish and subscribeFire and forget fan-out

CommandCostWhat it does
PUBLISH room:42 payloadO(N+M)Deliver to every current subscriber. Returns how many received it.
SUBSCRIBE room:42O(1)Listen. The connection enters subscriber mode.
SPUBLISH room:42 payloadO(k)Sharded publish, from Redis 7. In a cluster it reaches only the shard owning that channel, instead of broadcasting to every node.

Reach for it when

  • Pushing a message to whichever application server currently holds a user's websocket connection.
  • Broadcasting cache invalidations to every application instance.
Delivery is at most onceMessages are not stored. A subscriber that is disconnected, restarting, or simply too slow misses them permanently, and a slow subscriber is eventually dropped by the server to protect its output buffer. If a missed message is a bug rather than a blur, use a stream.

Probabilistic and search extensionsBundled in Redis 8, previously add-on modules

CommandCostWhat it does
BF.ADD seen key123O(1)Bloom filter insert. Membership tests can produce false positives but never false negatives.
BF.EXISTS seen key123O(1)Probable membership at a few bits per element, the guard that keeps lookups for nonexistent rows away from the database.
CMS.INCRBY traffic path:/a 1O(1)Count-min sketch, approximate frequency per item in fixed memory.
TOPK.LIST hotO(k)Heavy hitters, for detecting hot keys or abusive clients.

Mention these as options rather than assumptions, then confirm availability: "if the probabilistic module is available I would use a Bloom filter here, otherwise I would build the same guard with a set of known identifiers."

04  Keys, expiry, and eviction

Time to live, written TTL, is what turns a data structure server into a cache. The mechanics are asked about often because they are quietly surprising.

Naming and lifetime

CommandNotes
SET key v EX 30 / SET key v PX 30000The same 30 second lifetime, expressed in seconds and in milliseconds. The P prefix means milliseconds throughout: EXAT and PXAT set an absolute expiry instant instead of a duration, EXPIRE and PEXPIRE set a lifetime on an existing key, and TTL and PTTL report what is left.
EXPIRE key 300 NXSet a lifetime only if none exists. The GT and LT modifiers extend or shorten conditionally, which avoids a read-then-write race.
TTL keySeconds remaining. Minus one means no expiry, minus two means no key.
PERSIST keyRemove the expiry, making the key permanent.
SET key v KEEPTTLOverwrite the value without resetting the clock. A plain overwrite clears the expiry, which is a common accidental cache-forever bug.
DEL bigkeyFrees memory inline, so deleting a huge collection blocks the server.
UNLINK bigkeyUnlinks immediately and frees in a background thread. Prefer it for anything large.

Use a colon-delimited convention that encodes owner, entity, and version, for example cache:v3:user:42:profile. The version segment lets a schema change invalidate everything by moving to v4 rather than scanning for keys to delete.

How expiry actually happens

Expiry is not a scheduled deletion. Two mechanisms run:

  • Lazily, when a client touches the key and the server notices it is past its time, then deletes it and answers as if absent.
  • Actively, through a background cycle that samples keys carrying an expiry and removes the ones that have passed.

The consequence: an expired key can still occupy memory, sometimes for a while, if nothing reads it and sampling has not reached it. Capacity planning based on "it expires after an hour so memory is bounded by an hour of traffic" is optimistic.

A second consequence: replicas do not expire keys on their own. They wait for the deletion to arrive from the primary, and in the meantime a read on the replica of a logically expired key correctly returns nothing.

Eviction when memory fills

Set a memory ceiling, then choose what happens at the ceiling:

PolicyBehavior
noevictionWrites fail with an error, reads keep working. Correct when Redis holds data nobody can regenerate.
allkeys-lruEvict the approximately least recently used key, expiry or not. The default choice for a pure cache.
allkeys-lfuEvict the approximately least frequently used key. Better when a small set of keys is hot and occasional scans would otherwise flush them.
volatile-lruSame, but only among keys that carry an expiry. Lets a cache and durable working data share one instance, with only the cache evictable.
volatile-ttlEvict whichever expiring key dies soonest.

Both recency and frequency policies are approximations by sampling, not exact orderings, because maintaining a true ordering would cost memory and time on every access.

Iterating without stopping the world

SCAN 0 MATCH cache:v3:* COUNT 200 returns a cursor and a batch. Keep calling until the cursor comes back as zero.

Its guarantee is worth stating precisely, because interviewers probe it: every element present for the entire iteration is returned at least once. Elements added or removed during the iteration may or may not appear, and duplicates are possible, so the caller must tolerate them.

The same cursor pattern exists for hashes, sets, and sorted sets. Reach for it any time the alternative is a command whose cost is the size of the keyspace or the collection.

05  The atomicity toolbox

Four mechanisms, each with a different guarantee. Knowing which one a problem needs is a senior-level signal, and picking the wrong one is a common blocker in design reviews.

1. A single command

Already atomic, no ceremony required. Whenever a problem can be expressed as one command, it should be.

# claim, count, and pop are all single-command atomic
SET lock:job:9 token-a1b2 NX PX 30000
INCR quota:user:42:2026-08-15T14
ZPOPMIN jobs:due

Guarantee: full isolation. Nothing interleaves.

2. Queued transaction

Commands between the transaction markers are queued, then executed back to back with nothing interleaved.

MULTI
INCR feed:u42:unread
LPUSH feed:u42 post:991
LTRIM feed:u42 0 999
EXEC

Guarantee: isolation, and nothing else. There is no rollback: if the third command fails at runtime, the first two still applied. And you cannot read a value inside the block and branch on it, because results arrive only after execution.

3. Watch, the compare-and-swap path

Watch a key, read it, compute in the client, then execute. If any watched key changed in the meantime, execution aborts and returns a null reply, and the client retries.

WATCH inventory:sku99
GET inventory:sku99          # client sees 3
# client decides 3 is enough for an order of 2
MULTI
DECRBY inventory:sku99 2
EXEC                       # null reply if another client touched the key

Guarantee: optimistic concurrency. Correct, but it costs an extra round trip and degrades under contention, since a hot key means constant retries.

4. Server-side script

A script runs atomically on the server and can read, branch, and write within one execution. This is the tool for read-then-decide-then-write logic, which is exactly what rate limiters, lock releases, and inventory guards need.

-- release a lock only if we still own it
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

Load it once and invoke it by digest thereafter, so the script body is not shipped on every call. Declare every key the script touches in the key arguments, because that is how a cluster client routes the call and how it verifies all keys live on one node.

Guarantee: atomic read-modify-write. Cost: the script blocks the single execution thread, so keep it to microseconds and never loop over an unbounded collection inside one.

Pipelining is not on this list, and that is the pointBatching many commands into one network round trip is a latency optimization only. Other clients' commands can interleave between them, and there is no isolation and no rollback. If a candidate says "I will pipeline it so it is atomic," that is a correctness bug. Pipeline for throughput, script for atomicity.

06  The patterns interviewers are listening for

Each pattern below is written to stand on its own: the shape, the commands, the failure mode, and the sentence that earns the point.

11 patterns

Cache-aside, and the four ways it breaks

The application, not the cache, owns the logic. On a read, look in Redis first; on a miss, read the database, write the value back with a lifetime, and return it. On a write, update the database, then delete the cached key rather than overwriting it.

clientread user 42
step 1GET user:42
missnull reply
step 2SELECT from database
step 3SET user:42 value EX 300
step 4return value

Deleting rather than overwriting on a write matters because two concurrent writers can otherwise interleave their read of the database and their write to the cache, leaving the cache holding the older of the two values permanently. Deletion makes the next reader repopulate from the database instead.

Failure one: stampede on a hot key

A popular key expires and every in-flight request misses at the same instant, so the database receives thousands of identical queries. Fix by letting exactly one request recompute: each misser attempts SET lock:user:42 token NX PX 5000, the single winner queries the database and repopulates, and the losers either wait briefly and re-read or serve the previous value. An alternative that avoids the lock entirely is to store the value with a logical freshness timestamp and let a request refresh it probabilistically as it approaches expiry, so refreshes are spread out in time rather than synchronized.

Failure two: mass expiry, a synchronized wave of misses

Populating ten thousand keys in one batch with an identical lifetime makes all ten thousand expire in the same second. Add randomness to every lifetime, for example a base of 300 seconds plus a random 0 to 60, so expiry is spread across a minute.

Failure three: lookups for things that do not exist

Requests for identifiers that have no row miss the cache every time and always reach the database, which is also the shape of an easy denial of service attack. Cache the absence itself as a sentinel value with a short lifetime, or keep a Bloom filter of known identifiers and reject unknown ones before touching the database.

Failure four: one key hotter than one node

Sharding does not help when a single key takes the traffic, because a key lives on exactly one node. Three remedies, in the order you should offer them: a short-lived in-process cache in the application layer, which absorbs most reads and is bounded by its own lifetime; splitting the key into a small number of copies with a suffix so readers choose one at random and spread the load; and read replicas, if serving a slightly stale value is acceptable.

Say this"I will use cache-aside with a randomized lifetime, delete on write rather than overwrite, and a single-flight lock on repopulation so a hot key cannot stampede the database. Cached values are treated as stale-tolerant by design, since replication is asynchronous and eviction can drop anything at any time."

Rate limiting, three designs and the tradeoff between them

Rate limiting is the most common Redis question in a system design loop because it exercises expiry, atomicity, and a real accuracy versus memory tradeoff.

Design A, fixed window counter

The key embeds the window, so the window rolls over by key naming alone.

INCR rl:user:42:2026-08-15T14:23        # returns the new count
EXPIRE rl:user:42:2026-08-15T14:23 120  # only needed when the count came back as 1

One small key per user per window, constant time, trivially correct under concurrency because the increment is atomic. Its flaw is the boundary: a caller who spends the full allowance at the end of one minute and again at the start of the next has sent twice the limit inside a two second span. Offer this first, name the flaw, and let the interviewer decide whether it matters.

Design B, sliding window log with a sorted set

Exact enforcement over a continuously moving window. The member is a unique request identifier and the score is the arrival time in milliseconds. All four steps run in one script so that concurrent requests cannot both pass the check.

-- KEYS[1] = rl:user:42   ARGV = now_ms, window_ms, limit, request_id
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
local used = redis.call('ZCARD', KEYS[1])
if used < tonumber(ARGV[3]) then
  redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  return 1
end
return 0

Walked through completely, with a limit of 5 requests per 60,000 milliseconds on the key rl:user:42. Every step shows the full contents of the sorted set after the step runs.

1
t = 100,000 ms, request A. Cutoff is 100,000 minus 60,000, which is 40,000. Removal of scores from 0 to 40,000 removes nothing because the set is empty. Count is 0, and 0 is below 5, so A is admitted and added at score 100,000.rl:user:42 = { A:100000 }allow
2
t = 100,500 ms, request B. Cutoff is 40,500. Nothing is that old, so nothing is removed. Count is 1, below 5, so B is admitted at score 100,500.rl:user:42 = { A:100000, B:100500 }allow
3
t = 101,000 ms, request C. Cutoff is 41,000, nothing removed. Count is 2, below 5, so C is admitted at score 101,000.rl:user:42 = { A:100000, B:100500, C:101000 }allow
4
t = 101,200 ms, request D. Cutoff is 41,200, nothing removed. Count is 3, below 5, so D is admitted at score 101,200.rl:user:42 = { A:100000, B:100500, C:101000, D:101200 }allow
5
t = 101,900 ms, request E. Cutoff is 41,900, nothing removed. Count is 4, below 5, so E is admitted at score 101,900.rl:user:42 = { A:100000, B:100500, C:101000, D:101200, E:101900 }allow
6
t = 102,000 ms, request F. Cutoff is 42,000, nothing removed. Count is 5, which is not below 5, so F is rejected. A rejected request is not added, otherwise a client that keeps retrying would hold its own window open forever.rl:user:42 = { A:100000, B:100500, C:101000, D:101200, E:101900 }reject
7
t = 160,500 ms, request G. Cutoff is 100,500. Removal of scores from 0 to 100,500 removes A at 100,000 and B at 100,500, since the range boundary is inclusive. Count is now 3, below 5, so G is admitted at score 160,500.rl:user:42 = { C:101000, D:101200, E:101900, G:160500 }allow

Cost: memory proportional to the limit per active caller, because every admitted request is stored for the length of the window. A limit of 1,000 per minute across a million active users is a billion members, which is the moment to switch designs.

Design C, token bucket in a hash

Constant memory per caller regardless of the limit, and it permits a controlled burst. Two fields hold the state: tokens remaining and the time of the last refill. On each request a script computes how many tokens have accrued since the last refill, caps that at the bucket capacity, and spends one if any remain.

-- KEYS[1] = tb:user:42   ARGV = now_ms, refill_per_ms, capacity, cost
local s = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(s[1]) or tonumber(ARGV[3])
local ts = tonumber(s[2]) or tonumber(ARGV[1])
tokens = math.min(tonumber(ARGV[3]), tokens + (ARGV[1] - ts) * ARGV[2])
if tokens >= tonumber(ARGV[4]) then
  redis.call('HSET', KEYS[1], 'tokens', tokens - ARGV[4], 'ts', ARGV[1])
  redis.call('PEXPIRE', KEYS[1], 60000)
  return 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[1])
return 0
DesignMemory per callerAccuracyBurst
Fixed windowOne integerAllows double the limit at a boundaryUncontrolled at boundaries
Sliding logOne member per admitted requestExactNone
Token bucketTwo fieldsExact against a rate, not a windowConfigurable, equal to capacity
Say this"I would start with token bucket in a script, because memory is constant per caller and burst is a tunable product decision rather than an accident of window boundaries. If the requirement is a strict cap per rolling minute for compliance reasons, I would use the sorted set log and accept memory proportional to the limit."

Distributed lock, with the honest caveat

Acquire by claiming a key only if it does not exist, with a value unique to this holder and a lifetime that bounds the damage from a crash:

SET lock:invoice:88 <random-token> NX PX 30000

Release with a script that compares the token before deleting, shown in the atomicity section above. A plain delete is a bug: if the lifetime expired and another holder took the lock, a plain delete releases their lock. Extend a long-running job by a script that compares the token and then pushes the expiry out.

The caveat that earns the staff-level pointThis is not a correctness guarantee. A holder can be paused by garbage collection or a scheduler past its lifetime and continue working while a second holder is active, and an asynchronous failover can hand the lock out twice. Use it to avoid duplicate work, not to protect an invariant. When correctness depends on it, pair it with a monotonically increasing token that the protected resource checks and rejects if it is older than the last one it saw, or make the operation idempotent so double execution is harmless, or put the invariant in a store built for consensus.

Leaderboard

Writes are a score increment, reads are a rank range. Both are logarithmic, so a board with millions of players answers the top ten in microseconds.

ZINCRBY lb:season:9 25 u7        # award 25 points
ZRANGE  lb:season:9 0 9 REV WITHSCORES
ZREVRANK lb:season:9 u7          # this player's rank
ZCOUNT lb:season:9 1450 +inf     # how many are ahead of a score

Time-boxed boards come from key naming: a daily, weekly, and all-time key updated in the same pipeline, with lifetimes on the periodic ones so old boards evict themselves.

Scaling notes to volunteer: one board is one key on one node, so it is a hot key by construction. At tens of millions of members, memory is on the order of tens of bytes per entry and rank queries stay cheap, so the first thing to break is usually write throughput rather than read cost. If it does break, shard by score band and answer a rank query by counting members above the player's score in each shard, which keeps ranks exact while spreading writes.

Delayed and scheduled work

Store the job identifier as the member and the time it should run as the score, then have pollers read the band from the beginning of time up to now.

ZADD jobs:due 1755205200000 job:9912
-- claim atomically so two pollers cannot take the same job
local due = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 20)
for i, id in ipairs(due) do redis.call('ZREM', KEYS[1], id) end
return due

The claim has to remove and return in one atomic step, otherwise two pollers read the same batch and the job runs twice.

TrapSome designs schedule jobs by setting a key with a lifetime and listening for the expiry notification. Those notifications are fire and forget: nothing is redelivered if the listener is down, and the notification fires when the key is actually collected rather than exactly when it expired. Do not build a payment scheduler on it.

Matchmaking pool, two sorted sets over one member set

The pattern worth stealing from this one is not the game, it is the shape: when a workload needs two orderings over the same population, you keep two sorted sets over one member set, because a sorted set carries exactly one score. A matchmaker picks whom to build a match around by longest wait, then searches for opponents by skill rating. Those are two different axes over the same players, so neither one set nor a single composite score can serve both.

The keys

KeyShapeScore means
mm:pool:na:rankedSorted setMatchmaking rating, so a range read is a rating window
mm:enq:na:rankedSorted setEnqueue time, so index 0 is always the longest waiter
mm:player:9182HashRegion, mode, rating, state. It is what tells a cancel which two keys to clean

The search, and why the window widens

Each tick reads the anchor from the time set, derives a rating window from how long that anchor has waited, and reads the window out of the rating set. Only steps 1 and 4 and 6 are Redis calls; the rest is arithmetic, and saying so is what stops the trace sounding like magic.

# 1. the anchor. 0 0 are POSITIONS, not scores: no BYSCORE here
ZRANGE mm:enq:na:ranked 0 0 WITHSCORES   -> p:9182, 1755530400000
# 2. w = now - that score = 5000 ms  (arithmetic)
# 3. window = 50 + 25 * floor(w / 5000) = 75, capped at 400
ZSCORE mm:pool:na:ranked p:9182            -> "1450", the anchor's rating
# 5. bounds = 1450 +/- 75  (arithmetic)
# 6. same command name, different key, different axis: these ARE scores
ZRANGE mm:pool:na:ranked 1375 1525 BYSCORE LIMIT 0 50 WITHSCORES

Anchor on wait, search on the other axis. Sweeping the rating axis alone starves whoever sits at a sparse part of the curve. Anchoring on the oldest member puts a ceiling on wait time, and widening the window with age is what pays for that ceiling. The same shape works for any "match these two populations, but do not let anyone starve" problem.

The claim has to be one script

Reading candidates and removing them are two round trips, and in the gap another worker or a cancel can take one. Put the check and the removal in one script and the gap closes with no lock:

-- KEYS[1] = the rating set, KEYS[2] = the time set, ARGV = candidate ids
for i = 1, #ARGV do
  if redis.call('ZSCORE', KEYS[1], ARGV[i]) == false then return 0 end
end
redis.call('ZREM', KEYS[1], unpack(ARGV))
redis.call('ZREM', KEYS[2], unpack(ARGV))
return #ARGV

The property to name out loud: there is no rollback path to write, because a partial claim is not reachable. The script either finds every member present and removes them all, or finds one missing and removes nothing. A cancel is the same shape, so Redis serializes cancel against claim for free, and the design work moves to what the losing side is told: a cancel that removes zero members means a match already exists, so answer 409 with the match identifier rather than a bare failure.

Many workers: partition, lease, fence

Shard on region:mode, so two workers never touch the same keys. Do not shard on the score axis you search: a rating band boundary is a wall the widening window can never cross, so someone just under a boundary waits forever while a fine opponent sits just above it. Region and mode are product walls; a rating wall is an artifact of your sharding scheme.

SET mm:lease:na:ranked worker-3 NX PX 10000  # renew at ~1/3 of the TTL
INCR mm:fence:na:ranked                      -> 41, this owner's token
-- inside the claim script, before anything is removed
if redis.call('GET', KEYS[3]) ~= ARGV[1] then return -1 end
Why the lease alone is not enoughA lease bounds how long a worker believes it owns a partition. It does not bound how long a stalled worker takes to notice. A long garbage collection pause outlives the lease, a second worker takes over and increments the counter, and then the first one wakes and issues a claim that Redis has no reason to refuse. The fencing token is what refuses it: one counter, incremented on every handover, compared inside the script before a single member moves. This is the same caveat as the distributed lock above, with the pairing actually built.
Say the threshold, or this reads as a reflexPostgres stays the durable record here; the sorted sets are a derived index that exists to make the search cheap, and if they are lost the pool replays from the QUEUED rows with nobody dropped. That also means Redis and Postgres can now disagree, so you owe a reconciler sweep. Under roughly a few thousand concurrent queuers per region, a plain database poll is the better design and none of this is worth its price. Naming that number is what turns the pattern into a judgment call. The full version, with the worked trace, the cancel race, the durability table and the session allocation that follows a formed match, is in the game matching appendix.

Idempotency keys

A client sends a unique key with a request that must not be applied twice. The claim and the check are the same command:

SET idem:pay:<key> in-progress NX EX 86400

A successful set means this caller is the first, so proceed and then overwrite the value with the stored response. A null reply means the request was already handled, so read the key: if it holds a response, return that identical response; if it still says in progress, the original is running, so answer with a retry-later status rather than executing a second time.

Two details worth naming: the lifetime must exceed the longest plausible client retry window, and the stored value should include a hash of the request body so that the same key sent with different content is rejected as a conflict instead of silently returning the wrong result.

Work queue, list or stream

The list version is two commands. A producer pushes onto the head; a consumer atomically moves an entry from the tail of the queue onto its own processing list and deletes it from there when done.

LPUSH q:email job:551
BLMOVE q:email q:email:w1 RIGHT LEFT 5
# work happens here
LREM q:email:w1 1 job:551

If the worker dies mid-job the entry sits in its processing list, so a reaper must find abandoned entries and push them back. The stream version gets that machinery for free: delivery is tracked per consumer group and stranded work is reassigned by a single reclaim command with an age threshold.

Choose the list when the queue is simple, short lived, and one consumer group is enough. Choose the stream when you need acknowledgements, redelivery, or multiple independent consumers of the same events.

Counting at three accuracies

QuestionShapeCost
How many eventsString counter, incrementedExact, 8 bytes per counter
How many distinct usersHyperLogLogAbout 0.81 percent error, roughly 12 KB per counter
Which specific usersBitmap by user offset, or a setExact, one bit per user, or full identifiers

The interviewer is usually testing whether you ask about accuracy requirements before choosing. Ask whether the number is displayed to users, billed on, or reported to a regulator, then pick.

For high-volume counters, batch in the application layer and flush an aggregate increment periodically, rather than sending one command per event. Say the tradeoff: fewer round trips, but any counts buffered in a process that crashes are lost.

Five more things a sorted set score can mean

A filter applied to a ranking. Combining a board with a set of identifiers narrows it, because a set contributes a score of one per member, so weighting that side at zero preserves the original points.

ZINTERSTORE lb:friends:u42 2 lb:global friends:u42 WEIGHTS 1 0
ZRANGE lb:friends:u42 0 9 REV WITHSCORES   -> a friends-only leaderboard

Last touched, which gives deduplication a list cannot. Viewing an item twice appends twice to a list. Here the member is the item and the score is the time, so a repeat view moves it instead of duplicating it, and trimming is by rank.

ZADD rv:u42 1755205200 item:991        -- a second view just updates the score
ZREMRANGEBYRANK rv:u42 0 -21           -- keep only the 20 newest
ZRANGE rv:u42 0 19 REV                 -> recently viewed, newest first

Nothing at all, so ordering falls to the member itself. Give every member the same score and the set sorts lexicographically, which turns a range query into a prefix scan for typeahead.

ZADD terms 0 banana 0 band 0 bandana 0 bank 0 cactus
ZRANGE terms "[ban" "[ban\xff" BYLEX     -> banana, band, bandana, bank

An attribute value, making the set a secondary index. Redis cannot search by anything but the key, so an index you maintain yourself is the only way to ask for a price band. The same shape gives stable pagination, since paging by an exclusive score bound does not shift when rows are inserted, the way an offset does.

ZADD idx:price 2499 sku:88 1899 sku:91 3200 sku:14
ZRANGE idx:price 1800 2600 BYSCORE       -> sku:91, sku:88
ZRANGE feed:u42 (1755205200 -inf BYSCORE REV LIMIT 0 20
-- the page after the item scored 1755205200, the parenthesis makes the bound exclusive

Popularity that fades. Events raise the score and a periodic job multiplies every score down at once, so trending decays with no per-item bookkeeping and no background timer per entry.

ZINCRBY trending 1 post:551              -- on each view
ZUNIONSTORE trending 1 trending WEIGHTS 0.9   -- every 10 minutes, decay all
ZREMRANGEBYSCORE trending -inf 0.5     -- drop what has faded out
Cost to name before it is askedThe three store commands here write a whole new key, at a cost proportional to the inputs, so they are batch operations rather than request-path ones. A friends board for a user with 200 friends is cheap enough to compute on demand; decaying a million-member trending set is a scheduled job. In a cluster, every key involved must live in one slot, which means a deliberate brace tag on all of them.

Presence and sessions

Presence. Each client heartbeats into a sorted set with the current time as the score. Online means a score newer than the cutoff, and pruning is one range removal on a timer.

ZADD presence:room:42 1755205200 u7
ZRANGEBYSCORE presence:room:42 1755205140 +inf   # seen in the last 60s
ZREMRANGEBYSCORE presence:room:42 0 1755205140

Sessions. A hash per session with a lifetime refreshed on each request, which makes idle timeout automatic. Since the store is shared, any application server can serve any request, so no sticky routing is needed.

Say the failure modeIf the session store is lost in a failover, every user is logged out at once and the login service takes the full stampede. That is a product decision, not just an infrastructure one: either accept it, enable the append-only log with replication, or use signed tokens that survive a cold cache with Redis holding only the revocation list.

07  Replication, persistence, and cluster

The operational half of the interview. Candidates who only know the commands stop here; the ones who get the offer can describe what happens when a node dies.

Replication and what a failover costs

A primary streams its writes to replicas asynchronously. It does not wait for them, which is why write latency stays low and why a failover can lose recently acknowledged writes.

There is a command that blocks until a given number of replicas have confirmed, which converts some of that risk into latency. It is not a consensus commit, since a failover can still promote a replica that did not confirm, but it is worth naming as a mitigation.

Replicas serve reads at the cost of staleness. Reading your own write from a replica may return the previous value, so route reads that must reflect a just-completed write to the primary.

Persistence, two mechanisms

MechanismBehavior
SnapshotA point-in-time image of the dataset, written by forking the process and copying pages as they change. Compact, fast to reload, and it loses every write since the last snapshot.
Append-only logEvery write command appended to a file. Syncing to disk every second is the usual setting, which bounds loss at about one second. Syncing on every write is durable and slow. The file is periodically rewritten into a compact form so it does not grow without bound.

Running both is common: the log for recovery granularity, the snapshot for fast restarts and backups.

The fork is a latency eventSnapshotting and log rewriting fork the process. Under heavy writes the copied pages can push memory usage toward double the dataset size, and the fork itself pauses the server briefly on a large instance. This is a real cause of periodic latency spikes and a good detail to raise unprompted.

Two ways to be highly available

Sentinel is a monitoring layer for a single primary with replicas. A quorum of sentinels agrees the primary is down, promotes a replica, and clients ask the sentinels who the current primary is. One dataset, one node's worth of memory and throughput.

Cluster shards the keyspace across many primaries, each with its own replicas, and performs failover per shard. Choose it when the dataset or the write rate exceeds a single node, and accept the constraints in the next card.

Cluster: slots, tags, and cross-slot rules

The keyspace is divided into 16,384 hash slots. A key's slot is a checksum of the key name reduced into that range, and each primary owns a contiguous set of slots. Clients cache the slot map and are redirected when it changes during resharding.

Multi-key commands, transactions, and scripts require every key to live in one slot. If any key names appear in braces, only the text inside the braces is hashed, which lets related keys be pinned together deliberately:

slot assignment, computedcrc16 mod 16384
user:42:cartslot 12984
user:42:profileslot 9133
{user:42}:cartslot 15880
{user:42}:profileslot 15880
{user:42}:ordersslot 15880
CLUSTER KEYSLOT user:42:cart        -> 12984
CLUSTER KEYSLOT user:42:profile     -> 9133
CLUSTER KEYSLOT {user:42}:cart      -> 15880
CLUSTER KEYSLOT {user:42}:profile   -> 15880
CLUSTER KEYSLOT {user:42}:orders    -> 15880
CLUSTER SHARDS                       -- which node owns which slot range

The first two land on different nodes, so a script touching both is rejected. The last three are pinned to one slot by the brace tag, so they can be read together, updated in one transaction, and touched by one script.

TrapTagging is a sharding decision in disguise. Pinning everything for one very active entity to one slot recreates the hot node you sharded to avoid, so tag only what genuinely must be operated on together.

What to say when asked "what happens when it fails"

FailureEffectDesign response
Primary diesSeconds of unavailability for that shard during promotion, and writes not yet replicated are lost.Ensure the application degrades to the database instead of erroring, and never keep unrecoverable state only in Redis.
Cache is cold after restartEvery read misses and the database sees full traffic at once.Single-flight repopulation, request-level admission control, and warming the top keys before shifting traffic back.
Memory ceiling reachedEither writes are refused or unrelated keys are evicted, depending on policy.Alert on used memory and eviction rate, keep the cache and any durable working set on separate instances or use an expiry-only eviction policy.
One key gets hotOne node saturates while the rest of the cluster is idle.A short-lived in-process cache in front, key splitting across a few copies, or read replicas for stale-tolerant reads.
One command is slowEvery client's latency rises together, because execution is serial.Cap collection sizes, use cursor iteration instead of full reads, unlink large keys instead of deleting them, and watch the slow command log.

08  Complexity cheat sheet

N is collection size, k is the number of elements returned or touched. The pattern to internalize: writes and rank lookups on a sorted set are logarithmic, everything on strings and hashes is constant, and anything that returns a whole collection is linear and therefore dangerous.

Constant time, safe at any size

CommandUse
GET SET INCR GETDEL GETEXCache reads and writes, counters, claims
HSET HGET HINCRBYObject fields, bucket state
LPUSH RPUSH LPOP RPOP BLPOP BLMOVEQueues, capped feeds
SADD SISMEMBER SCARD SPOPMembership, deduplication
SETBIT GETBITPer-user flags, allocation maps, daily activity
PFADD PFCOUNTApproximate distinct counts
XADD XACKEvent append and acknowledgement

Logarithmic, the sorted set family

CommandUse
ZADD ZINCRBY ZREMScore writes
ZSCORE ZRANK ZREVRANK ZCOUNTPoint and rank lookups
ZRANGE (plus k)Top-K, score bands, time ranges
ZPOPMIN BZPOPMINPriority queue pop
GEOADD GEODISTPosition writes and distances

Linear, ask "how big can this get" first

CommandSafer alternative
KEYSSCAN with a cursor
SMEMBERS HGETALLSSCAN, HSCAN, or targeted field reads
DEL on a large collectionUNLINK, which frees in the background
LRANGE deep into a long listCap the list with LTRIM so depth is bounded
BITCOUNT over a huge bitmapRange-limited counts, or precomputed rollups

09  What to say out loud

Delivery notes. These are the sentences that move a Redis answer from competent to senior, and the questions that should trigger them.

Where the sorted set shows up in the standard questions

In most of these it is a supporting component rather than the headline, which is why it is easy to leave out and easy to score with.

QuestionWhat the score isThe commands
Ticket bookingSeat hold expiry, so abandoned carts release themselvesZADD holds:show:9 1755205500 seat:47 then a reaper on ZRANGE holds:show:9 0 now BYSCORE
Virtual waiting roomJoin time, so position in line is a rank lookupZRANK queue:show:9 u42 for position, ZPOPMIN queue:show:9 500 to admit a batch
News feedPost time, chosen over a list so edits update in place and paging stays stableZADD feed:u42 1755205200 post:991, ZREMRANGEBYRANK feed:u42 0 -801 to cap depth
Top K and trendingEvent count per time bucketZINCRBY top:2026-08-15T14 1 post:551, then ZUNIONSTORE across 60 buckets for the hour view
Ride hailing and proximityAn encoded coordinate, since the geo commands are a sorted set underneathGEOSEARCH drivers FROMLONLAT lon lat BYRADIUS 3 km ASC COUNT 10, with a parallel last-update set pruned by ZREMRANGEBYSCORE
Web crawlerEarliest time a domain may be fetched again, which enforces politeness and fairness in one popZPOPMIN frontier
Chat and messagingSequence number of undelivered messages per recipientZRANGE outbox:u42 (last_ack +inf BYSCORE on reconnect, ZREMRANGEBYSCORE on acknowledgement
Payments and notificationsNext retry time, so exponential backoff needs no queue per delay tierZADD retries 1755205800 job:88, claimed by a script that reads the due band and removes in one execution
Rate limitingArrival time of each admitted requestThe four-step script in cluster 06
Contest leaderboardPoints, with an inverted timestamp in the fraction so an earlier finish outranks a later one at equal pointsZADD lb:contest:7 1450.0173 u7, ZREVRANK lb:contest:7 u7
The follow-up is the same every timeThat sorted set is one key on one node, so it is both a hot key and an unbounded one. Have the answer ready before it is asked: shard by time window or by entity, trim by rank on every write, put a lifetime on the key wherever the data is disposable, and if exact ranks must survive sharding, count the members above the player's score in each shard and sum the counts.

Choose the shape, then justify it

Never say "cache it in Redis" alone. Say the key, the shape, the access pattern, and the lifetime, in one breath: "key is lb:season:9, a sorted set, member is the player identifier and score is points, no expiry because the season key is dropped at rollover."

Naming all four takes ten seconds and pre-empts the three follow-up questions the interviewer had queued.

State the durability boundary early

Volunteer that replication is asynchronous and a failover can lose recent writes, then say what your design does about it. Every strong answer eventually contains one of these three sentences: this data is derived and rebuildable, this data is written to the durable store first and Redis holds the read view, or this data can be lost and here is the user-visible consequence.

Reach for the script when the logic reads and then decides

The moment a design says "check the count and then add if it is under the limit," or "delete the lock if we still own it," the two steps must be one atomic execution. Watch-based retries are the alternative, and they are correct but degrade under contention. Naming this fork, and picking a side with a reason, is a reliable senior signal.

Know when the answer is not Redis

  • System of record for money or ownership. Use a transactional database and let Redis hold a derived view.
  • Unbounded data. Everything lives in memory, so a dataset that only grows is a budget problem that gets worse.
  • Queries by arbitrary attribute. Access is by key. Any other lookup requires an index you build and maintain yourself, and every one of those is a consistency liability.
  • Durable, high-retention event streams. Streams are excellent for the recent window and not a substitute for a log-based broker.
  • Large blobs. Multi-megabyte values consume the serial execution thread while they are read and written.

Saying where the tool stops is more convincing than claiming it does everything.

Questions to ask before designing

  • Is this cache stale-tolerant, and for how long?
  • Does this count have to be exact, or is a small error acceptable?
  • What is the largest this collection ever gets, per key?
  • What is the read to write ratio, and is any single key hot?
  • If the whole instance vanished, what breaks, and for how long?

Version notes, in case they come up

  • Streams and consumer groups arrived in version 5, access control and the newer protocol in version 6, persistent server-side functions in version 7.
  • Version 6.2 unified older commands into modern ones: the atomic list move that replaced the older pop-and-push pair, and the geographic search that replaced the older radius commands.
  • Version 7 folded the sorted set range commands into one, and added sharded publishing so a cluster does not broadcast every message to every node.
  • Version 7.4 added per-field expiry inside a hash.
  • Licensing changed in 2024 to a source-available license, prompting a community fork; the core project re-added an open source license option from version 8 in 2025. Current stable is in the 8.x line. Managed offerings differ, so it is fine to say the exact deployment target decides.