Building Blocks
Redis
Cache, rate limiter, session store, and leaderboard — and the two failure modes worth naming unprompted.
Redis comes up in nearly every system design interview, almost always as "we'd add a cache." That answer alone doesn't say much — what matters is being able to name which of Redis's actual capabilities you're reaching for, and to back each one with the mechanism underneath it, not just the vocabulary.
It's more than a cache
Redis is an in-memory data structure store. The cache use case is real, but four other capabilities show up just as often in interview answers:
- Rate limiting — cheap to implement correctly with an atomic counter:
INCRalone is atomic, butINCRfollowed by a separateEXPIREcall is not — a crash between the two leaves a key with no TTL, forever. The correct version wraps both in a Lua script, or sets the TTL only on the request that creates the key (SET key 0 EX 60 NXbefore the firstINCR). - Session storage — fast reads on a small key, with a natural TTL.
- Leaderboards / sorted sets — ranked data with O(log n) inserts and range queries without reaching for a full database.
- Real-time streams & message queues — lightweight event messaging with consumer groups and acknowledgments.
The data structures behind those patterns
"Redis" isn't one data structure — it's several, each suited to a different access pattern, and naming which structure backs a given use case is worth more in an interview than naming "Redis" alone:
| Structure | Typical operation | Backs |
|---|---|---|
| String | GET/SET, INCR — O(1) | Cache values, atomic counters (rate limiting), distributed locks |
| Hash | HGET/HSET on a field — O(1) | A session or object with several fields, without one key per field |
| List | LPUSH/RPOP — O(1) at either end | Simple queues or bounded recent-activity feeds |
| Set | SADD/SISMEMBER — O(1) | Membership checks (e.g. unique tags, active sockets) |
| Sorted Set | ZADD/ZRANGE — O(log n) | Leaderboards, sliding-window rate limiters, and geospatial queries |
| Streams | XADD/XREADGROUP — O(1) | Event logs with consumer groups, message replay, and acknowledgments |
Probabilistic & specialized structures
Senior candidates win points by knowing specialized memory-efficient structures:
- HyperLogLog (
PFADD/PFCOUNT) — estimates unique cardinality (e.g., 1 billion unique daily visitors) using a fixed ~12 KB memory footprint with ~0.81% error margin. - Bitmaps / Bitfields — bitwise operations (
GETBIT/SETBIT) for extreme memory compression (e.g., tracking 365-day login streaks or user online status using 365 bits per user). - Geospatial (
GEOADD/GEOSEARCH) — indexes coordinates using Geohashes backed by Sorted Sets (great for Uber driver proximity or Yelp nearby search). - Bloom Filters (via RedisBloom module) — probabilistic membership check with zero false negatives (answers "definitely not in set" or "probably in set") to prevent cache penetration.
- Vector Sets (
VADD/VSIM, Redis 8+) — a sorted-set-inspired data type for storing and similarity-searching high-dimensional embeddings natively, letting Redis sit inside a RAG or recommendation pipeline as the vector store instead of a separate service. New enough (2025) that it's worth naming explicitly rather than assuming an interviewer already expects it from Redis.
Execution model: why single-threaded means fast
Interviewers frequently ask: "Is Redis single-threaded, and how does it scale to 100k+ QPS?"
- Single-threaded command execution: Redis processes commands sequentially on a single thread using an I/O multiplexing event loop (
epollon Linux,kqueueon macOS). Because execution is single-threaded:- There is no lock contention, mutex overhead, or context switching overhead.
- Operations on single keys or data structures are naturally atomic.
- Multi-threaded I/O (Redis 6.0+): While command execution remains strictly single-threaded, network socket parsing and serialization are offloaded to background I/O threads.
- Non-blocking deletion (
UNLINKvsDEL):DELsynchronously reclaims memory on the single execution thread, which can freeze Redis for seconds if deleting a multi-million-item Hash or Set.UNLINKunlinks the key instantly and reclaims memory asynchronously on a background thread.
A related operational limit worth knowing: every open connection costs Redis memory and a file
descriptor, and maxclients (10,000 by default) caps how many can be open at once. Because
execution is single-threaded, Redis doesn't degrade gracefully under a connection storm — new
connections are simply refused once the cap is hit — which is the practical argument for
connection pooling in the application tier instead of opening a fresh connection per request.
Atomicity: Lua Scripts & Transactions
When simple atomic commands (INCR, SETNX) aren't enough for multi-step logic (e.g., checking balance and deducting only if sufficient), you have two mechanisms:
- Lua Scripting (
EVAL/EVALSHA): Redis executes Lua scripts atomically on the single execution thread. No other command can interleave while the script runs, avoiding race conditions without network roundtrips. - Transactions (
MULTI/EXEC/WATCH): Provides optimistic locking viaWATCH. If a watched key changes beforeEXEC, the transaction aborts and the application must retry.
As a cache, specifically
Read path (cache-aside)
App
GET key
Redis
miss
Postgres
SELECT — source of truth
Redis
SET key value EX 300 (5 min TTL)
App
Cache-aside — the application checks the cache first, and on a miss, reads through to the database and populates the cache itself — is the default worth reaching for unless something specific argues otherwise. It's also the most resilient failure mode of the three patterns below: if Redis becomes unavailable, the application keeps working, falling back to the database directly — only performance degrades, not correctness.
Write-through vs. write-behind
Cache-aside only defines a read path — it doesn't say what happens on a write. Two other named patterns cover that:
Write-through
App writes
Redis
writes synchronously to Postgres before returning
Postgres
always in sync with the cache
Write-behind
App writes
Redis
returns immediately
Postgres
flushed asynchronously, batched, later
Write-through keeps the cache and database always in sync, at the cost of every write paying the database's latency, since the write doesn't return until both are updated. Write-behind is the opposite trade: the write returns the instant Redis has it, and the database catches up asynchronously — much faster writes, but a real risk of data loss if Redis crashes before the batched flush happens, since that data existed nowhere durable in the meantime.
Eviction policies
A concrete default worth having ready: maxmemory-policy allkeys-lru evicts
the least-recently-used key once Redis hits its memory ceiling — and it's
worth knowing that Redis approximates LRU by sampling a handful of keys
rather than tracking exact recency for every key, which is cheaper and, in
practice, close enough. But allkeys-lru is one of six real policies, and
picking the right one is a real design decision, not a fixed default:
LRU: evicts the least-recently-used
Memory full — 3 keys
cart:1 (30s ago), cart:2 (2 min ago), cart:3 (40 min ago)
Evict cart:3
oldest last-access time — not the oldest data
Memory: 2 keys, 1 slot free
LFU: evicts the least-frequently-used
Memory full — 3 keys
trending:1 (500x today), trending:2 (80x), trending:3 (2x)
Evict trending:3
fewest accesses — regardless of how recently
Memory: 2 keys, 1 slot free
The difference matters concretely: a key that was viral yesterday and hasn't been touched in the last hour looks identical to genuine cold data under LRU — both get evicted — but LFU would correctly keep protecting it if it's still being accessed frequently in bursts, just not most-recently.
| Policy | Evicts among | When to use it |
|---|---|---|
allkeys-lru | Any key | Default for a pure cache — every key is expendable |
allkeys-lfu | Any key | A cache with viral spikes — protects frequently-hit keys even if not recently hit |
volatile-lru | Only keys with a TTL set | Mixed cache + durable data in the same Redis instance |
volatile-lfu | Only keys with a TTL set | Same mixed case, frequency-based |
volatile-ttl | Only keys with a TTL set | Evicts whichever expires soonest first |
noeviction | Nothing — errors instead | Redis is holding data that must never be silently dropped |
Expiration and eviction are two different mechanisms
It's easy to conflate the two, but they answer different questions. Eviction — the policies above — only kicks in once the whole instance is out of memory and something has to go, TTL or not. Expiration decides when one specific key with a TTL becomes invalid, independent of memory pressure. A key can expire and still sit in memory for a while afterward: Redis doesn't delete it the instant its TTL hits zero.
Redis clears expired keys two ways, running together:
- Lazy expiration — every time a key is read or written, Redis checks its TTL first. If it's passed, the key is deleted on the spot and the command behaves as if the key never existed. On its own, this would leave keys that are never touched again sitting in memory indefinitely.
- Active expiration — a background cycle, roughly ten times a second, samples 20 random keys that have a TTL set and deletes any that have expired. If more than 25% of that sample had already expired, Redis immediately samples another 20 instead of waiting for the next cycle — so a sudden burst of expiring keys (the cache-avalanche scenario below) gets cleaned up faster, not slower.
The practical distinction for a design answer: maxmemory-policy governs what happens under
memory pressure regardless of TTLs (unless a volatile-* policy is picked), while expiration is
what ordinarily reclaims memory from TTL'd keys long before memory pressure ever becomes a factor
— two separate levers, worth naming separately.
Three classic caching failure modes
Interviewers expect precise terminology for cache failures and their mitigations:
- Cache Penetration: Requests target keys that exist neither in Redis nor in the primary DB (e.g. malicious probes or deleted IDs). Every request bypasses Redis and hammers the DB.
- Fix: Cache empty/null results with a short TTL (e.g., 60s), or place a Bloom Filter in front of Redis to reject non-existent keys instantly.
- Cache Avalanche: A massive batch of keys expires simultaneously (e.g., all set at midnight), or a whole Redis node fails. The database is instantly crushed by incoming traffic.
- Fix: Add random jitter to key TTLs (
TTL = 300s + random(0, 60s)), use multi-node high availability (Redis Sentinel/Cluster), and apply circuit breakers.
- Fix: Add random jitter to key TTLs (
- Cache Breakdown (Stampede / Thundering Herd): A single extremely hot key (e.g., viral news article) expires, causing thousands of concurrent requests to miss at once and hit the database simultaneously.
Cache Breakdown: N requests, one hot key, no coordination
Hot key expires
e.g. a trending product's price
Request 1
Request 2
Request 3
Database
all N requests land here simultaneously
The standard fix is a short-lived distributed lock around the DB query:
Winner (1 of N requests)
SET lock:key token NX PX 5000
succeeds — this request wins the lock
Recomputes from the database
Writes new cache value & releases lock safely
Lua script verifies token match before deleting
Losers (the other N-1 requests)
SET lock:key token NX PX 5000
fails — lock is already held
Wait briefly (spin/sleep 50ms)
Read the now-populated cache once winner finishes
Hot keys vs. Big keys
- Hot keys: Skewed access frequency on one key (e.g., breaking news). Scaling the cluster doesn't help because 16,384 hash slots map that key to a single node. Fix: Replicate key with random suffixes (
key:1,key:2) across nodes, or add an in-process local cache (L1 cache with Redis 6+TRACKING ONinvalidation). - Big keys: Skewed memory size on one key (e.g., a Hash with 10M fields). Causes high memory fragmentation and network I/O lag. Fix: Split big keys into smaller sub-keys (
hash:user:part1,hash:user:part2).
Pub/Sub vs. Redis Streams
Redis provides two distinct messaging mechanisms that are often confused:
| Feature | Redis Pub/Sub | Redis Streams |
|---|---|---|
| Persistence | None (at-most-once, fire-and-forget) | Persistent append-only log on disk/RAM |
| Consumer Groups | No (all subscribers get all messages) | Yes (load-balance messages across workers) |
| Message Replay | No (disconnected clients lose messages) | Yes (read historical ranges via offset) |
| Acknowledgments | No | Yes (XACK and Pending Entries List) |
| Best Used For | Real-time notifications, chat signaling | Job queues, event sourcing, lightweight Kafka alternative |
Persistence: RDB, AOF, and Hybrid
Redis is in-memory first, but offers two persistence mechanisms with clear trade-offs:
- RDB (Snapshotting) — a point-in-time snapshot of the dataset saved to disk periodically (
bgsave, viafork()). Fast to restore, and cheap while it runs — the forked child process writes the snapshot while the parent keeps serving traffic. Butfork()'s copy-on-write means every memory page a write touches during the snapshot gets duplicated first, so a write-heavy workload mid-snapshot can push memory usage toward double the dataset size — a real cause of production OOM kills on memory-constrained instances, not just a theoretical edge case. Data written since the last snapshot is also lost on a crash. - AOF (Append-Only File) — logs every write command. Offers higher durability (
fsyncevery second or per query), but results in larger files and slower recovery times. - Hybrid (RDB + AOF preamble) — default since Redis 5.0. Rewritten AOF files start with an RDB snapshot as a preamble followed by incremental AOF logs, giving fast restarts with minimal data loss.
Scaling Redis: Replication & Cluster
A natural follow-up is "How does Redis scale horizontally?"
Replication (High Availability with Sentinel)
A primary node handles writes and asynchronously replicates to read replicas. If a primary fails, Redis Sentinel nodes perform quorum voting and automatically promote a replica to primary.
One primary, multiple read replicas
Primary
handles all writes
Replica 1
Replica 2
Replica 3
Replication is asynchronous by default: a write returns to the client as soon as the primary has
it, and replicas catch up shortly after over the replication stream. That gap is normally
milliseconds, but it means a read sent to a replica immediately after a write can return stale
data — the classic read-your-own-writes problem. A design that needs a client to always see its
own write has to route that read to the primary, or use WAIT to block until N replicas confirm
the write, at the cost of added latency. Failover isn't instantaneous either: Sentinel has to
detect the primary is actually down (not just slow) and reach quorum before promoting a replica,
which takes seconds, not milliseconds — worth naming as a real availability gap rather than
treating Sentinel failover as invisible to callers.
Redis Cluster (Sharding & Partitioning)
Redis Cluster shards data across multiple primary nodes using 16,384 fixed hash slots:
- Each key is assigned to a slot:
CRC16(key) % 16384. - Nodes communicate via a gossip protocol to maintain cluster topology and handle automated failover.
- A client that asks the wrong node for a key isn't served an error, it's redirected:
MOVEDpoints it permanently at the right node (a well-behaved client updates its local slot map and goes straight there next time), whileASKis a one-off redirect used only while a slot's data is actively being migrated between nodes. - A cluster needs a minimum of three primary nodes to run at all. Below that there's no majority to vote a failover, so the cluster stops accepting writes the moment any primary becomes unreachable — "add more nodes" has a floor, not just a ceiling.
When Redis is the wrong answer
If your system requires:
- ACID transactions with multi-table joins — reach for PostgreSQL / MySQL.
- Strict zero-data-loss durability — Redis async replication and AOF
fsync=everyseccan lose ~1 second of data on hard crashes. - Data set size far exceeding available RAM — storing multi-terabytes of cold data in Redis is cost-prohibitive compared to SSD-backed disk stores like DynamoDB or Cassandra.