What this module covers: Node.js's non-blocking I/O is not magic — it is a precise coordination between libuv and OS-level kernel interfaces. epoll on Linux, kqueue on macOS/BSD, and IOCP on Windows are the actual mechanisms that make thousands of concurrent connections possible without thousands of threads. Understanding them at the system call level lets you predict Node.js network behavior under load, tune kernel parameters correctly, and diagnose failures that are invisible from the JavaScript layer. This module traces a transaction payload from the network interface card to your callback with no hand-waving.
When a Node.js blockchain indexer handles 50,000 concurrent TCP connections from blockchain full nodes, each pushing new block data, two questions matter:
How does the OS tell Node.js that one of those 50,000 connections has new data ready to read?
How does Node.js read that data without blocking on connections that have no data?
The answer to both questions is the I/O multiplexer — a kernel interface that watches many file descriptors simultaneously and notifies the application when any of them are ready.
The three implementations differ in important ways. Knowing which one you're on and how it works determines which kernel parameters you can tune and how Node.js behaves under connection-heavy loads.
Critical limitation: O(N) at both submission and return. The fd_set is a fixed-size bitmap. The kernel scans every bit to find ready descriptors. You scan every bit again to find which ones fired. At 50,000 file descriptors, this is 50,000 bit checks twice, every time you call select. And select's maximum nfds is typically 1,024 on Linux — you literally cannot watch more than 1,024 file descriptors.
poll removes the 1,024 limit but keeps the O(N) scan:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
You pass an array of pollfd structs. The kernel fills in revents for each one that's ready. You still scan the entire array to find which descriptors fired. At 50,000 connections, this is 50,000 struct scans on every call.
Node.js does not use poll for network sockets (though libuv falls back to it on some platforms for specific operations).
epoll was introduced in Linux 2.5.44 (2002) and is the foundation of Node.js's I/O on Linux. It solves the O(N) problem:
// Create an epoll instance int epfd = epoll_create1(0);
// Register a file descriptor to watch struct epoll_event ev; ev.events = EPOLLIN; // watch for readable data ev.data.fd = socket_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &ev);
// Wait for events — returns only READY descriptors int n = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms); // n = number of ready events, events[] contains only the ready ones
epoll_wait is like a hotel concierge with a single buzzer board instead of walking every hallway checking each room's light — the room itself lights the board the instant it needs attention; the concierge only ever looks at lit bulbs. select and poll are the concierge walking every hallway on every round, checking each door whether it's lit or not.
The key difference:epoll_wait returns only the file descriptors that are actually ready. If 50,000 connections are registered but only 3 have new data, epoll_wait returns 3 — not 50,000. The kernel maintains an internal ready-list and adds to it when descriptors become ready via interrupt-driven notification.
The result: epoll_wait is O(ready events), not O(total registered). Registering 50,000 connections costs 50,000 epoll_ctl calls at setup time, but each subsequent epoll_wait scales with activity, not connection count.
epoll is a readiness API — it tells you a file descriptor is ready, then you still make a separate syscall (read, write, accept4) to actually move the data. Every ready event costs at least two syscalls: one to learn about it, one to act on it.
io_uring, added to the Linux kernel in 5.1 (2019), is a completion API built around two shared ring buffers (a submission queue and a completion queue) mapped into both kernel and userspace. You submit a batch of I/O requests — reads, writes, accepts — without a syscall per request, and the kernel posts completions to the other ring as they finish. This collapses the two-syscalls-per-event pattern of epoll + read/write into something closer to zero syscalls per event once a batch is submitted, and it uniformly supports both socket I/O and file I/O (epoll never worked reliably for regular files).
libuv has been incrementally integrating io_uring as an internal backend on Linux for several years — it is not yet a full replacement for the epoll-based poll phase across all of libuv's operations, but it is the direction the kernel and libuv are both moving for reducing per-I/O-event syscall overhead. None of this is exposed directly to JavaScript; it's an internal libuv implementation detail, but it explains why "epoll is the final word on Linux async I/O" is already out of date at the kernel level.
Creates an epoll instance — a kernel data structure that maintains the watched set and the ready list. Returns a file descriptor. EPOLL_CLOEXEC ensures the descriptor is closed if the process exec's (security hygiene).
libuv calls this once at startup. The single epfd watches all sockets, timers, pipes, and signals for the Node.js process.
// Add a new file descriptor to watch epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
// Modify the events to watch on an existing fd epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &event);
// Remove a file descriptor epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
Called by libuv when:
A new TCP connection is accepted → EPOLL_CTL_ADD with EPOLLIN | EPOLLOUT
A socket transitions from write-interested to read-only → EPOLL_CTL_MOD
A connection closes → EPOLL_CTL_DEL
For a blockchain indexer receiving 50,000 concurrent connections: 50,000 EPOLL_CTL_ADD calls at connection time. One epfd watching all 50,000.
How the connection gets accepted in the first place: when the listening socket becomes readable (a pending connection in the accept queue), libuv doesn't call plain accept() — it calls accept4(listen_fd, &addr, &addrlen, SOCK_NONBLOCK | SOCK_CLOEXEC). The two flags matter: SOCK_NONBLOCK sets the new connection's file descriptor to non-blocking mode atomically, as part of the same syscall that creates it — avoiding a separate fcntl(fd, F_SETFL, O_NONBLOCK) call and the race condition where another thread could operate on the fd in blocking mode between accept() and the follow-up fcntl(). SOCK_CLOEXEC atomically marks the descriptor to close on exec(), the same security hygiene EPOLL_CLOEXEC provides for the epoll instance itself. Only after accept4 returns does libuv register the new fd with EPOLL_CTL_ADD.
struct epoll_event events[MAX_EVENTS]; int n = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms); for (int i = 0; i < n; i++) { handle_event(events[i].data.fd, events[i].events); }
This is called by libuv in the poll phase of the event loop. It blocks until:
At least one registered fd is ready, OR
The timeout expires (calculated by libuv based on pending timers)
When it returns, events[0..n-1] contains only the ready descriptors. libuv processes each one, dispatching the appropriate callback.
epoll supports two notification modes, set per file descriptor via the EPOLLET flag in the epoll_event struct:
Level-triggered (the default, no EPOLLET):epoll_wait keeps reporting a file descriptor as ready for as long as its condition holds — e.g., if there are 4KB of unread data in the socket buffer and you only read() 1KB, the fd is still ready on the nextepoll_wait call, because 3KB remain unread.
Edge-triggered (EPOLLET):epoll_wait reports the fd exactly once, at the moment its state transitions from not-ready to ready. If you don't drain the socket buffer completely on that notification, you will not be told again until more new data arrives — you must loop on read() until it returns EAGAIN.
libuv uses level-triggered mode by default. This is a deliberate simplicity trade-off: level-triggered epoll is far more forgiving of partial reads — if a callback only drains part of a socket's buffered data, the fd simply comes back ready on the next iteration, no special drain-to-EAGAIN loop required. Edge-triggered mode is slightly more efficient in extreme high-connection-count scenarios (fewer redundant wakeups for already-known-ready descriptors) but requires the application to be scrupulous about fully draining every fd on every notification — a single missed drain silently stalls that connection until new data arrives. libuv's poll phase code isn't written to that discipline, so it stays with the simpler, safer level-triggered default.
Let me trace a transaction payload from a blockchain full node through every layer to your callback. This is the path that happens millions of times per second in a production indexer.
1. [Network] Full node sends TCP segment containing block data ↓ 2. [NIC] Network interface card receives the packet via DMA NIC fires a hardware interrupt to notify the CPU ↓ 3. [Kernel — Interrupt Handler] CPU handles the interrupt in kernel context Copies packet data from NIC buffer to kernel socket receive buffer (SO_RCVBUF — we'll tune this below) ↓ 4. [Kernel — Socket State Machine] TCP stack processes the segment: acknowledgement, reassembly If the socket was watching EPOLLIN, marks it ready in epoll's ready list ↓ 5. [epoll_wait returns] libuv's poll phase was blocked in epoll_wait epoll_wait returns with this socket in the events[] array ↓ 6. [libuv — I/O Poll Phase] libuv calls the registered read callback for this socket handle The callback calls recv() / read() to copy data from kernel buffer to userspace (libuv's read buffer) ↓ 7. [libuv → Node.js] libuv fires the 'data' event on the corresponding net.Socket Passes the data as a Buffer (or calls the registered stream handler) ↓ 8. [JavaScript — Event Loop] Your 'data' callback runs You parse the Buffer, extract the transaction Issue async database write (non-blocking) Return from callback ↓ 9. [Event loop continues] Next ready socket's data callback runs Or the loop blocks in epoll_wait again if nothing is ready
Every step from 1 to 7 happens in kernel or libuv C code, with zero JavaScript involvement. Step 8 is the only JavaScript. This is why a single Node.js process can handle 50,000 connections: steps 1–7 are handled by the kernel's interrupt-driven mechanisms, requiring no application-level threads.
kqueue is the BSD equivalent of epoll. The API is different but the concept is identical: an event queue that you register file descriptors into and poll for readiness notifications.
// Create a kqueue instance int kq = kqueue();
// Register a socket for read readiness struct kevent change; EV_SET(&change, socket_fd, EVFILT_READ, EV_ADD, 0, 0, NULL); kevent(kq, &change, 1, NULL, 0, NULL); // register (no wait)
// Wait for events struct kevent events[MAX_EVENTS]; int n = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL); // wait
Node.js on macOS uses kqueue via libuv's kqueue backend. The behavior is identical to epoll from the application perspective.
Practical difference for development: macOS often has lower default file descriptor limits (ulimit -n). If you're testing a high-connection-count scenario locally on macOS, you may need:
# Check current limit ulimit -n
# Increase for current session (testing only) ulimit -n 65536
# Permanent: edit /etc/launchd.conf or use launchctl limit maxfiles
Windows uses I/O Completion Ports (IOCP) — a fundamentally different model. Where epoll/kqueue are readiness-based (notify when an fd is ready to read/write), IOCP is completion-based (notify when an I/O operation has completed and the data is already in your buffer).
epoll/kqueue model: "Socket X has data available. Go read it." → Application calls recv() to copy data from kernel to userspace buffer
IOCP model: "I completed reading from socket X. The data is already in buffer Y at offset Z." → Application processes the data directly
IOCP can be more efficient on Windows because it eliminates the application-side recv() syscall — data arrives ready to use. libuv implements an IOCP backend for Windows, maintaining the same Node.js API surface across platforms.
When a blockchain full node is pushing multi-MB block data, the kernel's receive buffer determines how much data can accumulate before the sender must stop (TCP flow control kicks in).
Default: 87,380 bytes (85KB) on most Linux systems.
For a blockchain indexer receiving large block payloads:
// Set socket buffer sizes in Node.js const server = net.createServer((socket) => { // Set before the connection is fully established socket.setNoDelay(true); // disable Nagle socket.setKeepAlive(true, 30000);
// Buffer sizes must be set via socket options if you need > default // Node.js doesn't expose SO_RCVBUF directly, but you can use a native addon // or set system-wide defaults via sysctl
Too small (default 85KB): Full node pushes a 2MB Ethereum block. The kernel receive buffer fills at 85KB. TCP advertises zero window to the sender. The sender pauses. Node.js drains the buffer. TCP opens the window again. This cycle creates bursty throughput instead of smooth streaming. Under extreme load, this back-and-forth adds latency.
Too large: More memory consumed per connection. With 50,000 connections at 2MB buffer each, that's 100GB of kernel buffer space — untenable.
Correct approach: Size to your expected payload size. For a blockchain indexer where individual blocks are 1–5MB, a 4MB receive buffer per connection is reasonable. Scale rmem_max to 16MB and let the OS auto-tune with tcp_rmem.
Nagle's Algorithm was designed to reduce the number of small TCP packets. It buffers small writes and coalesces them:
Nagle's rule: send immediately IF: - The buffer contains a full-size segment (MSS), OR - All previously sent data has been acknowledged
Otherwise: buffer and wait for ACK or MSS
For a real-time payment gateway or WebSocket notification system, Nagle introduces visible latency. A 50-byte payment acknowledgement gets buffered waiting for a full 1460-byte segment that may never come.
// Where to set it: const server = net.createServer((socket) => { socket.setNoDelay(true); // as early as possible on new connections });
When to keep Nagle enabled: bulk data transfers where throughput matters more than latency. A blockchain full node pushing bulk historical block data benefits from Nagle — fewer, larger packets use network bandwidth more efficiently.
The rule for a hybrid system: disable Nagle on connections that send real-time acknowledgements; enable (or leave default) on connections used for bulk data ingestion.
By default, only one process can bind to a given port. SO_REUSEPORT allows multiple processes (or sockets) to bind to the same port, with the kernel distributing incoming connections between them using a hash of (source IP, source port, dest IP, dest port).
This is the correct way to use Node.js cluster for TCP workloads — instead of the master process distributing connections in userspace (with round-robin, which breaks connection affinity), the kernel distributes them with consistent hashing.
// Using SO_REUSEPORT for multiple workers bound to the same port // Native `reusePort` support in net.createServer()/server.listen() landed in // Node.js v23.1.0 (and was backported to v22.12.0) — NOT Node 16. Earlier // versions have no built-in way to request SO_REUSEPORT from net/cluster.
import cluster from 'node:cluster'; import net from 'node:net'; import { cpus } from 'node:os';
if (cluster.isPrimary) { for (let i = 0; i < cpus().length; i++) { cluster.fork(); } } else { // Each worker creates its OWN server on the same port // The kernel distributes connections between workers const server = net.createServer({ reusePort: true }, (socket) => { socket.setNoDelay(true); handleConnection(socket); });
strace (Linux) and dtruss (macOS) let you observe the actual system calls Node.js makes, confirming the kernel interactions described above.
# Trace I/O-related syscalls for a running Node.js process strace -p $(pgrep -f "node indexer") \ -e trace=epoll_wait,epoll_ctl,read,write,accept4,close \ -T # show time spent in each syscall
epoll_wait returning 48 events with timeout=0 means the ready list is continuously populated — the event loop never goes idle. This is a healthy high-throughput state, not starvation, as long as each callback is fast.
The kernel exposes all TCP socket state via /proc/net/tcp (IPv4) and /proc/net/tcp6 (IPv6).
# Count sockets by state awk 'NR>1 {print $4}' /proc/net/tcp | sort | uniq -c | sort -rn
# States (hex): 01=ESTABLISHED, 06=TIME_WAIT, 0A=LISTEN, 0B=CLOSING
The SYN backlog: detecting connection drops
When a blockchain full node makes 10,000 simultaneous new connections to your indexer, the kernel must process SYN packets faster than the application calls accept(). The SYN backlog queue holds pending connections waiting for accept().
If the SYN backlog fills, the kernel drops new SYN packets silently — clients get no response and eventually time out.
# Check current SYN backlog setting sysctl net.ipv4.tcp_max_syn_backlog # default: 128–256
# For high-connection-rate indexers sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65536 sudo sysctl -w net.core.somaxconn=65536 # also raise the listen() backlog
# In Node.js: pass backlog to listen() server.listen(3000, '0.0.0.0', 65536); // explicit backlog parameter
# Monitor SYN drops in real time watch -n 1 "netstat -s | grep -i 'syn\|overflow\|drop'"
Production story: A UPI settlement gateway had 40 partner bank connections that all reset and reconnected at the midnight settlement-window cutover — a scheduled, expected event, not a failure. All 40 reconnect attempts landed within the same few hundred milliseconds. The default net.ipv4.tcp_max_syn_backlog (128–256 on most distributions) combined with a low somaxconn was enough headroom on paper for 40 connections, but combined with retransmitted SYNs from banks whose first attempt got no ACK in time, the effective burst was several times larger than 40. The symptoms looked nothing like a backlog problem: partner banks reported random-seeming connection timeouts, some banks connected fine, others failed for a few seconds and then succeeded on retry, and application logs showed nothing — because a silently dropped SYN never reaches the application layer at all. It took netstat -s | grep -i overflow to reveal the actual cause: <N> times the listen queue of a socket overflowed, incrementing in lockstep with the midnight cutover, every night. The fix was raising tcp_max_syn_backlog and somaxconn well above the expected simultaneous-reconnect count and passing an explicit backlog to server.listen(), so the kernel had enough queue depth to absorb the entire reconnect burst plus its retransmits without dropping a single SYN.
Context: A blockchain indexer with 5,000 persistent connections from blockchain full nodes. The indexer deployed a new version. All 5,000 full nodes detected the connection drop simultaneously and attempted to reconnect at the same moment.
What happened:
All 5,000 connection attempts arrived within 200ms. The kernel accepted them all into the SYN backlog, but the Node.js net.createServer call queue could only accept connections as fast as the single accept4 call in libuv processed them.
The real problem: all 5,000 connections established simultaneously generated 5,000 epoll_ctl(EPOLL_CTL_ADD) calls, each sending an immediate "hello" protocol message. The first epoll_wait after reconnection returned 5,000 ready events. libuv dispatched 5,000 callbacks simultaneously. Each callback triggered a database lookup (authenticate the reconnecting node). The database connection pool (default 10 connections) was instantly exhausted. 4,990 database lookups queued. Authentication took 30–120 seconds per connection instead of < 10ms. The thundering herd had caused a database pool exhaustion cascade.
The fix — connection rate limiting at the TCP accept layer:
// Limit simultaneous new connections being processed let activeHandshakes = 0; const MAX_CONCURRENT_HANDSHAKES = 50; const pendingConnections = [];
server.on('connection', (socket) => { if (activeHandshakes >= MAX_CONCURRENT_HANDSHAKES) { // Defer processing — socket is accepted at TCP level, won't be dropped pendingConnections.push(socket); return; }
processConnection(socket); });
function processConnection(socket) { activeHandshakes++; socket.setNoDelay(true);
authenticateNode(socket) .then(() => registerNode(socket)) .finally(() => { activeHandshakes--; // Process next deferred connection if (pendingConnections.length > 0) { const next = pendingConnections.shift(); setImmediate(() => processConnection(next)); } }); }
Additionally:SO_REUSEPORT across 8 workers distributed the initial accept load — instead of one process handling all 5,000 accept calls, 8 workers each handled ~625. Reconnection storm time dropped from 120s to 8s.
You now know what happens at every layer below Node.js. Module 4 moves back up to the application layer and covers the HTTP/TCP subsystem — backpressure mechanisms, high-water marks, the drain event contract, and how to prevent socket floods from taking down your ingestion service.
Why is Linux's epoll crucial for Node.js's ability to handle tens of thousands of concurrent connections, compared to the older select or poll interfaces?
Which kernel-level parameter is most likely misconfigured if large TCP payloads arrive in bursty, inconsistent chunks because TCP flow control frequently forces the sender to pause?
To mitigate the CPU and connection pool exhaustion caused by a "thundering herd" of simultaneous reconnects, which strategy is the most effective at the application layer?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.