epoll vs io_uring: Linux Async I/O Models Compared
epoll tells you when a file descriptor is ready; io_uring lets you submit the I/O itself and collect results later. How each works and when to use which.
epoll and io_uring are the two main Linux interfaces for handling many I/O operations without dedicating a thread to each one. The key difference is what they hand back: epoll is a readiness API that tells you a socket or pipe can be read or written without blocking, after which you still make the system call yourself. io_uring is a completion API: you describe the operation, the kernel performs it, and you collect the finished result from a shared queue. That shift changes how many system calls a server makes and which kinds of I/O can be asynchronous at all.
The problem both solve
A network server might hold tens of thousands of open connections, most of them idle at any moment. The naive design, one thread per connection doing blocking reads, wastes memory on stacks and burns CPU on context switches. The alternative is an event loop: a small number of threads that ask the kernel “which of these connections has work for me?” and service only those.
That is the model behind Nginx, Redis, and the libuv library that powers the Node.js event loop. The question is how the event loop talks to the kernel.
How epoll works
Before epoll, Linux offered select and poll. Both take the full list of file descriptors on every call, and the kernel scans all of them each time. With thousands of mostly idle connections, that scan dominates.
epoll fixes this by keeping the interest list inside the kernel:
epoll_create1creates an epoll instance, itself a file descriptor.epoll_ctladds, modifies, or removes descriptors from the interest list, once per change rather than once per wait.epoll_waitblocks until one or more registered descriptors become ready, then returns only those.
The cost of epoll_wait scales with the number of ready descriptors, not the number of watched ones. That property is why epoll became the foundation of high-concurrency servers on Linux.
epoll supports two notification modes. Level-triggered (the default) keeps reporting a descriptor as long as data remains unread. Edge-triggered reports only when the state changes, so the application must drain the socket until it returns EAGAIN or risk missing data. Edge-triggered mode can reduce wakeups but is easier to get wrong.
Where epoll falls short
epoll only tells you something is ready. Every actual read or write is still a separate system call, so a busy server makes at least one epoll_wait plus one read or write per active connection per loop iteration. Each crossing into the kernel has a cost.
More fundamentally, readiness has no meaning for regular files on disk. A file is always “ready” to epoll, even if the read will block waiting on the storage device. That is why libuv runs file system operations on a thread pool: epoll simply cannot make disk I/O non-blocking.
How io_uring works
io_uring, added to the Linux kernel in 2019, is built around two ring buffers shared between user space and the kernel:
- Submission queue (SQ) — the application writes submission queue entries describing operations: read this many bytes from this descriptor into this buffer at this offset, accept a connection, send a message, and so on.
- Completion queue (CQ) — the kernel writes completion queue entries containing the result of each finished operation, tagged with a user-supplied value so the application can match them up.
Because the rings live in shared memory, the application can queue many operations and submit them all with a single io_uring_enter call, then reap many completions without any system call at all. In a mode called SQ polling, a kernel thread watches the submission queue continuously, so a busy application can submit work with no system calls in the steady state.
io_uring works for regular files, sockets, and an expanding list of other operations, including openat, statx, fsync, and timeouts. It also supports linked operations (do B only after A succeeds), registered buffers and files that avoid repeated setup cost, and multishot operations where one submission produces a stream of completions, such as accepting connections in a loop.
epoll vs io_uring at a glance
| epoll | io_uring | |
|---|---|---|
| Model | Readiness notification | Completion-based submission |
| Who performs the I/O | Application, after notification | Kernel, asynchronously |
| System calls per operation | At least one per read or write | Can batch many, or approach zero with SQ polling |
| Regular file I/O | Not truly async | Fully async |
| Programming model | Familiar reactor pattern | Proactor pattern; buffers must stay valid until completion |
| Maturity and portability | Long-established, widely supported | Newer; feature set varies by kernel version |
| Security posture | Small, well-audited surface | Large surface; some environments restrict or disable it |
The trade-offs in practice
io_uring’s performance advantage is real but situational. It shines when an application issues a high volume of small operations, mixes network and disk I/O, or is bottlenecked on system call overhead. Storage engines and databases benefit most, because they finally get asynchronous disk reads without a thread pool. If you have read about NVMe drives and their deep hardware queues, io_uring is the software interface that can actually keep those queues full.
For a typical web service that spends most of its time waiting on the network or on downstream services, epoll is rarely the bottleneck. Switching to io_uring adds complexity without a measurable gain.
The programming model also differs in a way that matters for correctness. With epoll, the buffer you read into only needs to exist during the read call. With io_uring, the buffer belongs to the kernel from submission until completion. Freeing or reusing it early causes memory corruption. Languages with strict ownership rules, like Rust, have had to design new async runtime abstractions specifically to make this safe.
Security is the other consideration. io_uring exposes a large and fast-moving kernel interface, and it has been the source of a number of privilege-escalation bugs. Some container platforms and hardened distributions block it with seccomp policies by default, which is worth knowing before you build a deployment around it. The same isolation questions apply whether you run on containers or virtual machines.
Which should you use?
Most developers never touch either directly. Your language runtime or framework chooses for you, and many now detect io_uring support and fall back to epoll when it is unavailable.
If you are writing systems code yourself, a reasonable rule:
- Use epoll for network servers where portability, simplicity, and a minimal kernel surface matter, and where profiling does not show system call overhead as a hot spot.
- Use io_uring for storage-heavy workloads, databases, proxies pushing very high request rates, or anything that needs truly asynchronous file I/O.
Either way, the patterns built on top stay the same. You still need backpressure so fast producers do not overwhelm slow consumers, and you still benefit from connection pooling to avoid repeated setup costs.
The takeaway
epoll answers “which descriptors are ready?” and leaves the I/O to you, which makes it simple, mature, and efficient for network servers, but unable to make disk reads asynchronous. io_uring lets you hand the kernel a batch of operations and collect results from a shared ring, cutting system calls and covering file I/O too, at the cost of a trickier ownership model and a larger security surface. Default to whatever your runtime picks, and reach for io_uring deliberately when profiling shows I/O submission overhead is what is holding you back.
Keep reading
The Lycoris Team · · 5 min read What Is Little's Law? Capacity Planning Explained
Little's Law relates the number of requests in a system, their arrival rate, and how long each one takes — a simple formula for sizing capacity.
Chisato · · 6 min read SSD Wear Leveling: How NAND Flash Stays Alive
SSD wear leveling spreads writes across NAND flash so no block wears out early. How the flash translation layer, garbage collection, and TRIM work together.
The Lycoris Team · · 5 min read What Is fsync? How Writes Actually Reach the Disk
fsync forces data in the OS page cache out to durable storage. Why a successful write() is not a durable write, and how databases use fsync safely.