JusDB
Free DB Audit
NoSQL Databases

Sizing Valkey io-threads: A Measurement-Based Method for 9.1

Size Valkey io-threads by measurement: what I/O threads offload, how to read the 9.1 active-time counters, a step-by-step benchmark method, and when more threads hurt.

JusDB Team
Published October 30, 2026
Updated November 10, 2026
20 min read

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.

TL;DR
  • io-threads counts the main thread: io-threads 4 means 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 in INFO 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-threads at 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_wait on 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

Valkey request path with I/O threads Step 1: the client sends a request, and an I/O thread reads it from the socket, decrypts TLS if it is used, and parses it. Step 2: the parsed command goes to the main thread, which prefetches the keys for a batch of commands and executes them one at a time. Step 3: the main thread hands the reply back to an I/O thread. Step 4: the I/O thread writes the reply to the client socket. Command execution, scripts, expiry, eviction, and blocked clients stay on the main thread. Client sends commands I/O threads read, TLS, parse Main thread prefetch, execute I/O threads TLS, write reply 1 2 3 4 main thread only: commands, Lua, expiry, eviction, blocked clients

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.

The count includes the main thread

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.

VersionChangeWhy it matters for sizing
8.0Asynchronous I/O threads, epoll_wait offload, key prefetching (#861)Threads are activated by load, so the setting is a maximum
8.1Up 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.0io-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.1used_active_time_* counters (#2463, #2931); lock-free job queues (#3324)Thread load becomes measurable
9.1.1 to 9.1.3Object 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 candidateCluster 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.

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

bash
valkey-cli CONFIG GET io-threads
valkey-cli CONFIG SET io-threads 4

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

text
(error) ERR CONFIG SET failed (possibly related to argument 'io-threads') - Can't update IO threads under load, try again later

Interpretation: 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.

Do not resize during an incident

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

valkey@cache-01:~
$ valkey-cli INFO cpu# CPUused_cpu_sys:812.402117used_cpu_user:1561.873320used_cpu_sys_children:0.000000used_cpu_user_children:0.000000used_cpu_sys_main_thread:211.306541used_cpu_user_main_thread:383.120967used_active_time_main_thread:421.937205used_active_time_io_thread_1:377.514862used_active_time_io_thread_2:352.008113used_active_time_io_thread_3:298.441790$ valkey-cli INFO stats | grep -E '^io_threaded_(reads|writes)'io_threaded_reads_processed:418553120io_threaded_writes_processed:417902664

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:

bash
#!/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

Signalio-threads 1
I/O thread lines in INFO cpuNone
Main thread active (10 s sample)96%
Busiest I/O thread activeNot applicable
Per-thread CPU in topMain thread about 96%
Throughput against io-threads 1Baseline
ReadingMain thread saturated, mostly by I/O; add threads

io-threads 4

Signalio-threads 4
I/O thread lines in INFO cpuused_active_time_io_thread_1 to _3
Main thread active (10 s sample)72%
Busiest I/O thread active81%
Per-thread CPU in topEvery thread near 100%
Throughput against io-threads 1Higher
ReadingRoom on both sides; close to the knee

io-threads 8

Signalio-threads 8
I/O thread lines in INFO cpuused_active_time_io_thread_1 to _7
Main thread active (10 s sample)73%
Busiest I/O thread active44%
Per-thread CPU in topEvery thread near 100%
Throughput against io-threads 1Same as io-threads 4
ReadingThe 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:

bash
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

  1. 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
  2. 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.

  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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.

  9. 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:

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

ini
# 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 child

Interpretation: 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.

bash
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,pcpu

Interpretation: 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 6 on 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-threads is 7 or more (the hidden setting min-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.
bash
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-threads is 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.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

Moving a Multi-Database Valkey Instance into a 9.x Cluster

Move a Valkey instance that uses SELECT 1 to 15 into a 9.x cluster: keep or remap databases, copy each one, verify counts per database, and keep a way back.

Redis18 minNov 10, 2026
Read

Valkey Keyspace Notifications in Production: Expired Events, hexpired and Cluster Mode

Run Valkey keyspace notifications in production: why expired and hexpired events lag the TTL, how to subscribe on every cluster node, and when a stream is safer.

Redis20 minNov 10, 2026
Read

Valkey GLIDE in Production: AZ-Affinity Reads and Migrating from Legacy Clients

Configure Valkey GLIDE AZ-affinity reads on servers and clients, learn what GLIDE 2.5.3 does not support, and migrate from redis-py, Jedis, ioredis, or go-redis one service at a time.

Redis20 minNov 10, 2026
Read