Almost every confusing thing about Node.js — why a slow function freezes your entire server, why setTimeout(fn, 0) does not run immediately, why a Promise resolves before a timer you set first — traces back to one mechanism: the event loop. Node runs your JavaScript on a single thread, and the event loop is how a single thread manages to serve thousands of concurrent connections without blocking. Understand it and the rest of Node stops being a collection of quirks and becomes a coherent system. Misunderstand it and you will eventually write the one function that takes the whole process down.
This is the anchor of a series on the Node.js runtime, which goes deep on async patterns, streams, worker threads, clustering, memory and profiling, and error handling. Start here, because all of them assume this model.
Single-threaded, but not single-tasking
The headline fact is that your JavaScript in Node runs on a single thread. There is one call stack, and only one thing executes at a time. The obvious question is how that serves thousands of simultaneous requests, and the answer is the distinction that everything hinges on: Node does your JavaScript on one thread, but it does not do your I/O there.
When your code asks to read a file, query a database, or make a network call, Node does not sit and wait. It hands that operation off — to the operating system, or to a small pool of background threads managed by a C library called libuv — and immediately moves on to run other JavaScript. When the I/O finishes, the result comes back as a callback that Node queues up to run when the thread is free. So the single thread is almost never blocked waiting; it is constantly doing useful work while dozens of I/O operations proceed in the background. This is why Node is exceptional at I/O-heavy workloads like APIs and proxies, and it is the whole reason the runtime exists.
The critical corollary: this only works if your JavaScript never hogs the thread. Node's concurrency comes from the thread being free to pick up the next callback. A function that runs a long synchronous loop holds the thread and every other request waits behind it. That is the failure mode to keep in mind through everything below.
What the event loop actually is
The event loop is the coordinator that decides what runs next on that single thread. It is a loop that runs continuously, and on each turn it works through a series of phases, each with its own queue of callbacks. Simplified, the phases in order are:
-
Timers — callbacks scheduled by
setTimeoutandsetIntervalwhose time has come. - Pending callbacks — certain deferred I/O callbacks.
- Poll — where the loop spends most of its time: it retrieves new I/O events and runs their callbacks (an incoming request, a finished file read).
-
Check — callbacks scheduled with
setImmediate. - Close — cleanup callbacks like a socket closing.
On each iteration, the loop enters a phase, drains that phase's queue of ready callbacks, and moves to the next. When it has gone through all phases, it loops again. If there is nothing left to do — no timers pending, no I/O outstanding — the loop exits and the process ends. This phase structure explains a lot of Node's ordering behavior, including why setTimeout(fn, 0) and setImmediate(fn) can fire in either order depending on context: they live in different phases.
Microtasks jump the queue
There is a second, higher-priority queue layered on top of the phases: the microtask queue, which holds resolved Promise callbacks (the .then handlers, and the continuations after await) and process.nextTick callbacks. The rule that matters: microtasks run between every operation, not on a phase of their own. After each individual callback finishes, and before the loop moves on, Node completely drains the microtask queue.
This is why a Promise resolved "later" in your code can run before a setTimeout(fn, 0) you scheduled "first":
setTimeout(() => console.log("timeout"), 0)
Promise.resolve().then(() => console.log("promise"))
// Output:
// promise
// timeout
The Promise's callback is a microtask, so it runs as soon as the current synchronous code finishes, before the loop even gets to the timers phase. The timer, despite a delay of zero, has to wait for the next timers phase. Once you internalize that microtasks drain between operations while timers and I/O wait for their phase, Node's execution order stops being surprising. It also carries a warning: because microtasks run to exhaustion before the loop proceeds, a Promise chain that endlessly schedules more microtasks can starve the loop and block I/O just as badly as a synchronous loop can.
Blocking the loop is the cardinal sin
Everything about Node's performance model comes down to keeping the event loop turning. Because there is one thread for your JavaScript, any single piece of code that runs too long delays every pending callback behind it — every queued request, every ready I/O result. A CPU-heavy task is the classic culprit:
app.get("/report", (req, res) => {
const result = crunchNumbersForTwoSeconds() // blocks the whole process
res.json(result)
})
While crunchNumbersForTwoSeconds runs, the event loop cannot advance. Not one other request is served, no matter how many are waiting. For those two seconds, your multi-thousand-connection server handles exactly nobody. This is the single most important thing to understand about Node in production: the runtime is superb at juggling I/O, and helpless against CPU-bound work on the main thread.
The fix is not to make the main thread do the heavy work faster; it is to get the heavy work off the main thread entirely. Genuinely CPU-bound tasks belong in worker threads, which run JavaScript on separate threads for exactly this purpose, or in a separate service reached through a message queue. To use every core on the machine rather than one, you run multiple Node processes with the cluster module. Both are ways of respecting the same rule: the event loop must stay free.
The model to carry forward
Hold onto this picture and the rest of Node follows from it. One thread runs your JavaScript. I/O is handed off and comes back as queued callbacks, so the thread stays busy instead of waiting. The event loop cycles through phases, draining callbacks, with microtasks squeezing in between every operation at higher priority. And the whole thing works only as long as no callback monopolizes the thread. Async patterns are how you write code that cooperates with this model, streams are how you process large data without blocking, and profiling is how you find the code that accidentally does. It all comes back to the loop.
Originally published at amanksingh.com.
Top comments (0)