Valkey ships with io-threads 1, so the main thread reads every request, parses it, runs it, and writes the reply. Turning on I/O threads takes one line of config, but the published advice disagrees: the 9.1.3 valkey.conf suggests 6 threads on 8 cores, and a Valkey blog post says core count minus 1 to 2. CPU graphs stop helping once the threads are on, because idle threads busy-wait and show close to 100%. Valkey 9.1 added the used_active_time_main_thread and used_active_time_io_thread_N counters for exactly this, and since 9.0 you can change io-threads without a restart. This post turns both into a sizing method: take a baseline, raise the count step by step, measure throughput and p99, and stop at the knee. Valkey has no documentation page for I/O threads, so the behavior here comes from the 9.1.3 source and release notes.
io-threadscounts the main thread:io-threads 4means 3 I/O threads. I/O threads read, parse, and write; every command still runs on the main thread.- On 9.1, judge thread load with the
used_active_time_*counters inINFO cpu, not CPU percent. Sample twice and divide the difference by the interval. - If the main thread is close to 100% active at peak, more I/O threads cannot help. Reduce the work per command or shard instead.
- Size by stepping
io-threadsat runtime (9.0 and later) under a repeatable benchmark, and stop when throughput stops rising or p99 at a fixed rate gets worse. - Run 9.1.3 or later. The 9.1.1 to 9.1.3 patches fix I/O-thread reply corruption, two throughput regressions, and event loop busy-spinning on some VMs.
Scope and review date: November 2026. This post covers self-managed Valkey 9.1 (9.1.3 is the current patch, released 26 October 2026), with notes on 8.0, 8.1, 9.0, and the 9.2 release candidates. Config names, defaults, INFO fields, and log and error strings were checked against the Valkey documentation, release notes, and the 9.1.3 source on 10 November 2026. The commands were not run in a lab, and every number shown in a card is illustrative, not measured. Managed services may not expose io-threads at all, so check your provider's parameter list.
What do Valkey I/O threads do, and what stays on the main thread?
Valkey executes commands on one thread. The THREADED I/O section of valkey.conf for 9.1.3 describes I/O threads as handling socket reads, socket writes, and protocol parsing. The 9.1.3 io_threads.c lists every job an I/O thread can take:
- read a client's query from the socket and parse it (TLS records are decrypted here too);
- write a reply to the socket (and encrypt it, for TLS);
- run
epoll_waiton the main thread's behalf; - complete a TLS handshake for a new connection;
- free reply objects and command arguments the main thread has finished with.
Everything else stays on the main thread: command execution, Lua scripts and functions, key prefetching before a batch runs, active expiry and eviction, blocked clients such as a waiting BLPOP, reads from replica connections on a primary, and, in 9.1, the cluster bus.
The request path with I/O threads on
Follow the numbers: I/O threads move and parse bytes, and the main thread runs every command
Sizing follows from this: I/O threads take syscalls, parsing, TLS, and reply writing off the main thread. A main thread that is busy executing commands gets no help from more of them.
io-threads 1, the default, means no I/O threads. In Valkey's own 8.0 benchmark recipe, --io-threads 9 meant 8 I/O threads plus the main thread. INFO lists one used_active_time_io_thread_N line per I/O thread, numbered from 1.
How did I/O threading change between Valkey 8.0 and 9.1?
Valkey 8.0 redesigned I/O threading so the main thread and I/O threads run at the same time instead of taking turns. It moved epoll_wait, which Valkey measured at more than 20% of main-thread time, onto the I/O threads, and added key prefetching (Valkey's 8.0 I/O threading post). Valkey reported SET throughput rising from 360K to 1.19M requests per second against 7.2 with 8 I/O threads: a vendor benchmark, not a sizing target.
| Version | Change | Why it matters for sizing |
|---|---|---|
| 8.0 | Asynchronous I/O threads, epoll_wait offload, key prefetching (#861) | Threads are activated by load, so the setting is a maximum |
| 8.1 | Up to 256 threads (#1220); io-threads-do-reads deprecated (#1138); more TLS work moved to I/O threads (#1271), and TLS handshakes offloaded; replication stream reads offloaded (#1449) | TLS work moves to I/O threads |
| 9.0 | io-threads can be changed at runtime (#2033); pipeline parse and prefetch (#2092); replies written directly to clients (#2078) | You can step the count without restarts |
| 9.1 | used_active_time_* counters (#2463, #2931); lock-free job queues (#3324) | Thread load becomes measurable |
| 9.1.1 to 9.1.3 | Object frees offloaded (#3938); corrupted replies fixed (#4060); small-payload and pipelined throughput restored (#4401, #4440); VM busy-spin fixed (#4707) | The patch level changes your results |
| 9.2 release candidate | Cluster bus can run on I/O threads (#3438); false sharing removed on SMT x86 (#4107, #4976); a replica's link to its primary stays on the main thread (#4765) | Not GA; do not size 9.1 on RC behavior |
The 9.1.0-rc2 release notes put the lock-free queue gain at 8 to 17% throughput. The same change replaced a fixed thread activation heuristic with a queue-depth rule, described below.
valkey-cli INFO server | grep -E '^(valkey_version|io_threads_active|monotonic_clock):'Interpretation: valkey_version must be 9.1.x for the active-time counters. io_threads_active:1 means at least one I/O thread is working right now; 0 is normal at low load even with io-threads above 1. monotonic_clock names the clock source, which matters on VMs (see the last section).
How do you change io-threads at runtime, and what happens when you do?
In 8.1, io-threads is a startup-only setting. The 8.1.11 source still marks it immutable. Valkey 9.0 made it modifiable (9.0.0-rc1 release notes, #2033), with a range of 1 to 256.
valkey-cli CONFIG GET io-threads
valkey-cli CONFIG SET io-threads 4Interpretation: on success the server logs Changing number of IO threads from 1 to 4. In the 9.1.3 source, the main thread then waits for every queued I/O job to finish, puts all I/O threads to sleep, and creates or stops threads to reach the new count. The new threads start inactive and switch on as load rises.
The change refuses to run if too many replies are still waiting to come back from the I/O threads, because draining them could deadlock. The old value stays in place and the client gets this error:
(error) ERR CONFIG SET failed (possibly related to argument 'io-threads') - Can't update IO threads under load, try again laterInterpretation: nothing changed. Retry when traffic is lower, not in a tight loop.
How threads switch on matters for measurement. In 9.1.3, the first I/O thread starts only when the main thread is active more than 30% of the time. After that, one more thread is woken when the shared job queue averages more than one waiting job, and one is put back to sleep when the queue is empty on average and at least a second has passed since the last change. So io-threads is a ceiling, not a count of busy threads, and every benchmark step needs a warmup.
The resize drains the I/O queues on the main thread, which adds a short pause, and it resets the thread ramp. Change the count in a planned window, and record the old value first so you can set it back.
How do you read the used_active_time counters in Valkey 9.1?
With I/O threads on, idle threads spin while they wait for work, so per-thread CPU looks close to 100% whatever the load. The INFO reference says the active-time fields, added in 9.1, are a better guide to spare capacity because they exclude time spent waiting. Each is cumulative seconds of real work since startup. The main thread counts only event loop cycles that handled at least one event or finished work from the I/O threads, plus expiry time.
INFO cpu on Valkey 9.1 with io-threads 4
Illustrative output, 600 seconds after start: CPU time says busy, active time says otherwise
Read the gap: the main thread used about 594 seconds of CPU in 600 seconds but was active for about 422 of them. These totals cover the whole uptime; sample twice to see the current load.
The INFO reference gives the rule: if the main thread's active time grows 5 seconds between two calls 10 seconds apart, it is running at 50% of its capacity. Pull request #2463 suggests using the change in server_time_usec as the interval. This script does both for every thread:
#!/usr/bin/env bash
# thread-load.sh HOST SECONDS: per-thread active percent on Valkey 9.1+
HOST=${1:-127.0.0.1}; SECS=${2:-10}
snap() { valkey-cli -h "$HOST" INFO server cpu | tr -d '\r' \
| grep -E '^(server_time_usec|used_active_time_)'; }
a=$(snap); sleep "$SECS"; b=$(snap)
paste -d' ' <(echo "$a") <(echo "$b") | awk -F'[: ]' '
$1 == "server_time_usec" { dt = ($4 - $2) / 1e6; next }
{ printf "%-30s %5.1f%%\n", $1, 100 * ($4 - $2) / dt }'Interpretation: each line is the share of the interval that thread spent working. Do not change io-threads between the two samples, or the lines will not pair up. For alerting on these counters through Prometheus, see our Valkey monitoring guide.
Is the main thread or the I/O threads the bottleneck?
Run the script while the instance is at peak load, or under a saturating benchmark, and read the main thread first. There are three cases:
- Main thread above about 90% active. Command execution is the limit. More I/O threads add nothing; reduce the work per command, split hot keys, or shard.
- Main thread has room, and every I/O thread is close to 100% active. I/O is the limit, and one more thread should help if a core is free.
- Neither is close to 100%, but throughput has stopped rising. The limit is outside Valkey: the benchmark client, the network, or interrupt handling on the server.
The same workload at io-threads 1, 4, and 8
Illustrative values for one workload at peak, not measurements: switch the setting to compare
io-threads 1
| Signal | io-threads 1 |
|---|---|
| I/O thread lines in INFO cpu | None |
| Main thread active (10 s sample) | 96% |
| Busiest I/O thread active | Not applicable |
| Per-thread CPU in top | Main thread about 96% |
| Throughput against io-threads 1 | Baseline |
| Reading | Main thread saturated, mostly by I/O; add threads |
io-threads 4
| Signal | io-threads 4 |
|---|---|
| I/O thread lines in INFO cpu | used_active_time_io_thread_1 to _3 |
| Main thread active (10 s sample) | 72% |
| Busiest I/O thread active | 81% |
| Per-thread CPU in top | Every thread near 100% |
| Throughput against io-threads 1 | Higher |
| Reading | Room on both sides; close to the knee |
io-threads 8
| Signal | io-threads 8 |
|---|---|
| I/O thread lines in INFO cpu | used_active_time_io_thread_1 to _7 |
| Main thread active (10 s sample) | 73% |
| Busiest I/O thread active | 44% |
| Per-thread CPU in top | Every thread near 100% |
| Throughput against io-threads 1 | Same as io-threads 4 |
| Reading | The same I/O work spread thinner; go back to 4 |
Stop rule: when a higher count leaves throughput flat and only spreads the same work across more threads, keep the lower count. The spare cores do more good running interrupts, the OS, and the fork child.
Check that the threads are doing the work you expect:
valkey-cli INFO stats | grep -E '^io_threaded_(reads|writes|poll|freed|total_prefetch)'Interpretation: io_threaded_reads_processed and io_threaded_writes_processed should rise between samples whenever io_threads_active is 1. If they stay flat under load, the threads have not switched on, and the main thread is still under the 30% ignition threshold.
How do you size io-threads step by step?
Use a staging host with the production instance type, kernel, Valkey patch, and dataset size. valkey-benchmark writes keys, so never point it at production. Run it from a separate client host, with --threads matching the server's thread count, as the 9.1.3 valkey.conf advises. The --warmup and --duration options are new in 9.1 (valkey-benchmark documentation).
io-threads sizing method for Valkey 9.1
Nine steps on staging; steps 4 to 7 repeat for each thread count
-
Confirm the version, cores, and clock
Run 9.1.3 or later. Count physical cores, not only vCPUs, and note the kernel clocksource.
valkey-cli INFO server | grep valkey_version lscpu | grep -E '^(CPU\(s\)|Thread|Core|NUMA)' cd /sys/devices/system/clocksource/clocksource0 cat current_clocksource -
Reserve cores before counting threads
Set aside cores for network interrupts, the OS, and the BGSAVE and AOF rewrite child. The rest is the budget for the main thread plus I/O threads.
-
Record a baseline at io-threads 1
Same dataset, same client host. Record throughput, p99, and main-thread active percent.
valkey-cli CONFIG SET io-threads 1 -
Raise the count one step
Try 2, 3, and 4, then go up by 2. Threads restart inactive after each change, so keep the warmup.
valkey-cli CONFIG SET io-threads 4 -
Measure saturated throughput
Use your real value size and pipeline depth. Keep every flag the same at each step.
valkey-benchmark -h 10.0.0.12 -t set,get \ -d 512 -r 3000000 -c 400 --threads 8 \ --warmup 30 --duration 120 --csv -
Measure p99 at a fixed rate
Pick a rate near your real peak and keep it fixed. Throughput can rise while p99 gets worse.
valkey-benchmark -h 10.0.0.12 -t get \ -d 512 -r 3000000 -c 400 --threads 8 \ --rps 300000 --warmup 30 --duration 120 --csv -
Sample the active-time counters
Run the sampling script in a second terminal during step 5, after the warmup.
./thread-load.sh 10.0.0.12 10 -
Stop at the knee
Stop when a step adds less than about 5% throughput, p99 at the fixed rate rises, or the main thread is above about 90% active. These cut-offs are our suggestion, not a Valkey rule.
-
Persist and record the value
Write it to the config file, and repeat the method after an upgrade or an instance type change.
valkey-cli CONFIG REWRITE grep '^io-threads' /etc/valkey/valkey.conf
Rollback point: every step is a runtime CONFIG SET, so going back is one command. If it returns "Can't update IO threads under load", wait for lower traffic instead of retrying in a loop.
Record five numbers per step: thread count, saturated throughput, p99 at the fixed rate, main thread active, and busiest I/O thread active. The Valkey performance tuning guide covers the memory, persistence, and cluster settings to fix before you start, so they do not move the result between steps.
How do TLS and pipelining change the right thread count?
TLS
With TLS, decryption happens inside the read job and encryption inside the write job, so it lands on I/O threads. Since 8.1, the TLS handshake for new connections can also run there. Valkey's connection storms post reports that the handshake offload raised the rate of accepting new connections by around 300%, and that related 8.1 changes gave 10% more SET and 22% more GET throughput on TLS. Those are Valkey's figures. A thread count sized on plain TCP can be too low once TLS is on, so benchmark with it:
valkey-benchmark -h 10.0.0.12 -p 6380 --tls --cacert ca.crt \
--cert client.crt --key client.key -t set,get -d 512 -r 3000000 \
-c 400 --threads 8 --warmup 30 --duration 120 --csv
valkey-cli INFO stats | grep '^io_threaded_accept_processed'Interpretation: io_threaded_accept_processed counts handshakes completed on I/O threads. It is present in 9.1.3 INFO but missing from the INFO reference page. The 9.1.3 valkey.conf also limits new connections accepted per event loop cycle to 10 for TCP and 1 for TLS (max-new-connections-per-cycle and max-new-tls-connections-per-cycle). Raise them only if clients have bounded pools and backoff.
Pipelining
With pipelining, one socket read carries many commands, so I/O cost per command falls and the main thread becomes the limit sooner. Valkey 9.0 added parsing and prefetching for whole pipelines (#2092); Valkey's 9.0 announcement puts the gain at up to 40%. The 9.1 announcement reports 2.1 million requests per second with 512-byte values, "9 IO threads" in the post's words, and a pipeline depth of 10. Those are vendor numbers from a tuned host.
With I/O threads on, Valkey prefetches the keys of commands from several clients together, up to prefetch-batch-max-size (default 16, 0 disables it). Benchmark with -P set to the depth your clients really use: -P 1 for request and response clients, or higher for clients that batch.
Should you pin Valkey threads and network interrupts to cores?
For a sizing run, yes. Valkey's own benchmarks do. The second part of the 8.0 I/O threading post pins the NIC interrupts to dedicated cores and the main thread to another with taskset. Valkey's 9.0 large-cluster test ran io-threads 6 on 8-core r7g.2xlarge nodes with 2 cores reserved for interrupts. The performance dashboard pins interrupts away from the server's CPUs and puts server and client on separate NUMA nodes.
Valkey can set CPU affinity itself. In the 9.1.3 source, I/O threads apply server-cpulist just like the main thread, so that one list covers both. The other lists cover background threads and child processes. All four are startup-only.
# Example layout for one instance on a 16-vCPU host; adapt to lscpu output.
# CPUs 0-1: NIC interrupts. CPUs 12-15: OS and agents.
io-threads 6
server-cpulist 2-7 # main thread and the 5 I/O threads
bio-cpulist 8-9 # bio threads: lazy free, AOF fsync, file close
bgsave-cpulist 10-11 # BGSAVE child (and slot migration child)
aof-rewrite-cpulist 10-11 # AOF rewrite childInterpretation: the main thread and its I/O threads get a core each, and the fork child does not compete with them. A restart applies the lists.
grep -E 'eth0|ens5' /proc/interrupts | awk '{print $1, $NF}'
echo 0-1 | sudo tee /proc/irq/48/smp_affinity_list # repeat per IRQ
systemctl is-active irqbalance # if active, it can move IRQs back
ps -T -p "$(pidof valkey-server)" -o spid,comm,psr,pcpuInterpretation: the first line lists the NIC queue IRQ numbers; the IRQ number 48 is only an example. In the ps output, I/O threads are named io_thd_1 and up, and psr shows the CPU each last ran on, which should fall inside server-cpulist. A pcpu near 100 on an I/O thread is the busy-wait, not load.
When do more I/O threads make Valkey slower?
An active I/O thread spins on its core while it waits, so extra threads are not free. Watch for these cases.
- The main thread is the limit. If it is above about 90% active, added I/O threads only add spinning cores and contention.
- More threads than reserved cores. The OS time-slices Valkey threads against interrupts and each other. Four instances at
io-threads 6on one host need 24 cores. - Fork children lose CPU. BGSAVE and AOF rewrite children compete with spinning threads, so snapshots take longer and copy-on-write memory grows for longer.
- SMT siblings on x86. The 9.2 release candidates remove false sharing that hurt I/O-thread throughput on SMT x86 (#4107, #4976). On 9.1, treat physical cores as the budget rather than hyperthreads.
- The step from 6 to 7. In the 9.1.3 source, reply copy avoidance applies to raw-encoded string replies of any size once
io-threadsis 7 or more (the hidden settingmin-io-threads-avoid-copy-reply). Below that, with threads on, it applies only to strings of 64 KB and up. Measure across that step. - VMs on 9.1.0 to 9.1.2. When the kernel clocksource is not
tsc, the event loop can busy-spin (#4707, fixed in 9.1.3), which makes every thread look loaded.
valkey-cli INFO stats | grep -E '^(latest_fork_usec|total_forks):'
valkey-cli INFO persistence | grep -E '^(rdb_last_bgsave_time_sec|aof_last_rewrite_time_sec):'Interpretation: compare these across a thread count change, during a scheduled BGSAVE. If the fork or snapshot gets longer after raising io-threads, the child is short of CPU: give it cores with bgsave-cpulist or step the count back. For spikes unrelated to threads, see our Valkey latency incident runbook.
Frequently asked questions
How many io-threads should I use for Valkey?
There is no fixed rule. The 9.1.3 valkey.conf suggests 2 or 3 threads on 4 cores and 6 on 8 cores, and Valkey's blog suggests core count minus 1 to 2. Treat those as starting points. Reserve cores for interrupts, the OS, and the fork child, then step the count up under a benchmark and stop when throughput stops rising or p99 gets worse.
Does the io-threads value include the Valkey main thread?
Yes. io-threads counts the main thread, so io-threads 1, the default, means no I/O threads, and io-threads 4 starts 3 I/O threads. INFO cpu on Valkey 9.1 lists one used_active_time_io_thread_N field for each I/O thread, numbered from 1, next to used_active_time_main_thread.
Can I change io-threads without restarting Valkey?
Yes, from Valkey 9.0. Run CONFIG SET io-threads with the new value; in 8.1 and earlier the setting is fixed at startup. Under heavy load the change can fail with "Can't update IO threads under load, try again later", and the old value stays. Make the change in a planned window and persist it with CONFIG REWRITE.
Why does Valkey show close to 100% CPU per thread with I/O threads enabled?
Valkey busy-waits when I/O threads are active, so each thread uses CPU even while it has no work. CPU percent therefore overstates load. On Valkey 9.1, read used_active_time_main_thread and used_active_time_io_thread_N in INFO cpu instead. Sample them twice and divide the difference by the interval to get the real share of time each thread spent working.
Do I/O threads make slow commands or Lua scripts faster in Valkey?
No. I/O threads read, parse, and write network data, and handle TLS, but every command, Lua script, and function still runs on the main thread. A slow KEYS call or a long script blocks other clients just as it does with io-threads 1. Find those with COMMANDLOG and fix the command, or shard the workload.
Key takeaways
io-threadsis a ceiling that includes the main thread; Valkey 9.1 switches I/O threads on only when the main thread is more than 30% active.- Use the 9.1
used_active_time_*counters, sampled over an interval, to see whether the main thread or the I/O threads limit you. CPU percent cannot tell you. - Size on staging: baseline at 1, step up with runtime CONFIG SET, measure saturated throughput and p99 at a fixed rate, and stop at the knee.
- Benchmark with production TLS settings and pipeline depth, and pin interrupts, the server threads, and fork children to separate cores.
- Run 9.1.3 or later before measuring, and repeat the method after upgrades or instance changes.
Size Valkey I/O threads with JusDB
JusDB's Valkey performance tuning team can review your io-threads and CPU layout against your core count, TLS use, and pipeline depth, plan a staging benchmark that steps the thread count, and read the active-time counters with you.
Bring your Valkey version, instance type, current config, and a recent INFO output from peak traffic. Your team approves every production change.