Epoll vs. io_uring: Navigating the Evolution of Linux Asynchronous I/O

Why readiness notification is no longer enough

For two decades, epoll has been the default event loop for Linux network servers—from NGINX-style proxies to Redis and custom TCP services. Epoll scales to tens of thousands of connections by returning only ready file descriptors, avoiding O(n) scans across huge fd sets. The model is mature, well understood, and available on every long-term-support kernel.

But readiness is only half the job. After epoll_wait returns, the application still issues read() / write() / accept() syscalls to move bytes. Each user/kernel transition costs CPU cycles—context switches, TLB churn, and cache pollution. At 10 Gbps+ NICs and NVMe arrays pushing millions of IOPS, that boundary tax becomes measurable. io_uring, merged in Linux 5.1 (2019) and documented in the kernel io_uring guide, batches submissions and completions through shared submission (SQ) and completion (CQ) rings—often with a single io_uring_enter() per batch instead of one syscall per fd operation.

Epoll in production: still the safe default

Epoll’s strength is simplicity under real workloads. Edge-triggered (EPOLLET) loops are idiomatic in C/C++/Rust servers; libraries like libevent and Tokio’s epoll backend wrap decades of battle scars. For typical HTTPS APIs and WebSocket fan-out, NIC interrupts, TCP stack work, and application parsing dominate—syscall count is already amortized when you read in large chunks (256 KiB–1 MiB).

Community benchmarks on liburing Issue #536 show epoll outperforming io_uring in streaming mode (single-direction bulk transfers) on some highly tuned servers—epoll hit ~1.2M QPS vs ~660K for an early io_uring prototype on the same kernel generation. That does not mean epoll is “faster forever”; it means workload shape and engine maturity matter as much as the syscall API.

io_uring: unified async for disk, network, and beyond

io_uring’s design goal is a single asynchronous interface for operations epoll cannot express natively: linked SQEs (IOSQE_IO_LINK), registered buffers, accept + recv chains, splice, openat, and NVMe passthrough (IORING_OP_URING_CMD). The liburing userspace helper is maintained by io_uring’s creator Jens Axboe.

Research from Jasny et al. (VLDB 2026 / arXiv:2512.04859) quantifies the migration trap:

  • Naive swap (io_uring instead of epoll/libaio with no architectural change): ~1.06×–1.10× end-to-end—often noise in A/B tests.
  • Engineered for rings (registered buffers, batching, zero-copy send and receive): up to ~2.3× on network shuffle workloads and ~2.5× with zero-copy receive vs naive epoll baselines.

Independent systems writing at Systems Explained notes microbenchmark wins of 2–5× syscall throughput at ~1M small ops/sec, while real NIC-limited servers see narrower gaps unless the CPU was syscall-bound.

Polling modes: IOPoll and SQPoll

IOPoll busy-polls completion paths—useful when I/O latency dominates and you can spend CPU to avoid interrupts (storage-heavy DB scans). SQPoll adds a kernel thread polling the submission queue, trimming enter syscalls further. Both can regress latency-sensitive mixed workloads or steal cores from your app if enabled blindly. Treat them as tunables with flame graphs, not feature flags to flip in production on Friday.

Decision matrix for MVP and platform teams

ScenarioPreferWhy
Standard REST/GraphQL API on kernel 5.15 LTSepollMature tooling, predictable ops
Custom storage engine / WAL on NVMeio_uring + registered buffersSyscall + copy elimination
High-concurrency proxy needing zero-copy RXio_uring (kernel 6.x+)epoll lacks unified zero-copy receive
Streaming CDN-style bulk TCPepoll (optimized)Documented cases where io_uring lags streaming pipelines
Greenfield on Linux 6.6+ with unified disk+net asyncio_uringOne event loop for accept, read, write, fsync

Illustrative fleet KPIs (validate your own perf / eBPF traces): <5% CPU in syscall path for epoll servers at P99 load; >1.5× throughput uplift required before accepting SQPoll core reservation; 0 production incidents from IORING_SETUP_SINGLE_ISSUER misuse during rollouts.

Migration playbook (without the 8% regression)

Production teams (see open fkvs io_uring findings) report epoll beating naive io_uring by ~8% on Unix-domain socket microbenchmarks when pipelining already hides syscall cost. Before you rewrite NGINX modules:

  1. Profile first — if compute (hash tables, JSON) is hot, fix algorithms; I/O API swaps won’t move P99.
  2. Batch SQEs — submit accepts, reads, and writes in one ring flush.
  3. Register buffers — pin read/write regions to avoid per-I/O mapping.
  4. Enable zero-copy only with fallbacks — kernel and NIC capabilities vary; keep a epoll code path for rollback.
  5. Kernel version policy — io_uring fixes land frequently; pin minimum 6.1+ for production async accept + buffer ring features.

For commerce platforms chasing sub-200ms TTFB targets, epoll remains the pragmatic choice until profiling proves syscall-bound I/O. For database-heavy MVPs flushing WAL to NVMe, io_uring is the modernization path—if you invest in ring-native design, not a line-for-line epoll port.

Closing perspective

io_uring is the most significant Linux I/O interface since epoll—but it is not a free performance upgrade. Epoll remains the correct default for most network services in 2026. Reach for io_uring when unified async I/O, zero-copy, and NVMe-centric batching are on your critical path—and budget engineering time to exploit the rings, not just rename your event loop.

Metrics snapshot

Epoll vs. io_uring: Navigating the Evolution of Linux Asynchronous I/O — key metrics

Illustrative epoll vs io_uring uplift ranges from VLDB 2026 research and community benchmarks—run your own flame graphs before choosing an API.

Architecture flow

Epoll vs. io_uring: Navigating the Evolution of Linux Asynchronous I/O — integration flow

Linux kernel — home of epoll and io_uring

Source: kernel.org

Approach comparison

ApproachSignalRiskBest for
epoll (edge-triggered)Battle-tested; huge ecosystem; simple mental modelSeparate syscalls per read/write; no unified disk+net APITypical API servers and proxies
io_uring (batched SQ/CQ)Syscall amortization; linked ops; zero-copy RX/TXComplexity; kernel-version sensitivity; tuning IOPoll/SQPollNVMe-heavy DB engines and high-QPS custom servers
SPDK / DPDK user-space I/OMaximum raw IOPS; bypass kernel stackNo POSIX/TCP niceties; ops burdenDedicated storage/network appliances

Code sketches

/* Conceptual io_uring read batch (liburing) */
// Pseudocode — see liburing examples for production use
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd2, buf2, len2, offset2);

io_uring_submit(&ring);
io_uring_wait_cqe(&ring, &cqe);

Official references

Article slug: epoll-vs-io-uring-linux-asynchronous-io · Engineering notes by Nitin Rachabathuni — MVP in 2 days specialist.