August 3, 2026
Interview: How Does Redis Handle 100 Million Requests Per Second?
An interview-style deep dive, told the way Iβd explain it across the table.

By Dylan Smith
11 min read
An interview-style deep dive, told the way I'd explain it across the table.
My articles are open to everyone; non-member readers can read the full article by clicking this link.
I'll be posting future updates first on Substack; they'll also be posted on Medium, but a little later. If you want to be the first to read them, follow me on Substack! Thanks for your support!
I once watched a production Redis instance push past a million operations per second on a single box, CPU pinned at 60%, and the engineer next to me just shrugged and said, "Yeah, that's nothing for it." That moment stuck with me, because it forced me to ask the real question: what is it about Redis's design that lets it scale to 100 million requests per second?
When I get this question in an interview β and I've been asked it more than once β I don't start with benchmarks. I start with the thing that confuses everyone first: Redis's core is single-threaded. How can something single-threaded beat multi-threaded systems? Let me walk through it the way I'd answer it, layer by layer.
1. Why a Single-Threaded Model Is Actually Faster
The intuitive answer is "more threads = more throughput." In practice, for an in-memory key-value store, that intuition is wrong. Here's why.
1.1 The concurrency tax nobody talks about
Multi-threaded designs pay three kinds of tax on every operation:
Lock contention. The moment two threads can touch the same hash table, you need a mutex. Under high load, threads spend a surprising fraction of their time waiting, not working. And it gets worse with fine-grained locking: more locks mean more complexity, more deadlock risk, and more cache-line ping-pong.
Context switching. Every thread switch costs 1β10 microseconds: saving registers, flushing the TLB, losing the CPU's cached view of your data structures. A Redis operation on hot data takes well under a microsecond. If your scheduling overhead is bigger than your actual work, threads are a net loss.
Cache line invalidation. This is the silent killer. Modern CPUs read memory in 64-byte cache lines. When core A writes to a variable, core B's copy of that cache line is invalidated and must be re-fetched over the interconnect. With eight threads hammering a shared hash table, you get constant false sharing β cores invalidating each other's caches over unrelated variables that happen to sit on the same cache line. Throughput collapses not because of any bug, but because of physics.
Redis sidesteps all three taxes with a blunt instrument: one thread owns the data. No locks on the hot path. No contention. The entire dataset lives in L1/L2 cache and stays there.
1.2 But wait β doesn't single-threaded mean you can only use one core?
That's the follow-up I always expect, and it's a fair one. The answer is: for the command execution path, yes β and that's fine. Redis's bottleneck is almost never CPU compute; it's network I/O and memory bandwidth. And when you do need more cores, you scale out (more instances, Redis Cluster) rather than up with threads. We'll get to that.
The mental model I use: Redis trades parallelism for the elimination of synchronization. When your critical section is the entire dataset, the cheapest lock is the one you never need.
2. The Reactor Pattern and I/O Multiplexing
So how does one thread juggle tens of thousands of client connections? This is where the answer gets interesting: Redis is a textbook Reactor pattern implementation built on I/O multiplexing β epoll on Linux, kqueue on BSD/macOS.
2.1 The key insight: don't block, register
A naive server reads from a socket and blocks until data arrives. With 10,000 clients, that's 10,000 blocked threads. Redis instead asks the kernel: "tell me when any of these 10,000 sockets is actually ready," and only then processes it. The single thread never waits on I/O β it waits on the event multiplexer, which returns only sockets that have work to do.
2.2 Inside aeMain() β the heart of Redis
Every Redis instance spends its life in one function, aeMain(). If I had to whiteboard it in an interview, this is what I'd draw:
And here's the core logic as C pseudocode β close enough to ae.c to be honest, simplified enough to read in thirty seconds:
/* Simplified version of Redis's ae.c event loop */
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
}
}
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
/* 1. Find the nearest time event to bound the poll timeout */
aeTimeEvent *shortest = aeSearchNearestTimer(eventLoop);
struct timeval tvp = timeUntilNextTimer(shortest);
/* 2. Ask the kernel: which sockets are ready RIGHT NOW? */
int numevents = aeApiPoll(eventLoop, &tvp); /* epoll_wait / kevent under the hood */
/* 3. Handle only the sockets that actually have work */
for (int j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
if (fe->mask & AE_READABLE) fe->rfileProc(eventLoop, fd, ...); /* readQueryFromClient */
if (fe->mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, ...); /* sendReplyToClient */
}
/* 4. Run due time events: incremental rehash, expiry scans, cron */
processTimeEvents(eventLoop);
return numevents;
}/* Simplified version of Redis's ae.c event loop */
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP);
}
}
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
/* 1. Find the nearest time event to bound the poll timeout */
aeTimeEvent *shortest = aeSearchNearestTimer(eventLoop);
struct timeval tvp = timeUntilNextTimer(shortest);
/* 2. Ask the kernel: which sockets are ready RIGHT NOW? */
int numevents = aeApiPoll(eventLoop, &tvp); /* epoll_wait / kevent under the hood */
/* 3. Handle only the sockets that actually have work */
for (int j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
if (fe->mask & AE_READABLE) fe->rfileProc(eventLoop, fd, ...); /* readQueryFromClient */
if (fe->mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, ...); /* sendReplyToClient */
}
/* 4. Run due time events: incremental rehash, expiry scans, cron */
processTimeEvents(eventLoop);
return numevents;
}The details worth mentioning in an interview:
- On Linux, Redis uses
epollin edge-triggered mode with an event array, so the cost of polling is O(ready events), not O(total connections).select()/poll()are O(n) per call β that's whyepollmatters at scale. - Time events are never late by more than one loop iteration, because the poll timeout is always bounded by the nearest timer. That's how
activeExpireCycleruns smoothly alongside client traffic. - Even incremental rehash (covered next) gets a slice of CPU here β a few milliseconds per loop, never a big pause.
One sentence I like to say out loud at this point: "Redis isn't fast despite being single-threaded β it's fast because the single thread never does anything slow: it never blocks, never locks, and never context-switches."
3. In-Memory Data Structures Built for Speed
The event loop explains how Redis handles requests. The data structures explain why each request is so cheap. Two of my favorites: SDS and the dict with progressive rehash.
3.1 SDS β Simple Dynamic String
Redis doesn't use C strings. It rolls its own: the SDS. The definition is small, but every field is a deliberate performance choice:
/* Redis SDS (simplified; real version has sdshdr5/8/16/32/64 variants) */
struct sdshdr {
int len; /* used length -> O(1) strlen, no scanning for '\0' */
int free; /* unused capacity -> amortized O(1) append */
char buf[]; /* flexible array holding the actual bytes */
};/* Redis SDS (simplified; real version has sdshdr5/8/16/32/64 variants) */
struct sdshdr {
int len; /* used length -> O(1) strlen, no scanning for '\0' */
int free; /* unused capacity -> amortized O(1) append */
char buf[]; /* flexible array holding the actual bytes */
};Why this matters in practice:
- O(1) length queries.
STRLENon a C string scans for the null terminator β O(n). On a 1 MB value, that's the difference between a nanosecond and a millisecond. - No buffer overflows.
strcattrusts you; SDS checksfreeand reallocates itself. - Space pre-allocation. Appends under 1 MB double the buffer, so repeated
APPENDis amortized O(1) instead of reallocating on every write. - Binary-safe.
lendefines the string, not a null byte β you can store images, protobuf, whatever. - Five header sizes (
sdshdr5throughsdshdr64) mean a 3-byte key pays for a 1-byte length field, not an 8-byte one. At billions of keys, that's gigabytes saved.
3.2 The dict: hash table + incremental rehash
Redis's core keyspace is the dict β chained-hash tables with MurmurHash/FNV and incremental rehashing. The classic problem: when a hash table doubles in size, rehashing millions of keys in one shot would freeze the single thread for hundreds of milliseconds. Unacceptable.
Redis's answer: spread the rehash across many small steps, doing a little bit of work on every access.
/* Simplified from dict.c β the progressive rehash contract */
typedef struct dict {
dictht ht[2]; /* two tables: source and destination */
long rehashidx; /* -1 = not rehashing; else next bucket to move */
} dict;
/* Called incrementally: move `n` buckets, never more */
void dictRehash(dict *d, int n) {
while (n-- && d->ht[0].used != 0) {
/* find next non-empty bucket */
while (d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
/* move all entries in this bucket to ht[1] */
migrateEntries(d->ht[0].table[d->rehashidx], &d->ht[1]);
d->rehashidx++;
}
if (d->ht[0].used == 0) { ht[1] becomes ht[0]; rehashidx = -1; } /* done */
}
/* Every lookup/delete/insert helps a little while rehash is active */
dictEntry *dictFind(dict *d, const void *key) {
if (dictIsRehashing(d)) dictRehash(d, 1); /* pay one step of the toll */
/* search ht[0], then ht[1] */
}/* Simplified from dict.c β the progressive rehash contract */
typedef struct dict {
dictht ht[2]; /* two tables: source and destination */
long rehashidx; /* -1 = not rehashing; else next bucket to move */
} dict;
/* Called incrementally: move `n` buckets, never more */
void dictRehash(dict *d, int n) {
while (n-- && d->ht[0].used != 0) {
/* find next non-empty bucket */
while (d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
/* move all entries in this bucket to ht[1] */
migrateEntries(d->ht[0].table[d->rehashidx], &d->ht[1]);
d->rehashidx++;
}
if (d->ht[0].used == 0) { ht[1] becomes ht[0]; rehashidx = -1; } /* done */
}
/* Every lookup/delete/insert helps a little while rehash is active */
dictEntry *dictFind(dict *d, const void *key) {
if (dictIsRehashing(d)) dictRehash(d, 1); /* pay one step of the toll */
/* search ht[0], then ht[1] */
}The beauty is in the amortization: no single operation ever pays the full rehash cost. Worst-case latency stays flat while the table grows from 1M to 100M keys. Same idea powers KEYS alternatives and why SCAN exists β Redis consistently refuses to do big things all at once.
4. Pipelining: Killing the RTT Bottleneck
Now let's leave the server and look at the client side β because I've profiled plenty of "slow Redis" problems that turned out to be pure network latency.
Here's the trap: on a 0.5 ms RTT network, sending 100,000 commands one at a time costs 50 seconds of pure round trips, even if Redis executes each in 10 microseconds. The bottleneck isn't Redis; it's physics.
Pipelining flips the model: send a batch of commands without waiting for replies, then read all replies at once. RTT is paid once, not N times.
4.1 Wrong way vs. right way
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
public class PipelineDemo {
public static void main(String[] args) {
try (Jedis jedis = new Jedis("localhost", 6379)) {
int N = 100_000;
// β Wrong: one round trip per command
// With 0.5ms RTT this takes ~50 seconds no matter how fast Redis is.
long start = System.nanoTime();
for (int i = 0; i < N; i++) {
jedis.set("key:" + i, String.valueOf(i)); // send -> WAIT for reply -> next
}
System.out.printf("naive: %.2fs%n", (System.nanoTime() - start) / 1e9);
// β
Right: pipeline batches commands, one round trip per batch
start = System.nanoTime();
Pipeline pipe = jedis.pipelined(); // pipeline != MULTI/EXEC
for (int i = 0; i < N; i++) {
pipe.set("key:" + i, String.valueOf(i));
}
pipe.sync(); // one big send, one big read
System.out.printf("pipeline: %.2fs%n", (System.nanoTime() - start) / 1e9);
}
}
}import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
public class PipelineDemo {
public static void main(String[] args) {
try (Jedis jedis = new Jedis("localhost", 6379)) {
int N = 100_000;
// β Wrong: one round trip per command
// With 0.5ms RTT this takes ~50 seconds no matter how fast Redis is.
long start = System.nanoTime();
for (int i = 0; i < N; i++) {
jedis.set("key:" + i, String.valueOf(i)); // send -> WAIT for reply -> next
}
System.out.printf("naive: %.2fs%n", (System.nanoTime() - start) / 1e9);
// β
Right: pipeline batches commands, one round trip per batch
start = System.nanoTime();
Pipeline pipe = jedis.pipelined(); // pipeline != MULTI/EXEC
for (int i = 0; i < N; i++) {
pipe.set("key:" + i, String.valueOf(i));
}
pipe.sync(); // one big send, one big read
System.out.printf("pipeline: %.2fs%n", (System.nanoTime() - start) / 1e9);
}
}
}On a typical cross-AZ link, I've seen this turn 45 seconds into 1.2 seconds β a ~40x win with zero server-side changes.
Two things I always add:
- Size your batches. A pipeline of 100k commands builds a large output buffer on the server. Batches of 1,000β10,000 are usually the sweet spot between throughput and memory.
- Pipeline β transaction. A pipeline just batches I/O; commands can interleave with other clients'. Use MULTI/EXEC (e.g., Jedis's
Transaction) only when you need atomicity.
5. Scaling to 100M RPS: Redis Cluster
A single Redis instance tops out around 1M+ ops/s on modern hardware (more with threaded I/O, next section). To reach 100 million, we scale horizontally with Redis Cluster: 16,384 hash slots distributed across masters, each master with replicas for failover.
Rough math I'd share in an interview: 100 shards Γ ~1M ops/s each β 100M RPS, with headroom. The cluster isn't magic β it's partitioning plus a gossip protocol.
5.1 Sharding topology
How keys land on slots: CRC16(key) mod 16384. Here's the Java version:
import java.nio.charset.StandardCharsets;
public class SlotCalculator {
/**
* Redis Cluster slot for a key.
*
* Honors hash tags: '{user:1}.profile' hashes only 'user:1',
* letting you force related keys onto the same slot (e.g. for MULTI).
*/
public static int keySlot(String key) {
int s = key.indexOf('{'), e = key.indexOf('}');
if (s != -1 && e != -1 && e > s + 1) {
key = key.substring(s + 1, e); // hash tag wins
}
return crc16(key.getBytes(StandardCharsets.UTF_8)) % 16384; // XMODEM CRC16
}
private static int crc16(byte[] data) {
int crc = 0;
for (byte b : data) {
crc ^= (b & 0xFF) << 8;
for (int i = 0; i < 8; i++) {
crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1;
crc &= 0xFFFF;
}
}
return crc;
}
public static void main(String[] args) {
System.out.println(keySlot("user:1000")); // e.g. 6918
System.out.println(keySlot("{user:1000}.profile")); // same slot as 'user:1000'
}
}import java.nio.charset.StandardCharsets;
public class SlotCalculator {
/**
* Redis Cluster slot for a key.
*
* Honors hash tags: '{user:1}.profile' hashes only 'user:1',
* letting you force related keys onto the same slot (e.g. for MULTI).
*/
public static int keySlot(String key) {
int s = key.indexOf('{'), e = key.indexOf('}');
if (s != -1 && e != -1 && e > s + 1) {
key = key.substring(s + 1, e); // hash tag wins
}
return crc16(key.getBytes(StandardCharsets.UTF_8)) % 16384; // XMODEM CRC16
}
private static int crc16(byte[] data) {
int crc = 0;
for (byte b : data) {
crc ^= (b & 0xFF) << 8;
for (int i = 0; i < 8; i++) {
crc = (crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1;
crc &= 0xFFFF;
}
}
return crc;
}
public static void main(String[] args) {
System.out.println(keySlot("user:1000")); // e.g. 6918
System.out.println(keySlot("{user:1000}.profile")); // same slot as 'user:1000'
}
}And standing up a 3-master/3-replica cluster from the CLI:
# 1. Launch six nodes (ports 7000-7005), each with cluster-enabled yes
for port in $(seq 7000 7005); do
redis-server ./nodes/$port/redis.conf &
done
# 2. Create the cluster: 3 masters, 1 replica each, slots auto-assigned
redis-cli --cluster create \
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1
# 3. Verify slot distribution and health
redis-cli --cluster check 127.0.0.1:7000# 1. Launch six nodes (ports 7000-7005), each with cluster-enabled yes
for port in $(seq 7000 7005); do
redis-server ./nodes/$port/redis.conf &
done
# 2. Create the cluster: 3 masters, 1 replica each, slots auto-assigned
redis-cli --cluster create \
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1
# 3. Verify slot distribution and health
redis-cli --cluster check 127.0.0.1:7000Three operational points worth saying in an interview:
- Clients must be cluster-aware. Smart clients (e.g.,
JedisCluster, Lettuce) cache the slotβnode map and talk to the right master directly. AMOVEDreply just means "refresh your map." - No multi-key across slots. Multi-key commands spanning slots fail unless the keys share a hash tag β that's the trade-off of partitioning.
- Failover is automatic. If a master dies, its replicas elect a replacement via gossip-based voting, with no external sentinel required.
6. The Multi-Threaded I/O Evolution (Redis 6.0 β 7.x)
Here's the part that surprises people, and it's a great way to close the answer: Redis added threads β but only where they actually help.
Redis 6.0 introduced multi-threaded I/O: socket reading, protocol parsing, and response writing can be offloaded to I/O threads, while command execution stays strictly single-threaded. Redis 7.x carried this forward with refinements, and the config became genuinely practical at high connection counts.
Why this split? Profiling showed that at very high throughput, the bottleneck wasn't executing commands β it was syscalls and string parsing of the RESP protocol. So Redis parallelized the cheap-to-parallelize part (I/O) and kept the hard-to-parallelize part (data access) lock-free.
# redis.conf β threaded I/O
io-threads 4 # I/O threads; rule of thumb: ~ half the core count
io-threads-do-reads yes# redis.conf β threaded I/O
io-threads 4 # I/O threads; rule of thumb: ~ half the core count
io-threads-do-reads yes
The lifecycle of one event-loop iteration with threaded I/O:
- Main thread collects ready reads, distributes them across I/O threads.
- I/O threads read and parse requests in parallel; main thread waits (spin) until done.
- Main thread executes all commands sequentially β the data model stays trivially safe.
- Replies are distributed back to I/O threads for parallel writes.
The honest numbers I'd quote: with 4β8 I/O threads, throughput roughly doubles on network-bound workloads, with no change in the consistency model. It's not a 4x win, because execution is still serial β and that's the design point. Redis grew threads surgically, exactly where profiling said to.
7. Key Takeaways
If I had one minute left in the interview, this is the summary I'd give:
- Single-threaded is a feature, not a limitation. By eliminating lock contention, context switches, and cache-line invalidation, Redis's hot path runs in microseconds with zero synchronization cost.
- The Reactor pattern + I/O multiplexing (
epoll/kqueue) let one thread serve tens of thousands of connections by processing only sockets that are actually ready β theaeMain()loop never blocks on any single client. - Purpose-built data structures keep every operation cheap: SDS gives O(1) length checks and amortized O(1) appends; the dict's incremental rehash spreads table resizing across millions of operations so latency never spikes.
- Pipelining removes the network round trip from the critical path β often a 10β50x client-side speedup with zero server changes. If your Redis feels slow, check the client first.
- 100M RPS comes from horizontal scaling. Redis Cluster splits keyspace into 16,384 slots (
CRC16(key) % 16384) across many masters with automatic replica failover; ~100 shards Γ ~1M ops/s gets you there. - Threaded I/O (6.0+, refined in 7.x) shows pragmatic evolution: parallelize socket I/O where profiling proved the bottleneck, keep command execution single-threaded to preserve the lock-free data model.
The deeper lesson, the one I'd want the interviewer to walk away with: Redis isn't fast because of any single trick. It's fast because every layer of the design β threading model, event loop, memory layout, wire protocol, cluster topology β was engineered to remove work rather than optimize it. The fastest instruction is the one you never execute; the fastest lock is the one that doesn't exist.