Skip to content
Knowledge Hub

What does "Redis is single-threaded" mean when there are I/O threads? Basic

Estimated reading time: 7 min read

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 (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:

  1. The main thread assigns clients to I/O threads.
  2. An I/O thread reads and parses the query, then notifies the main thread.
  3. The main thread processes the query and produces the reply.
  4. 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.

One slow command on Redis makes another client's PING wait
One slow command on Redis makes another client's PING waitStep 1/4: Client A sends a command that has to scan many elements. It starts occupying Redis's command execution thread.Client ARedis serverClient Bslow command
Client A → Redis serverslow command
Step 1/4

Client 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.

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 KILL works.
  • 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.

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.

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.

The demo runs a short CPU-bound Lua loop on a local Redis; do not run it on a shared or production Redis.

Terminal window
docker compose -f examples/docker-compose.yml up -d redis
npx --prefix examples ts-node examples/src/redis-event-loop/command-queue-demo.ts
docker compose -f examples/docker-compose.yml exec -T redis redis-cli SLOWLOG GET 5

Connection 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.

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
  1. Never use KEYS in application code; use SCAN.
  2. Use UNLINK instead of DEL for large keys; FLUSH* with ASYNC.
  3. Cap collection sizes; iterate with *SCAN.
  4. Keep Lua scripts short and measure them; treat busy-reply-threshold only as a safety net.
  5. Enable io-threads only when measurements show socket I/O eating main-thread CPU (many connections).
  6. Track client-side p99 alongside SLOWLOG and INFO commandstats.
  7. Separate reporting/batch workloads from the instance serving requests.

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?