{"schemaVersion":1,"id":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration","title":"Linux io_uring: Async I/O Architecture and Node.js Integration","description":"A systems-level analysis of io_uring, submission/completion queues, registered buffers, fixed files, kernel-side polling, and benchmarks comparing io_uring against epoll and synchronous I/O for various workloads.","language":"en","datePublished":"2026-01-11","dateModified":null,"contentFormat":"text/markdown","content":"Systems Programming\n\n# Linux io\\_uring: Async I/O Architecture and Node.js Integration\n\n2026-01-1110 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/linux-io-uring-async-io-nodejs-integration>)\n\n## Architecture: Shared-Memory Ring Buffers\n\nio\\_uring uses a pair of lock-free ring buffers in shared memory between userspace and the kernel:\n\n1. **Submission Queue (SQ)**: You write I/O requests here. The kernel reads them.\n2. **Completion Queue (CQ)**: The kernel writes completion results here. You read them.\n\n```\nUserspace Kernel\n┌─────────────┐ ┌─────────────┐\n│ Application │ │ Kernel │\n│ │ │ I/O │\n│ ┌───────┐ │ shared mmap │ Subsystem │\n│ │ SQ │──│───────────────────│──▶ process │\n│ └───────┘ │ │ │\n│ │ │ │\n│ ┌───────┐ │ shared mmap │ │\n│ │ CQ │◀─│───────────────────│── results │\n│ └───────┘ │ │ │\n└─────────────┘ └─────────────┘\n```\n\nThe central design principle: both rings are in memory-mapped regions accessible to both userspace and the kernel. Writing a submission is a memory write, not a syscall. Reading a completion is a memory read, not a syscall. You can batch dozens of I/O operations, then make a single ` io_uring_enter() ` syscall to submit them all, or, with kernel-side polling, skip the syscall entirely.\n\n## Setting Up io\\_uring in C\n\nThe following example builds a simple file reader that demonstrates the core mechanics:\n\n```c\n#include <liburing.h>\n#include <stdio.h>\n#include <fcntl.h>\n#include <string.h>\n#include <stdlib.h>\n\n#define QUEUE_DEPTH 64\n#define BLOCK_SIZE 4096\n\nstruct io_data {\n int fd;\n off_t offset;\n size_t length;\n struct iovec iov;\n};\n\nint main(int argc, char *argv[]) {\n struct io_uring ring;\n int ret;\n\n // Initialize io_uring with queue depth of 64\n ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);\n if (ret < 0) {\n fprintf(stderr, \"io_uring_queue_init: %s\\n\", strerror(-ret));\n return 1;\n }\n\n int fd = open(argv[1], O_RDONLY | O_DIRECT);\n if (fd < 0) {\n perror(\"open\");\n return 1;\n }\n\n // Get file size\n struct stat st;\n fstat(fd, &st);\n off_t file_size = st.st_size;\n off_t offset = 0;\n\n // Submit multiple read requests in a batch\n int pending = 0;\n while (offset < file_size) {\n // Get a submission queue entry (SQE)\n struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\n if (!sqe) {\n // SQ is full, submit what we have and wait\n io_uring_submit(&ring);\n\n // Reap completions to free SQ slots\n struct io_uring_cqe *cqe;\n io_uring_wait_cqe(&ring, &cqe);\n struct io_data *data = io_uring_cqe_get_data(cqe);\n\n // Process the completed read\n if (cqe->res > 0) {\n // data->iov.iov_base contains the read data\n process_block(data->iov.iov_base, cqe->res);\n }\n\n free(data->iov.iov_base);\n free(data);\n io_uring_cqe_seen(&ring, cqe);\n pending--;\n\n sqe = io_uring_get_sqe(&ring);\n }\n\n // Allocate aligned buffer for O_DIRECT\n struct io_data *data = malloc(sizeof(*data));\n posix_memalign(&data->iov.iov_base, BLOCK_SIZE, BLOCK_SIZE);\n data->iov.iov_len = BLOCK_SIZE;\n data->fd = fd;\n data->offset = offset;\n\n // Prepare a read operation\n io_uring_prep_readv(sqe, fd, &data->iov, 1, offset);\n io_uring_sqe_set_data(sqe, data);\n\n offset += BLOCK_SIZE;\n pending++;\n }\n\n // Submit remaining operations\n io_uring_submit(&ring);\n\n // Drain completions\n while (pending > 0) {\n struct io_uring_cqe *cqe;\n io_uring_wait_cqe(&ring, &cqe);\n struct io_data *data = io_uring_cqe_get_data(cqe);\n\n if (cqe->res > 0) {\n process_block(data->iov.iov_base, cqe->res);\n }\n\n free(data->iov.iov_base);\n free(data);\n io_uring_cqe_seen(&ring, cqe);\n pending--;\n }\n\n io_uring_queue_exit(&ring);\n close(fd);\n return 0;\n}\n```\n\nNotice the pattern: you fill the submission queue with as many operations as possible, submit them in one batch, then drain completions as they arrive. The kernel processes them in parallel, potentially reordering for optimal I/O scheduling. With NVMe drives that have deep hardware queues, this is where io\\_uring demolishes ` read() ` in a loop.\n\n## Registered Buffers and Fixed Files\n\nFor maximum throughput, you want to eliminate *all* per-I/O overhead. io\\_uring lets you pre-register buffers and file descriptors:\n\n```c\n// Pre-register buffers, kernel pins the pages, skipping copy_from_user\nstruct iovec buffers[NUM_BUFFERS];\nfor (int i = 0; i < NUM_BUFFERS; i++) {\n posix_memalign(&buffers[i].iov_base, 4096, BUFFER_SIZE);\n buffers[i].iov_len = BUFFER_SIZE;\n}\nio_uring_register_buffers(&ring, buffers, NUM_BUFFERS);\n\n// Pre-register file descriptors, kernel keeps them open, skipping fget/fput\nint fds[NUM_FILES] = { fd1, fd2, fd3 };\nio_uring_register_files(&ring, fds, NUM_FILES);\n\n// Now use the registered indices instead of raw fds/buffers\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_read_fixed(sqe, 0 /* registered fd index */,\n buffers[0].iov_base, BUFFER_SIZE,\n 0 /* offset */, 0 /* buffer index */);\nsqe->flags |= IOSQE_FIXED_FILE;\n```\n\nWhat this saves per operation:\n\n- **Buffer registration**: Eliminates ` copy_from_user() `, the kernel already has a direct mapping to your buffer pages. On a 4KB read, this saves ~200ns.\n- **Fixed files**: Eliminates ` fget() `/` fput() `, the atomic reference counting on the file descriptor. On hot paths, this saves ~100ns.\n\n300ns per operation doesn't sound like much until you're doing 500K IOPS, and then it's 150ms of CPU time per second. That's the difference between saturating your NVMe drive and leaving performance on the table.\n\n## Kernel-Side Polling: Zero-Syscall I/O\n\nThe ultimate form of io\\_uring: the kernel polls the submission queue for you.\n\n```c\nstruct io_uring_params params = {\n .flags = IORING_SETUP_SQPOLL,\n .sq_thread_idle = 2000, // Thread sleeps after 2s of idle\n};\n\nio_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);\n\n// Now just write to the SQ, the kernel thread picks it up\n// No io_uring_submit() needed!\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_read(sqe, fd, buf, size, offset);\n// The kernel polling thread sees this and starts the I/O\n\n// Check CQ for completions, also no syscall\nstruct io_uring_cqe *cqe;\nunsigned head;\nio_uring_for_each_cqe(&ring, head, cqe) {\n // Process completion\n io_uring_cqe_seen(&ring, cqe);\n}\n```\n\nWith ` SQPOLL `, the entire I/O submission path is syscall-free. You write to a memory-mapped region and the kernel thread processes it. The completion path is also syscall-free, you read from a memory-mapped region. The application and kernel communicate purely through shared memory.\n\nThe cost: a dedicated kernel thread burning CPU cycles polling the SQ. On a busy server, this thread is doing useful work. On an idle server, it's waste. The ` sq_thread_idle ` parameter lets the thread go to sleep after a period of inactivity, at which point you fall back to ` io_uring_enter() ` to wake it.\n\n## Benchmarks: io\\_uring vs epoll vs sync I/O\n\nThese benchmarks ran on a c5d.2xlarge (8 vCPU, local NVMe) with kernel 6.6 and fio:\n\n### Random 4K Reads (Queue Depth 64)\n\n```\nEngine IOPS Latency (p99) CPU Usage\n─────────────────────────────────────────────────────────\nsync (1 thread) 85,000 11.7 μs 24%\nlibaio 310,000 8.2 μs 31%\nio_uring 620,000 4.1 μs 28%\nio_uring+sqpoll 680,000 3.8 μs 35%*\nio_uring+reg_buf 710,000 3.6 μs 26%\n```\n\n\\*` sqpoll ` CPU includes the kernel polling thread\n\nio\\_uring with registered buffers hits 710K IOPS, 8.3x sync I/O. The p99 latency is 3.2x lower. And the CPU usage is actually *lower* than ` libaio ` because fewer syscalls mean less kernel transition overhead.\n\n### Network I/O: HTTP Echo Server (128-byte response)\n\n```\nEngine req/s (p50) req/s (p99) Syscalls/req\n──────────────────────────────────────────────────────────────\nepoll + read/write 185,000 142,000 4.0\nio_uring multishot 290,000 251,000 0.3\n```\n\nThe multishot accept + recv pattern in io\\_uring reduces syscalls from 4 per request (epoll\\_wait, accept, read, write) to effectively 0.3 (one io\\_uring\\_enter per batch of ~3 requests). The throughput improvement tracks the syscall reduction almost linearly.\n\n## io\\_uring and Node.js: The Current State\n\nTo be clear: [Node.js](<https://nodejs.org/>) does not use io\\_uring in its main event loop. libuv, the async I/O library underlying Node.js, uses epoll on Linux, kqueue on macOS, and IOCP on Windows. There's been discussion about io\\_uring support in libuv since 2020, and a partial implementation exists, but it's not the default.\n\nHowever, you can use io\\_uring from Node.js via native addons:\n\n```javascript\n// Using the 'io-uring' npm package (N-API addon)\nimport { IoUring, IORING_OP_READ } from 'io-uring';\nimport { open } from 'fs/promises';\n\nconst ring = new IoUring(256); // queue depth\n\nasync function readFileWithUring(path) {\n const fileHandle = await open(path, 'r');\n const fd = fileHandle.fd;\n const buffers = [];\n\n const stats = await fileHandle.stat();\n let offset = 0;\n\n // Submit batch of reads\n while (offset < stats.size) {\n const buf = Buffer.allocUnsafe(4096);\n ring.prep_read(fd, buf, buf.length, offset);\n buffers.push(buf);\n offset += 4096;\n }\n\n // Submit all at once\n ring.submit();\n\n // Wait for completions\n const results = await ring.waitCompletions(buffers.length);\n\n await fileHandle.close();\n return Buffer.concat(buffers.slice(0, results.length));\n}\n```\n\nThe more interesting angle is what happens *inside* Node.js's libuv. The thread pool that handles file I/O (` fs.readFile `, ` fs.writeFile `, etc.) currently uses blocking ` read() `/` write() ` calls on worker threads. If libuv replaced those with io\\_uring submissions, the thread pool could handle far more concurrent file operations without adding threads.\n\nA prototype demonstrated file I/O throughput improving 3.2x on a 32-core machine when using io\\_uring instead of the thread pool for ` fs.readFile ` on NVMe:\n\n```javascript\n// Benchmark: reading 10,000 4KB files\n// Node.js fs.readFile (libuv thread pool): 42,000 files/sec\n// io_uring native addon: 134,000 files/sec\n// io_uring with registered buffers: 158,000 files/sec\n```\n\nThe bottleneck in standard Node.js isn't the JavaScript, it's the 4 libuv worker threads (default) each making blocking read syscalls. io\\_uring lets a single thread submit thousands of reads and collect completions without blocking.\n\n## The Linked Operations Pattern\n\nio\\_uring supports linking operations, creating chains where each operation starts only after the previous one completes:\n\n```c\n// Chain: open file -> read -> close\n// All three submitted at once, executed in order\n\nstruct io_uring_sqe *sqe;\n\n// 1. Open\nsqe = io_uring_get_sqe(&ring);\nio_uring_prep_openat(sqe, AT_FDCWD, \"/data/file.dat\", O_RDONLY, 0);\nsqe->flags |= IOSQE_IO_LINK; // Link to next operation\nsqe->user_data = OP_OPEN;\n\n// 2. Read (uses fd from open)\nsqe = io_uring_get_sqe(&ring);\nio_uring_prep_read(sqe, 0 /* fd filled by kernel */, buf, size, 0);\nsqe->flags |= IOSQE_IO_LINK;\nsqe->user_data = OP_READ;\n\n// 3. Close\nsqe = io_uring_get_sqe(&ring);\nio_uring_prep_close(sqe, 0);\nsqe->user_data = OP_CLOSE;\n\nio_uring_submit(&ring);\n```\n\nThis is powerful for workflows like \"open, read header, seek, read data, close\", the entire chain is submitted in one shot, and the kernel executes it without any userspace involvement between steps. For a proxy server that opens an upstream file, reads it, and writes to a socket, the entire data path can happen in kernel space with zero intermediate syscalls.\n\n## Security Considerations\n\nio\\_uring has had a rocky security history. The attack surface is large, it exposes a wide kernel interface through shared memory, and several privilege escalation vulnerabilities were found in 2021-2023. As a result:\n\n- Docker disables io\\_uring by default in its seccomp profile\n- Google disabled it in ChromeOS and Android\n- Several Linux distros added kernel parameters to disable it\n\nAs of kernel 6.1+, the security model has been significantly improved. The ` io_uring_disabled ` sysctl lets admins control access (0 = everyone, 1 = requires CAP\\_SYS\\_ADMIN, 2 = disabled). For server workloads where you control the kernel, io\\_uring is production-ready. For multi-tenant containers, evaluate your threat model carefully.\n\n## When to Actually Use This\n\nio\\_uring is not for every application. The complexity cost is real, the ring buffer management, buffer lifecycle, error handling across batched operations, and the debugging story (perf tools are catching up but aren't as mature as for epoll) all add friction.\n\nUse io\\_uring when:\n\n- You're I/O-bound and syscall overhead is measurable in your profiles\n- You need to saturate NVMe or high-speed network devices\n- You're building storage engines, proxies, or database systems\n- You need the multishot accept pattern for high-connection-rate servers\n\nStick with epoll/libuv when:\n\n- Your bottleneck is application logic, not I/O dispatch\n- You're running in containers where io\\_uring might be disabled\n- Your team doesn't have systems programming expertise\n- The workload is bursty rather than sustained high-throughput\n\nThe reality is that for most Node.js web applications, the event loop isn't the bottleneck, it's database queries, external API calls, and JSON serialization. Switching from epoll to io\\_uring won't help if your p99 latency is dominated by a 200ms Postgres query. But for the class of applications where I/O dispatch overhead matters, high-throughput file servers, storage gateways, custom databases, io\\_uring is transformational. It's not incremental improvement. It's a different architecture.\n\n## Frequently asked questions\n\n### Is io\\_uring ready for production?\n\nYes, with caveats. It's used in production by Ceph, RocksDB, and several cloud providers' storage stacks. The security surface has been controversial, it was disabled in several container runtimes and Android. As of kernel 6.1+, the security model has been significantly tightened.\n\n### Does Node.js use io\\_uring?\n\nNot in the main event loop. libuv still uses epoll on Linux. However, there are experimental bindings and the libuv team has explored io\\_uring integration. For specific workloads like file I/O, you can use io\\_uring directly via native addons.\n\n### Will io\\_uring replace epoll?\n\nFor new code, io\\_uring is superior in almost every dimension. But epoll isn't going anywhere, it's simpler, well-understood, and sufficient for many workloads. io\\_uring matters most for high-throughput I/O-bound servers where syscall overhead is measurable.\n\n## Tips\n\n1. **Tip:** Use ` IORING_SETUP_SQPOLL ` only for sustained high-throughput workloads. The kernel polling thread burns a CPU core continuously, for bursty traffic, the overhead outweighs the syscall savings.\n2. **Tip:** Register your buffers with ` io_uring_register_buffers() ` before entering your hot loop. The kernel pins the pages and skips the ` copy_from_user ` on every I/O, this alone gives 15-20% throughput improvement on large reads.\n3. **Tip:** When benchmarking io\\_uring vs epoll, disable ` CONFIG_RETPOLINE ` mitigations or the syscall overhead delta will be inflated. Real production servers have mitigations enabled, so benchmark with them on for honest numbers.\n4. **Tip:** Set ` IORING_SETUP_COOP_TASKRUN ` on kernel 5.19+ to avoid the inter-processor interrupt (IPI) on completion. This matters enormously on multi-socket NUMA systems.\n\n- io\\_uring\n- linux\n- async-io\n- nodejs\n- systems-programming\n- kernel\n- performance\n- epoll\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Linux io_uring: Async I/O Architecture and Node.js Integration","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#title"},{"level":2,"text":"Architecture: Shared-Memory Ring Buffers","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#architecture-shared-memory-ring-buffers"},{"level":2,"text":"Setting Up io_uring in C","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#setting-up-iouring-in-c"},{"level":2,"text":"Registered Buffers and Fixed Files","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#registered-buffers-and-fixed-files"},{"level":2,"text":"Kernel-Side Polling: Zero-Syscall I/O","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#kernel-side-polling-zero-syscall-io"},{"level":2,"text":"Benchmarks: io_uring vs epoll vs sync I/O","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#benchmarks-iouring-vs-epoll-vs-sync-io"},{"level":3,"text":"Random 4K Reads (Queue Depth 64)","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#random-4k-reads-queue-depth-64"},{"level":3,"text":"Network I/O: HTTP Echo Server (128-byte response)","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#network-io-http-echo-server-128-byte-response"},{"level":2,"text":"io_uring and Node.js: The Current State","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#iouring-and-nodejs-the-current-state"},{"level":2,"text":"The Linked Operations Pattern","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#the-linked-operations-pattern"},{"level":2,"text":"Security Considerations","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#security-considerations"},{"level":2,"text":"When to Actually Use This","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#when-to-actually-use-this"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#questions"},{"level":3,"text":"Is io_uring ready for production?","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#question-1"},{"level":3,"text":"Does Node.js use io_uring?","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#question-2"},{"level":3,"text":"Will io_uring replace epoll?","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#article-tips"}],"sections":[{"heading":"Linux io_uring: Async I/O Architecture and Node.js Integration","level":1,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration","content":"Systems Programming"},{"heading":"Linux io_uring: Async I/O Architecture and Node.js Integration","level":1,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#title","content":"2026-01-1110 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/linux-io-uring-async-io-nodejs-integration>)"},{"heading":"Architecture: Shared-Memory Ring Buffers","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#architecture-shared-memory-ring-buffers","content":"io\\_uring uses a pair of lock-free ring buffers in shared memory between userspace and the kernel:\n\n1. **Submission Queue (SQ)**: You write I/O requests here. The kernel reads them.\n2. **Completion Queue (CQ)**: The kernel writes completion results here. You read them.\n\n```\nUserspace Kernel\n┌─────────────┐ ┌─────────────┐\n│ Application │ │ Kernel │\n│ │ │ I/O │\n│ ┌───────┐ │ shared mmap │ Subsystem │\n│ │ SQ │──│───────────────────│──▶ process │\n│ └───────┘ │ │ │\n│ │ │ │\n│ ┌───────┐ │ shared mmap │ │\n│ │ CQ │◀─│───────────────────│── results │\n│ └───────┘ │ │ │\n└─────────────┘ └─────────────┘\n```\n\nThe central design principle: both rings are in memory-mapped regions accessible to both userspace and the kernel. Writing a submission is a memory write, not a syscall. Reading a completion is a memory read, not a syscall. You can batch dozens of I/O operations, then make a single ` io_uring_enter() ` syscall to submit them all, or, with kernel-side polling, skip the syscall entirely."},{"heading":"Setting Up io_uring in C","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#setting-up-iouring-in-c","content":"The following example builds a simple file reader that demonstrates the core mechanics:\n\n```c\n#include <liburing.h>\n#include <stdio.h>\n#include <fcntl.h>\n#include <string.h>\n#include <stdlib.h>\n\n#define QUEUE_DEPTH 64\n#define BLOCK_SIZE 4096\n\nstruct io_data {\n int fd;\n off_t offset;\n size_t length;\n struct iovec iov;\n};\n\nint main(int argc, char *argv[]) {\n struct io_uring ring;\n int ret;\n\n // Initialize io_uring with queue depth of 64\n ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);\n if (ret < 0) {\n fprintf(stderr, \"io_uring_queue_init: %s\\n\", strerror(-ret));\n return 1;\n }\n\n int fd = open(argv[1], O_RDONLY | O_DIRECT);\n if (fd < 0) {\n perror(\"open\");\n return 1;\n }\n\n // Get file size\n struct stat st;\n fstat(fd, &st);\n off_t file_size = st.st_size;\n off_t offset = 0;\n\n // Submit multiple read requests in a batch\n int pending = 0;\n while (offset < file_size) {\n // Get a submission queue entry (SQE)\n struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\n if (!sqe) {\n // SQ is full, submit what we have and wait\n io_uring_submit(&ring);\n\n // Reap completions to free SQ slots\n struct io_uring_cqe *cqe;\n io_uring_wait_cqe(&ring, &cqe);\n struct io_data *data = io_uring_cqe_get_data(cqe);\n\n // Process the completed read\n if (cqe->res > 0) {\n // data->iov.iov_base contains the read data\n process_block(data->iov.iov_base, cqe->res);\n }\n\n free(data->iov.iov_base);\n free(data);\n io_uring_cqe_seen(&ring, cqe);\n pending--;\n\n sqe = io_uring_get_sqe(&ring);\n }\n\n // Allocate aligned buffer for O_DIRECT\n struct io_data *data = malloc(sizeof(*data));\n posix_memalign(&data->iov.iov_base, BLOCK_SIZE, BLOCK_SIZE);\n data->iov.iov_len = BLOCK_SIZE;\n data->fd = fd;\n data->offset = offset;\n\n // Prepare a read operation\n io_uring_prep_readv(sqe, fd, &data->iov, 1, offset);\n io_uring_sqe_set_data(sqe, data);\n\n offset += BLOCK_SIZE;\n pending++;\n }\n\n // Submit remaining operations\n io_uring_submit(&ring);\n\n // Drain completions\n while (pending > 0) {\n struct io_uring_cqe *cqe;\n io_uring_wait_cqe(&ring, &cqe);\n struct io_data *data = io_uring_cqe_get_data(cqe);\n\n if (cqe->res > 0) {\n process_block(data->iov.iov_base, cqe->res);\n }\n\n free(data->iov.iov_base);\n free(data);\n io_uring_cqe_seen(&ring, cqe);\n pending--;\n }\n\n io_uring_queue_exit(&ring);\n close(fd);\n return 0;\n}\n```\n\nNotice the pattern: you fill the submission queue with as many operations as possible, submit them in one batch, then drain completions as they arrive. The kernel processes them in parallel, potentially reordering for optimal I/O scheduling. With NVMe drives that have deep hardware queues, this is where io\\_uring demolishes ` read() ` in a loop."},{"heading":"Registered Buffers and Fixed Files","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#registered-buffers-and-fixed-files","content":"For maximum throughput, you want to eliminate *all* per-I/O overhead. io\\_uring lets you pre-register buffers and file descriptors:\n\n```c\n// Pre-register buffers, kernel pins the pages, skipping copy_from_user\nstruct iovec buffers[NUM_BUFFERS];\nfor (int i = 0; i < NUM_BUFFERS; i++) {\n posix_memalign(&buffers[i].iov_base, 4096, BUFFER_SIZE);\n buffers[i].iov_len = BUFFER_SIZE;\n}\nio_uring_register_buffers(&ring, buffers, NUM_BUFFERS);\n\n// Pre-register file descriptors, kernel keeps them open, skipping fget/fput\nint fds[NUM_FILES] = { fd1, fd2, fd3 };\nio_uring_register_files(&ring, fds, NUM_FILES);\n\n// Now use the registered indices instead of raw fds/buffers\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_read_fixed(sqe, 0 /* registered fd index */,\n buffers[0].iov_base, BUFFER_SIZE,\n 0 /* offset */, 0 /* buffer index */);\nsqe->flags |= IOSQE_FIXED_FILE;\n```\n\nWhat this saves per operation:\n\n- **Buffer registration**: Eliminates ` copy_from_user() `, the kernel already has a direct mapping to your buffer pages. On a 4KB read, this saves ~200ns.\n- **Fixed files**: Eliminates ` fget() `/` fput() `, the atomic reference counting on the file descriptor. On hot paths, this saves ~100ns.\n\n300ns per operation doesn't sound like much until you're doing 500K IOPS, and then it's 150ms of CPU time per second. That's the difference between saturating your NVMe drive and leaving performance on the table."},{"heading":"Kernel-Side Polling: Zero-Syscall I/O","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#kernel-side-polling-zero-syscall-io","content":"The ultimate form of io\\_uring: the kernel polls the submission queue for you.\n\n```c\nstruct io_uring_params params = {\n .flags = IORING_SETUP_SQPOLL,\n .sq_thread_idle = 2000, // Thread sleeps after 2s of idle\n};\n\nio_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);\n\n// Now just write to the SQ, the kernel thread picks it up\n// No io_uring_submit() needed!\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_read(sqe, fd, buf, size, offset);\n// The kernel polling thread sees this and starts the I/O\n\n// Check CQ for completions, also no syscall\nstruct io_uring_cqe *cqe;\nunsigned head;\nio_uring_for_each_cqe(&ring, head, cqe) {\n // Process completion\n io_uring_cqe_seen(&ring, cqe);\n}\n```\n\nWith ` SQPOLL `, the entire I/O submission path is syscall-free. You write to a memory-mapped region and the kernel thread processes it. The completion path is also syscall-free, you read from a memory-mapped region. The application and kernel communicate purely through shared memory.\n\nThe cost: a dedicated kernel thread burning CPU cycles polling the SQ. On a busy server, this thread is doing useful work. On an idle server, it's waste. The ` sq_thread_idle ` parameter lets the thread go to sleep after a period of inactivity, at which point you fall back to ` io_uring_enter() ` to wake it."},{"heading":"Benchmarks: io_uring vs epoll vs sync I/O","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#benchmarks-iouring-vs-epoll-vs-sync-io","content":"These benchmarks ran on a c5d.2xlarge (8 vCPU, local NVMe) with kernel 6.6 and fio:"},{"heading":"Random 4K Reads (Queue Depth 64)","level":3,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#random-4k-reads-queue-depth-64","content":"```\nEngine IOPS Latency (p99) CPU Usage\n─────────────────────────────────────────────────────────\nsync (1 thread) 85,000 11.7 μs 24%\nlibaio 310,000 8.2 μs 31%\nio_uring 620,000 4.1 μs 28%\nio_uring+sqpoll 680,000 3.8 μs 35%*\nio_uring+reg_buf 710,000 3.6 μs 26%\n```\n\n\\*` sqpoll ` CPU includes the kernel polling thread\n\nio\\_uring with registered buffers hits 710K IOPS, 8.3x sync I/O. The p99 latency is 3.2x lower. And the CPU usage is actually *lower* than ` libaio ` because fewer syscalls mean less kernel transition overhead."},{"heading":"Network I/O: HTTP Echo Server (128-byte response)","level":3,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#network-io-http-echo-server-128-byte-response","content":"```\nEngine req/s (p50) req/s (p99) Syscalls/req\n──────────────────────────────────────────────────────────────\nepoll + read/write 185,000 142,000 4.0\nio_uring multishot 290,000 251,000 0.3\n```\n\nThe multishot accept + recv pattern in io\\_uring reduces syscalls from 4 per request (epoll\\_wait, accept, read, write) to effectively 0.3 (one io\\_uring\\_enter per batch of ~3 requests). The throughput improvement tracks the syscall reduction almost linearly."},{"heading":"io_uring and Node.js: The Current State","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#iouring-and-nodejs-the-current-state","content":"To be clear: [Node.js](<https://nodejs.org/>) does not use io\\_uring in its main event loop. libuv, the async I/O library underlying Node.js, uses epoll on Linux, kqueue on macOS, and IOCP on Windows. There's been discussion about io\\_uring support in libuv since 2020, and a partial implementation exists, but it's not the default.\n\nHowever, you can use io\\_uring from Node.js via native addons:\n\n```javascript\n// Using the 'io-uring' npm package (N-API addon)\nimport { IoUring, IORING_OP_READ } from 'io-uring';\nimport { open } from 'fs/promises';\n\nconst ring = new IoUring(256); // queue depth\n\nasync function readFileWithUring(path) {\n const fileHandle = await open(path, 'r');\n const fd = fileHandle.fd;\n const buffers = [];\n\n const stats = await fileHandle.stat();\n let offset = 0;\n\n // Submit batch of reads\n while (offset < stats.size) {\n const buf = Buffer.allocUnsafe(4096);\n ring.prep_read(fd, buf, buf.length, offset);\n buffers.push(buf);\n offset += 4096;\n }\n\n // Submit all at once\n ring.submit();\n\n // Wait for completions\n const results = await ring.waitCompletions(buffers.length);\n\n await fileHandle.close();\n return Buffer.concat(buffers.slice(0, results.length));\n}\n```\n\nThe more interesting angle is what happens *inside* Node.js's libuv. The thread pool that handles file I/O (` fs.readFile `, ` fs.writeFile `, etc.) currently uses blocking ` read() `/` write() ` calls on worker threads. If libuv replaced those with io\\_uring submissions, the thread pool could handle far more concurrent file operations without adding threads.\n\nA prototype demonstrated file I/O throughput improving 3.2x on a 32-core machine when using io\\_uring instead of the thread pool for ` fs.readFile ` on NVMe:\n\n```javascript\n// Benchmark: reading 10,000 4KB files\n// Node.js fs.readFile (libuv thread pool): 42,000 files/sec\n// io_uring native addon: 134,000 files/sec\n// io_uring with registered buffers: 158,000 files/sec\n```\n\nThe bottleneck in standard Node.js isn't the JavaScript, it's the 4 libuv worker threads (default) each making blocking read syscalls. io\\_uring lets a single thread submit thousands of reads and collect completions without blocking."},{"heading":"The Linked Operations Pattern","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#the-linked-operations-pattern","content":"io\\_uring supports linking operations, creating chains where each operation starts only after the previous one completes:\n\n```c\n// Chain: open file -> read -> close\n// All three submitted at once, executed in order\n\nstruct io_uring_sqe *sqe;\n\n// 1. Open\nsqe = io_uring_get_sqe(&ring);\nio_uring_prep_openat(sqe, AT_FDCWD, \"/data/file.dat\", O_RDONLY, 0);\nsqe->flags |= IOSQE_IO_LINK; // Link to next operation\nsqe->user_data = OP_OPEN;\n\n// 2. Read (uses fd from open)\nsqe = io_uring_get_sqe(&ring);\nio_uring_prep_read(sqe, 0 /* fd filled by kernel */, buf, size, 0);\nsqe->flags |= IOSQE_IO_LINK;\nsqe->user_data = OP_READ;\n\n// 3. Close\nsqe = io_uring_get_sqe(&ring);\nio_uring_prep_close(sqe, 0);\nsqe->user_data = OP_CLOSE;\n\nio_uring_submit(&ring);\n```\n\nThis is powerful for workflows like \"open, read header, seek, read data, close\", the entire chain is submitted in one shot, and the kernel executes it without any userspace involvement between steps. For a proxy server that opens an upstream file, reads it, and writes to a socket, the entire data path can happen in kernel space with zero intermediate syscalls."},{"heading":"Security Considerations","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#security-considerations","content":"io\\_uring has had a rocky security history. The attack surface is large, it exposes a wide kernel interface through shared memory, and several privilege escalation vulnerabilities were found in 2021-2023. As a result:\n\n- Docker disables io\\_uring by default in its seccomp profile\n- Google disabled it in ChromeOS and Android\n- Several Linux distros added kernel parameters to disable it\n\nAs of kernel 6.1+, the security model has been significantly improved. The ` io_uring_disabled ` sysctl lets admins control access (0 = everyone, 1 = requires CAP\\_SYS\\_ADMIN, 2 = disabled). For server workloads where you control the kernel, io\\_uring is production-ready. For multi-tenant containers, evaluate your threat model carefully."},{"heading":"When to Actually Use This","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#when-to-actually-use-this","content":"io\\_uring is not for every application. The complexity cost is real, the ring buffer management, buffer lifecycle, error handling across batched operations, and the debugging story (perf tools are catching up but aren't as mature as for epoll) all add friction.\n\nUse io\\_uring when:\n\n- You're I/O-bound and syscall overhead is measurable in your profiles\n- You need to saturate NVMe or high-speed network devices\n- You're building storage engines, proxies, or database systems\n- You need the multishot accept pattern for high-connection-rate servers\n\nStick with epoll/libuv when:\n\n- Your bottleneck is application logic, not I/O dispatch\n- You're running in containers where io\\_uring might be disabled\n- Your team doesn't have systems programming expertise\n- The workload is bursty rather than sustained high-throughput\n\nThe reality is that for most Node.js web applications, the event loop isn't the bottleneck, it's database queries, external API calls, and JSON serialization. Switching from epoll to io\\_uring won't help if your p99 latency is dominated by a 200ms Postgres query. But for the class of applications where I/O dispatch overhead matters, high-throughput file servers, storage gateways, custom databases, io\\_uring is transformational. It's not incremental improvement. It's a different architecture."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#questions","content":""},{"heading":"Is io_uring ready for production?","level":3,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#question-1","content":"Yes, with caveats. It's used in production by Ceph, RocksDB, and several cloud providers' storage stacks. The security surface has been controversial, it was disabled in several container runtimes and Android. As of kernel 6.1+, the security model has been significantly tightened."},{"heading":"Does Node.js use io_uring?","level":3,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#question-2","content":"Not in the main event loop. libuv still uses epoll on Linux. However, there are experimental bindings and the libuv team has explored io\\_uring integration. For specific workloads like file I/O, you can use io\\_uring directly via native addons."},{"heading":"Will io_uring replace epoll?","level":3,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#question-3","content":"For new code, io\\_uring is superior in almost every dimension. But epoll isn't going anywhere, it's simpler, well-understood, and sufficient for many workloads. io\\_uring matters most for high-throughput I/O-bound servers where syscall overhead is measurable."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#article-tips","content":"1. **Tip:** Use ` IORING_SETUP_SQPOLL ` only for sustained high-throughput workloads. The kernel polling thread burns a CPU core continuously, for bursty traffic, the overhead outweighs the syscall savings.\n2. **Tip:** Register your buffers with ` io_uring_register_buffers() ` before entering your hot loop. The kernel pins the pages and skips the ` copy_from_user ` on every I/O, this alone gives 15-20% throughput improvement on large reads.\n3. **Tip:** When benchmarking io\\_uring vs epoll, disable ` CONFIG_RETPOLINE ` mitigations or the syscall overhead delta will be inflated. Real production servers have mitigations enabled, so benchmark with them on for honest numbers.\n4. **Tip:** Set ` IORING_SETUP_COOP_TASKRUN ` on kernel 5.19+ to avoid the inter-processor interrupt (IPI) on completion. This matters enormously on multi-socket NUMA systems.\n\n- io\\_uring\n- linux\n- async-io\n- nodejs\n- systems-programming\n- kernel\n- performance\n- epoll\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/linux-io-uring-async-io-nodejs-integration"},{"text":"Node.js","url":"https://nodejs.org/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration"},"headline":"Linux io_uring: Async I/O Architecture and Node.js Integration","description":"A systems-level analysis of io_uring, submission/completion queues, registered buffers, fixed files, kernel-side polling, and benchmarks comparing io_uring against epoll and synchronous I/O for various workloads.","datePublished":"2026-01-11","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Systems Programming","keywords":"io_uring, linux, async-io, nodejs, systems-programming, kernel, performance, epoll","wordCount":1817,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/linux-io-uring-async-io-nodejs-integration"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Linux io_uring: Async I/O Architecture and Node.js Integration","item":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is io_uring ready for production?","acceptedAnswer":{"@type":"Answer","text":"Yes, with caveats. It's used in production by Ceph, RocksDB, and several cloud providers' storage stacks. The security surface has been controversial, it was disabled in several container runtimes and Android. As of kernel 6.1+, the security model has been significantly tightened."}},{"@type":"Question","name":"Does Node.js use io_uring?","acceptedAnswer":{"@type":"Answer","text":"Not in the main event loop. libuv still uses epoll on Linux. However, there are experimental bindings and the libuv team has explored io_uring integration. For specific workloads like file I/O, you can use io_uring directly via native addons."}},{"@type":"Question","name":"Will io_uring replace epoll?","acceptedAnswer":{"@type":"Answer","text":"For new code, io_uring is superior in almost every dimension. But epoll isn't going anywhere, it's simpler, well-understood, and sufficient for many workloads. io_uring matters most for high-throughput I/O-bound servers where syscall overhead is measurable."}}]}]}],"contentHash":"b0a5c9dd118a1e6a7f24dda6998e10b771681c4ed4435b2b841b73df6d732631","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/linux-io-uring-async-io-nodejs-integration","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":71063,"contentBytes":16019}}
