What does "Redis is single-threaded" mean when there are I/O threads? Basic
Estimated reading time: 7 min read
The problem
Section titled “The problem”An endpoint that fetches a profile with GET is usually very fast. One day p99 jumps even though request volume is unchanged and the network is clean.
The cause: another client is running a command over a large dataset. Commands on a single Redis instance run sequentially, so a cheap GET still has to wait its turn.
“Redis is single-threaded” is true for command execution, but not for the whole process:
| Component | Thread |
|---|---|
| Command execution (keyspace reads/writes, Lua) | Main thread, one command at a time |
| Background work (AOF fsync, closing files, lazy free) | Background threads |
| Socket reads/writes, protocol parsing | Optional I/O threads (io-threads) |
Redis 7, Redis 8 and Valkey 8
Section titled “Redis 7, Redis 8 and Valkey 8”| Redis 7 (used in this article’s demo) | Redis 8 / Valkey 8 | |
|---|---|---|
| I/O threading | Available since Redis 6, off by default | Reworked around an asynchronous model |
io-threads |
Off by default (redis.conf: “By default threading is disabled”) |
Enabled with io-threads N |
| Who runs commands | Main thread | Still the main thread |
According to the Redis blog on 8.0, the processing flow is:
- The main thread assigns clients to I/O threads.
- An I/O thread reads and parses the query, then notifies the main thread.
- The main thread processes the query and produces the reply.
- The I/O thread writes the reply to the socket.
io-threads defaults to 1 (main thread only). Valkey 8 also changed how it uses threads (per Valkey’s GA announcement). Throughput numbers depend on hardware and workload; this article does not quote them.
The conclusion holds for every version: I/O threads increase socket-handling capacity with many connections; they do not split a Lua script or an SMEMBERS on a large set into parallel pieces. Enable io-threads when socket I/O is eating the main thread’s CPU, not to fix slow commands.
How it works
Section titled “How it works”slow commandClient A sends a command that has to scan many elements. It starts occupying Redis's command execution thread.
The time B measures = queueing time + processing time + network + client scheduling. PING is not slow; it waits.
Separate two ideas: connection concurrency (many clients waiting on I/O at once) and command parallelism (many commands using CPU at once). Redis has the former, not the latter, on a single instance.
Atomic per command, not per sequence
Section titled “Atomic per command, not per sequence”INCR counter is atomic: two clients sending it at once never lose an increment. But GET, compute in code, then SET is two commands, and another client can slip in between.
For multiple steps, use a dedicated command, MULTI/EXEC with WATCH, or a Lua script. Lua runs atomically; in exchange, every other client waits for the whole script.
Lua running too long: BUSY and SCRIPT KILL
Section titled “Lua running too long: BUSY and SCRIPT KILL”A script does not block forever. After busy-reply-threshold (alias lua-time-limit, default 5 seconds per redis.conf), Redis starts returning BUSY errors to other clients.
- The script has not written anything to the dataset:
SCRIPT KILLworks. - The script has written: the only way out is
SHUTDOWN NOSAVE.
Keep scripts far shorter than 5 seconds: 5 seconds is the last-resort threshold, not a budget.
Dangerous commands and their replacements
Section titled “Dangerous commands and their replacements”| Command | Problem | Replace with |
|---|---|---|
KEYS pattern |
Scans the whole keyspace in one go | SCAN (in batches) |
SMEMBERS, HGETALL on large collections |
Returns every element at once | SSCAN, HSCAN, or cap the collection size |
DEL on a very large key |
Frees memory synchronously on the main thread | UNLINK (frees in the background) |
FLUSHALL/FLUSHDB |
Synchronous deletion | add ASYNC |
| Heavy Lua script | Blocks every client | Batch it, keep scripts short, measure duration |
Transactions with many GET-SET steps |
Races between commands | INCR/a fitting command, WATCH, short Lua |
Redis’s latency documentation calls KEYS a very common source of latency in production and says it should be used only for debugging.
Observability
Section titled “Observability”| Tool | Tells you | Limitation |
|---|---|---|
SLOWLOG GET 5 |
Commands exceeding the execution-time threshold | Excludes network time; not a complete history |
INFO commandstats |
Call counts and usec_per_call per command name |
Does not say which key caused the slowness |
LATENCY DOCTOR |
Describes latency events Redis recorded | Requires the latency monitor to be enabled |
redis-cli --bigkeys |
Large keys per type | Scans the keyspace, run with care |
If SLOWLOG is clean but clients are still slow, check the network, the application-side event loop, the connection pool, and large payloads.
Real-world example
Section titled “Real-world example”Two-connection demo
Section titled “Two-connection demo”The demo runs a short CPU-bound Lua loop on a local Redis; do not run it on a shared or production Redis.
docker compose -f examples/docker-compose.yml up -d redisnpx --prefix examples ts-node examples/src/redis-event-loop/command-queue-demo.tsdocker compose -f examples/docker-compose.yml exec -T redis redis-cli SLOWLOG GET 5Connection A runs a Lua loop that only crunches numbers; 10 ms later, B sends PING, and the script prints pingWaitMs and then confirms Lua has finished. If the loop ends before B sends, the measured time looks almost normal; increase the iteration count locally to see the queueing.
The demo needs Docker, so it was not rerun during this review and the article includes no output; run it on your machine to see pingWaitMs.
Trade-offs
Section titled “Trade-offs”Strengths of the model
- Each command is clearly atomic within an instance, a good fit for counters and short operations
- Little synchronization overhead between execution workers
- I/O threads and background work raise socket throughput without changing the command model
Bottlenecks to manage
- A long-running command or Lua script drags up tail latency for every client
- One instance cannot use multiple cores for command logic; scaling needs cluster/sharding or workload separation
- Single-command atomicity does not replace a multi-step business transaction
Production checklist
Section titled “Production checklist”- Never use
KEYSin application code; useSCAN. - Use
UNLINKinstead ofDELfor large keys;FLUSH*withASYNC. - Cap collection sizes; iterate with
*SCAN. - Keep Lua scripts short and measure them; treat
busy-reply-thresholdonly as a safety net. - Enable
io-threadsonly when measurements show socket I/O eating main-thread CPU (many connections). - Track client-side p99 alongside
SLOWLOGandINFO commandstats. - Separate reporting/batch workloads from the instance serving requests.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why can PING be slow while another client runs EVAL? (2) When does SCRIPT KILL work? (3) If SLOWLOG is empty but requests are still slow, which other layers do you check?