The Node.js Event Loop Explained: How Single-Threaded Serves Thousands
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.
Key takeaways
- Node runs your JavaScript on one thread but hands off I/O to libuv and the OS, so the thread stays free instead of waiting — the source of its concurrency.
- The event loop cycles through phases (timers, poll, check, close), draining each phase's queue of ready callbacks each turn.
- Microtasks (resolved Promises, process.nextTick) run between every operation at higher priority, which is why a Promise can beat a setTimeout(fn, 0).
- Blocking the loop with long synchronous or CPU-bound work stalls every pending request, because there is one thread for all your JavaScript.
- Move CPU-bound work off the main thread with worker threads or a separate service, and use all cores with the cluster module.
Frequently asked questions
How does single-threaded Node.js handle thousands of connections?
It runs your JavaScript on one thread but does not wait there for I/O. File, network, and database operations are handed off to libuv and the operating system, and their results come back as queued callbacks. The single thread stays busy running JavaScript while many I/O operations proceed in the background.
What are the phases of the event loop?
Simplified and in order: timers (setTimeout/setInterval), pending callbacks, poll (where most I/O callbacks run), check (setImmediate), and close callbacks. Each turn, the loop enters a phase, drains its ready callbacks, and moves on, looping until there is no work left.
Why does a Promise resolve before a setTimeout(fn, 0)?
Because Promise callbacks are microtasks, which run between every operation before the loop advances, while setTimeout callbacks wait for the timers phase. So a resolved Promise runs as soon as the current synchronous code finishes, ahead of a zero-delay timer scheduled earlier.
What does it mean to block the event loop?
Running code that holds the single JavaScript thread too long — a long synchronous loop or CPU-heavy computation. While it runs, the loop cannot advance, so no other requests or I/O callbacks are served. The fix is to move heavy work to worker threads or a separate service.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.