epoll vs io_uring
If you write networked services on Linux, you have two ways to do async I/O. The old one is epoll, which has been the default since 2002. The new one is io_uring, which landed in kernel 5.1 back in 2019 and has been getting better with every release since. They solve the same basic problem, but the way they solve it is fundamentally different.
I spent time with both recently, and it changed how I think about async I/O on Linux.
The readiness model (epoll)
epoll works on a simple idea: you tell the kernel which file descriptors you care about, and the kernel tells you when one of them is ready for I/O. "Ready" means you can call read() or write() on it without blocking. The notification is the easy part. The hard part is what happens next: you still have to issue the I/O yourself.
That means two syscalls per event. One for epoll_wait() to find out what is ready, and another for read() or write() to actually do the work. Each syscall crosses the user/kernel boundary. On a modern x86_64 machine, a context switch costs around 300 nanoseconds. That sounds tiny until you are handling ten thousand connections at once.
At 10K concurrent I/O events, epoll forces 20,000 syscalls. At 300ns each, that is 6 milliseconds spent just crossing the boundary, before any actual work happens. Double your connections, double the overhead. There is no batching. There is no escape.
The API itself is not bad. It is easy to understand. The complexity lives in your code: you need a main loop that calls epoll_wait(), dispatches events, and handles partial reads and writes because the kernel only guarantees readiness, not completion. A socket marked "writable" might only accept 64 bytes of a 4KB buffer. You have to track that state yourself.
The completion model (io_uring)
io_uring flips the model. Instead of asking the kernel "is this fd ready?" and then doing the I/O yourself, you submit the I/O request and the kernel tells you when it is done. No readiness step. No separate read call. The kernel does the work and posts the result to a shared memory ring buffer.
The ring buffer is the trick. Your application and the kernel share two circular buffers: a submission queue (SQ) where you write requests, and a completion queue (CQ) where the kernel writes results. Both live in memory mapped between user space and kernel space. You can batch 100 reads into the SQ, call io_uring_enter() once, and the kernel processes all of them and posts 100 completions to the CQ.
One syscall for a batch of any size, instead of two per operation. At 10K events with a batch size of 128, you get roughly 78 syscalls instead of 20,000. That drops your context-switch overhead from 6ms to 0.02ms. Not a 2x improvement, more like 250x.
If even that one syscall per batch feels like too much, there is IORING_SETUP_SQPOLL. This spins up a dedicated kernel thread that polls the submission queue continuously, without your application having to call io_uring_enter() at all. In steady state, zero syscalls. The tradeoff is that the kernel thread burns CPU even when idle, though it does back off after a configurable timeout.
The code tells the story
Reading the same bytes from stdin, first with epoll:
// epoll: 3 syscalls to read once
int epoll_fd = epoll_create1(0); // setup
struct epoll_event ev = { .events = EPOLLIN, .data.fd = STDIN_FILENO };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); // register
epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // "is it ready?"
read(STDIN_FILENO, buf, sizeof(buf)); // "now do the read"
And with io_uring:
// io_uring: 2 calls, 1 actual syscall
struct io_uring ring;
io_uring_queue_init(8, &ring, 0); // setup
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, STDIN_FILENO, buf, sizeof(buf), 0); // "do this read"
io_uring_submit(&ring); // one syscall, batched
io_uring_wait_cqe(&ring, &cqe); // "is it done?"
The epoll version needs a separate registration step and a separate I/O call after the readiness notification. The io_uring version describes the operation upfront and waits for the result. In a real server handling thousands of connections, that difference compounds fast.
When epoll is still fine
io_uring is not an automatic upgrade for everything. There are real reasons to stick with epoll in some cases.
If your workload is a few hundred mostly-idle connections with low throughput, the syscall overhead of epoll does not matter. You are spending microseconds on context switches while waiting milliseconds for network packets. The difference is noise.
If you need to support kernels older than 5.1, you have no choice. io_uring simply does not exist there. Some embedded and long-term-support distributions still ship 4.x kernels.
epoll is also debuggable in ways that io_uring is not. When something goes wrong with epoll, you can strace it and see every syscall. With io_uring, the work happens inside the kernel asynchronously. You can see the submission and completion, but what happens in between is harder to trace. This is getting better with each kernel release, but it is still a real friction point when debugging production issues at 3am.
The io_uring API is also more complex to use correctly. You have to manage SQE lifetimes, handle the CQ ring correctly, think about when to flush submissions, and deal with asynchronous error codes in completion events. The learning curve is steeper. liburing helps, but you are still reasoning about a different execution model than what most programmers are used to.
Zero-copy and other io_uring extras
io_uring has capabilities that epoll simply cannot replicate, because epoll is not in the I/O path. It only watches.
You can register buffers in advance with io_uring_register_buffers() so the kernel does not have to set up memory mappings on every operation. There is also IORING_OP_SEND_ZC (kernel 6.0+) for true zero-copy network sends where the buffer never gets copied into kernel space. For high-throughput network services, this can save significant CPU cycles.
io_uring also supports operations beyond plain read/write: file creation, linking operations so one starts after another completes, timeout handling, even filesystem operations like openat and unlink. It is becoming a general-purpose async system call interface, not just an I/O notification mechanism.
What major software is doing
nginx added experimental io_uring support in 2024 behind a compile flag. The results were mixed in the first release: some workloads saw 20-30% throughput improvements, others showed no change or slight regressions, especially at low connection counts. The implementation has been improving steadily since.
Redis 8.0 uses io_uring for disk I/O when available, falling back to regular AIO or threaded I/O on older kernels. PostgreSQL has experimental support. The Rust ecosystem has adopted it more aggressively: tokio-uring and glommio both build on io_uring as their core async primitive.
The transition is happening, but it is slow. Every high-performance networking project has years of epoll-optimized code and edge-case handling built up. Rewriting that for a different execution model is real work, and the gains depend on your workload.
So which should you use?
For a new project on a modern Linux system (5.1+, and honestly 5.6+ for the stable API), io_uring is the better choice for anything that touches a lot of I/O. The batching model is cleaner, the performance ceiling is higher, and the API keeps getting new capabilities that epoll cannot match.
If you are maintaining existing epoll code that works fine, do not rewrite it for the sake of it. Measure first. If your service is CPU-bound or your connection count is low, the syscall savings will not show up in your latency numbers. epoll is not broken. It is just not the best tool anymore for the cases where it used to be the only option.
The real takeaway is that Linux async I/O went 17 years without a fundamental redesign, and io_uring is fixing a design problem that has been costing performance the entire time. The readiness model was a reasonable choice in 2002. It is not the right default in 2026.
Further reading
Resources
- epoll vs io_uring in Linux (the sibexico databank). Walks through both APIs with C code. The article that kicked off my thinking on this.
- io_uring and effective async I/O (Shuveb Hussain). Deep architecture explanation with benchmarks.
- Efficient IO with io_uring (Jens Axboe, the io_uring author). The original design document. Dense but worth reading if you want to understand the ring buffer mechanics.
- io_uring(7) man page. The definitive API reference.