Blog
Inside the Event Loop: Why Your Node Server Stalls Under Load
Understand how the Node.js event loop actually schedules your callbacks, where blocking creeps in, and which async patterns keep a single thread responsive under real traffic.
Node.js runs your JavaScript on a single thread, yet it serves thousands of concurrent connections. That apparent contradiction is resolved by the event loop, the scheduler libuv runs underneath V8. If you came from a threaded backend, the mental model is different: you do not get parallelism for free, you get cooperative concurrency. One slow synchronous function freezes everything, including unrelated requests. The event loop processes work in distinct phases, each draining a queue of callbacks before moving on. Promises and timers land in different places, which explains ordering surprises that confuse newcomers. Once you internalize the phases and where your code actually executes, you can reason about latency, avoid accidental blocking, and choose async patterns deliberately. This post walks through the loop, the queues, and the patterns that keep your server fast.
Single Thread, Many Connections
Node handles concurrency without threads per request by relying on non-blocking I/O. When you read a file or query Postgres, Node hands the operation to the operating system or a libuv thread pool and immediately returns to processing other work. The result arrives later as a callback. This is why a single Node process can hold tens of thousands of idle sockets cheaply: each one costs memory, not a thread. The trade-off is strict. Any CPU-heavy work you run inline, like parsing a huge JSON payload or hashing a password synchronously, occupies the only thread that serves every client. During that time no other request progresses. Coming from Express on a typical app, most of your time is spent waiting on I/O, so the model works beautifully. The danger is the occasional CPU spike that nobody profiled.
Phases of the Loop
The event loop is not one queue but a cycle of phases, each with its own callback queue. The timers phase runs callbacks scheduled by setTimeout and setInterval whose deadlines have passed. The pending callbacks phase handles deferred system operations. The poll phase is where most action happens: it retrieves new I/O events and executes their callbacks, and it can block here waiting for work if nothing else is pending. The check phase runs setImmediate callbacks. The close phase fires close events like a destroyed socket. The loop visits these phases in order, draining each queue before advancing. Understanding this order explains why setImmediate often fires before a setTimeout of zero, and why I/O callbacks schedule follow-up work predictably. You rarely manipulate phases directly, but knowing they exist demystifies timing behavior in production.
Microtasks Versus Macrotasks
Beyond the phases sit two microtask queues that Node drains between every callback, not just between phases. Promise reactions, meaning then, catch, and the continuation after await, go into one queue. process.nextTick callbacks go into a separate, even higher priority queue that runs first. After each macrotask completes, Node empties nextTick entirely, then empties the promise microtask queue, before returning to the loop. This is why an awaited promise resolves before a pending setTimeout, and why flooding process.nextTick can starve the loop completely, blocking I/O indefinitely. The practical lesson is that microtasks feel instant but are not free: they run before the loop can service sockets. Use process.nextTick sparingly, prefer promises for ordinary async flow, and never build a recursive nextTick loop expecting other work to interleave, because it will not.
Callbacks, Promises, Async Await
The async vocabulary in Node evolved in layers. Original APIs used error-first callbacks, where the first argument is an error or null and the second is the result. Nesting these produces the infamous pyramid that is hard to read and error prone. Promises flattened that nesting into chains and gave a single place to attach error handling. Async and await then made promise-based code read like synchronous code while still yielding to the loop at each await. Under the hood, await suspends the function and schedules the continuation as a microtask when the promise settles. The key correctness point: await does not block the thread, it pauses one function while the loop keeps serving others. For new APIs, prefer async and await with promises, and wrap legacy callback APIs using util.promisify so your entire codebase speaks one dialect.
Avoiding Accidental Blocking
Blocking the event loop is the most common production performance bug in Node. The synchronous file methods, large synchronous JSON parsing or stringifying, regular expressions with catastrophic backtracking, and tight CPU loops all hold the thread hostage. While blocked, your server accepts no new connections, finishes no requests, and answers no health checks, so orchestrators may even kill it. Detect blocking by measuring event loop lag: schedule a timer for a known interval and observe how late it actually fires. Libraries expose this metric for dashboards. To fix blocking, move CPU-bound work off the main thread using worker threads or a separate service, break large loops into chunks scheduled with setImmediate so the loop breathes, and stream large payloads instead of buffering them. Treat any synchronous operation over a few milliseconds as suspect under load.
Concurrency Without Threads
Async patterns let you run many I/O operations at once even on one thread. Awaiting in a loop runs operations one after another, which is correct when each depends on the last but wasteful otherwise. To fan out, start all the promises first and await them together with Promise.all, which resolves when every promise settles or rejects as soon as one fails. Promise.allSettled instead waits for all and reports each outcome, useful when partial failure is acceptable. Promise.race resolves with the first to settle, handy for timeouts. Be careful with unbounded fan-out: firing ten thousand database queries simultaneously can exhaust connections and memory. Use a concurrency limiter to cap how many run at once, processing a queue in controlled batches. The mental shift from sequential thinking to deliberate parallelism is where most performance gains in Node code actually come from.
Worker Threads And Offloading
When you genuinely have CPU-bound work, image processing, cryptography, or heavy computation, the answer is to move it off the event loop thread. Worker threads, available through the worker_threads module, run JavaScript on separate threads with their own V8 instance and event loop, communicating through message passing or shared memory buffers. This keeps your main thread free to serve requests while the heavy work proceeds in parallel. For coarse-grained workloads you might instead spawn child processes or offload to a dedicated microservice, which is closer to how you might use a separate worker in a MERN stack. A worker pool, where you reuse a fixed set of workers rather than spawning one per task, avoids the startup cost of creating threads repeatedly. The rule of thumb: I/O stays on the loop, sustained computation goes to workers, and you measure before assuming you need either.
Practical Takeaways
To keep a Node server fast, treat the single thread as a shared resource that every request competes for. Profile with event loop lag metrics and flame graphs rather than guessing where time goes. Default to async and await for I/O, fan out with Promise.all when operations are independent, and bound that fan-out so you do not overwhelm databases or memory. Never call synchronous file or crypto APIs on the request path, and be suspicious of any regular expression fed untrusted input. Push CPU-heavy tasks to worker threads or a separate service. Use process.nextTick almost never, and understand that awaited promises resolve as microtasks ahead of timers. Most importantly, build a mental model of the phases and queues so that when latency spikes in production, you can reason about cause instead of randomly adding await statements and hoping. The event loop rewards deliberate design.