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
| Scenario | Prefer | Why |
|---|---|---|
| Standard REST/GraphQL API on kernel 5.15 LTS | epoll | Mature tooling, predictable ops |
| Custom storage engine / WAL on NVMe | io_uring + registered buffers | Syscall + copy elimination |
| High-concurrency proxy needing zero-copy RX | io_uring (kernel 6.x+) | epoll lacks unified zero-copy receive |
| Streaming CDN-style bulk TCP | epoll (optimized) | Documented cases where io_uring lags streaming pipelines |
| Greenfield on Linux 6.6+ with unified disk+net async | io_uring | One 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:
- Profile first — if compute (hash tables, JSON) is hot, fix algorithms; I/O API swaps won’t move P99.
- Batch SQEs — submit accepts, reads, and writes in one ring flush.
- Register buffers — pin read/write regions to avoid per-I/O mapping.
- Enable zero-copy only with fallbacks — kernel and NIC capabilities vary; keep a epoll code path for rollback.
- 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

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


Source: kernel.org
Approach comparison
| Approach | Signal | Risk | Best for |
|---|---|---|---|
| epoll (edge-triggered) | Battle-tested; huge ecosystem; simple mental model | Separate syscalls per read/write; no unified disk+net API | Typical API servers and proxies |
| io_uring (batched SQ/CQ) | Syscall amortization; linked ops; zero-copy RX/TX | Complexity; kernel-version sensitivity; tuning IOPoll/SQPoll | NVMe-heavy DB engines and high-QPS custom servers |
| SPDK / DPDK user-space I/O | Maximum raw IOPS; bypass kernel stack | No POSIX/TCP niceties; ops burden | Dedicated 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
- Linux kernel io_uring documentation
- liburing (Jens Axboe)
- VLDB 2026 — io_uring for DBMSs (arXiv)
- Linux I/O models comparison
Related on this site
- Sub-200ms TTFB checkout targets
- Catalog search at fifteen million subscribers
- Building AI agents with LangGraph
Article slug: epoll-vs-io-uring-linux-asynchronous-io · Engineering notes by Nitin Rachabathuni — MVP in 2 days specialist.


