io_uring vs epoll on a Low-Core VPS: Cutting Syscall Overhead

For roughly two decades, Linux network servers have lived on epoll. It’s battle-tested, scales to hundreds of thousands of file descriptors, and every framework from nginx to Node.js is built on it. But io_uring — merged into the kernel in 5.1 — now offers a genuinely different model: a shared-memory submission and completion queue that can batch syscalls and cut per-request overhead dramatically. On a low-core VPS, where you might have only one or two vCPUs to work with, that overhead reduction can matter more than raw throughput. This guide covers when io_uring wins, when epoll still wins, and how to benchmark both on your own instance. To see which providers give you a new enough kernel to even try this, see our full VPS comparison.

Why the syscall interface is the bottleneck

epoll is efficient at waiting. The problem is everything around it. A typical request on an epoll-based server involves epoll_wait, then a read, then a write, then a close — each one a separate entry into the kernel, each one a context switch. On a machine with 32 cores, you can hide that cost across threads. On a 1-core or 2-core VPS, every syscall is time the CPU isn’t spending on your application.

io_uring changes the shape of the conversation. Instead of one syscall per operation, the application writes submission queue entries (SQEs) into a ring buffer shared with the kernel, then makes a single io_uring_enter call — or, with SQPOLL, no call at all. The kernel writes completions into a second ring. The result is batching, and on syscall-bound workloads batching is where the wins live.

Aspectepollio_uring
Syscalls per I/O op1–3 (wait + r/w)~0 with batching / SQPOLL
Kernel version needed2.5.44+5.1+, mature from 5.10+
Ecosystem supportUniversalGrowing; nginx 1.25+ has partial support
Best caseMany idle connectionsHigh ops/sec, batched, low core count
RiskVery lowKernel-version sensitive, historically had security CVEs

Where io_uring actually helps on a small VPS

Two workloads benefit most, and both are common on cheap instances.

  • Storage-heavy services. Databases and log processors that issue thousands of small random reads per second. io_uring batches these into a handful of kernel entries, and the CPU cost per IOPS drops sharply. This is the strongest, most mature use case.
  • Proxy and API gateways on 1–2 vCPUs. When you’re proxy-passing high request rates, the syscall overhead is a real fraction of total CPU. Batching reads and writes across many connections reclaims that time for actual work.

Where it does not help: workloads dominated by CPU-bound application logic, low-traffic sites where the kernel cost was never the bottleneck, and memory-bandwidth-bound services. On a quiet WordPress box, switching I/O backends changes nothing measurable.

Checking whether your VPS can use it

Kernel version is the gate. io_uring needs 5.1 minimum, but 5.10 (LTS) or 6.x is what you want for stability and features like registered buffers.

# Kernel and feature check
uname -r
grep -i io_uring /boot/config-$(uname -r)   # expect CONFIG_IO_URING=y

# Confirm the syscall is exposed
strace -e trace=io_uring_setup,io_uring_enter true 2>&1 | tail -3

# Check what your web server supports
nginx -V 2>&1 | tr ' ' '\n' | grep -i uring
nginx -V 2>&1 | tr ' ' '\n' | grep -i epoll

If you’re on a kernel older than 5.10 and your provider won’t let you upgrade, you’re stuck with epoll — and that’s fine, because epoll is not broken. Many budget VPS providers ship 4.15 or 5.4 kernels on shared hosts, which makes this decision for you.

Benchmarking both on the same box

The only trustworthy comparison is one you run yourself. The cleanest approach is a micro-benchmark isolating syscall overhead, then a real-server test with your actual config.

# 1. Syscall overhead: compare a batched io_uring reader to a read() loop.
#    'fio' ships with both engines — the cleanest apples-to-apples test.
sudo apt-get install -y fio

# epoll-equivalent (psync/sync I/O loop semantics)
fio --name=sync --ioengine=sync --rw=randread --bs=4k \
    --size=1G --numjobs=1 --runtime=30 --time_based --group_reporting

# io_uring engine, same workload
fio --name=uring --ioengine=io_uring --rw=randread --bs=4k \
    --iodepth=32 --size=1G --numjobs=1 --runtime=30 --time_based \
    --group_reporting

Compare the reported IOPS and, more importantly, the CPU utilisation fio prints at the end (cpu and sys percentages). A drop in sys time is the io_uring signature — total IOPS may be similar if the disk is the limit, but you’re spending fewer CPU cycles to get there, which is exactly what a 1-core VPS needs.

For a network-side test, point wrk or hey at your server and watch CPU saturation:

# Baseline
wrk -t2 -c200 -d30s http://127.0.0.1:8080/api/ping

# During the run, in a second shell:
pidstat -u -p ALL 1 10   # watch %system vs %user on each core
mpstat -P ALL 1 5

If %system is a large share of total CPU during the run, there’s overhead to reclaim and an io_uring-capable server is worth testing. If %user dominates, your application logic is the bottleneck and the backend choice won’t rescue it.

Practical adoption advice

  • Don’t migrate your production stack to be an early adopter. io_uring’s history includes several privilege-escalation CVEs. Run a current kernel and keep it patched.
  • Test the whole stack, not a component. An io_uring-capable database behind an epoll-only proxy gains you nothing on the network path.
  • Watch memory. Registered buffers and deeper queues consume RAM. On a 1 GB instance, tune queue depth conservatively — you’re trading memory for CPU.
  • Measure before and after with monitoring in place. A change that improves throughput 8% but doubles p99 latency is usually a loss for interactive services.

The verdict

io_uring is not a replacement for epoll — it’s a tool for a specific bottleneck. On a low-core VPS running storage-heavy or high-request-rate services, it can recover a meaningful slice of CPU that epoll spends on kernel transitions. On a lightly loaded box, it’s a science project. Benchmark on your own workload, keep your kernel current, and treat the syscall overhead as one more resource to budget — the same way you budget RAM and IOPS.

If your current provider is stuck on a 4.x kernel, no amount of tuning will let you test these options. Choosing a host that keeps pace with upstream kernels is the prerequisite for modern performance work — see our full VPS comparison to find providers that ship current kernels and give you the root access to use them.

Ready to run these benchmarks on hardware that can actually take advantage? Compare current VPS plans and pricing here and pick an instance with a modern kernel and NVMe storage to test io_uring against epoll yourself.

Leave a Reply