1.1–1.2 · The event loop and libuv: one waiter, two kitchens
How Node.js serves thousands of connections with one JavaScript thread — the six-phase event loop, microtasks, and where libuv sends the work.
- Essay
- Interactive
- Quiz · 10
Your JavaScript runs one function at a time. A Node server still handles thousands of connections at once. This is Module 1 (chapters 1.1 and 1.2) of a series on Node.js performance, and it explains how those two facts fit together — with one picture: a waiter running a circuit between two kitchens.
Your JavaScript runs on one thread. It has one call stack and runs one function at a time. The Node process that runs your JavaScript is not single-threaded, though. It has other threads too: the garbage collector, the libuv thread pool, and the operating system doing socket work underneath it all. The event loop is the link between your one thread and everything else.
Everything in this post hangs off one picture: a single waiter running an endless circuit around a restaurant. The waiter never cooks. They take an order, hand it to a kitchen, and move on. When something is ready, a bell rings and the waiter delivers it. That is the whole shape of Node.
| Restaurant | Node |
|---|---|
| The waiter | The JavaScript thread |
| The waiter’s fixed circuit | The event loop |
| Kitchen one — the order board | OS non-blocking sockets (epoll) |
| Kitchen two — four chefs | The libuv thread pool |
| The waiter’s two pockets | Microtask queues (nextTick, promises) |
The rest of the post fills in that table, one row at a time. First the circuit, then the kitchens, then how the two connect.

1.1The circuit
The event loop
The event loop is a real while loop inside libuv. Each pass calls a fixed sequence of steps — six phases — and each phase drains one queue. Nothing mystical: “phase” means “this queue gets emptied now.”
Three facts carry the whole section.
- The circuit starts once. It begins after your top-level code finishes, and runs until the process exits. Nothing restarts it, and requests do not trigger it.
- Async work starts mid-step. Your code starts an async operation inside a callback, without the waiter stopping.
- The waiter does one thing at a time. A slow callback stalls every table, not just one.
The six phases
| # | Phase | What it is |
|---|---|---|
| 1 | Timers | Sticky notes — fire when due. Late is possible, early is not. |
| 2 | Pending callbacks | A small leftover tray — rare, housekeeping. |
| 3 | Idle / prepare | Internal to libuv. |
| 4 | Poll | The kitchen window — deliver finished I/O. The loop rests here when idle. |
| 5 | Check | The table right by the window — setImmediate callbacks. |
| 6 | Close callbacks | Clearing empty tables — socket close, cleanup. |
Here is the circuit as a diagram. Poll is highlighted because it is not just one stop among six. It is the resting spot: when there is nothing to do, the loop parks there instead of spinning.
The six phases, in order
Microtasks run between the steps
Microtasks are the exception to the circuit. They run between steps, not as one of the six phases. After every single callback finishes, before the waiter picks up the next one, two pockets get emptied completely: process.nextTick first, then the promise reaction queue. If emptying them adds more, those get emptied too, before anything else happens.
What blocks the loop
Anything that keeps the waiter busy for a long time blocks the loop. The usual suspects:
| Blocker | Why it sneaks in |
|---|---|
| Synchronous I/O | Fine at startup, deadly inside a request handler. |
| CPU-heavy JavaScript | Big loops, sorting, pure-JS image work. |
Large JSON.parse / JSON.stringify | Does not feel heavy. A 10 MB body is tens of milliseconds blocked. The most common blocker in API servers. |
| Catastrophic regex backtracking | Bad pattern + hostile input = seconds. Lives inside validation libraries. |
| Synchronous crypto | Hashing is designed to be slow. On the loop, one login blocks everyone. |
React server rendering (renderToString) | CPU-heavy JS on the loop. In a server that renders React, this is the biggest legitimate blocker. |
Watch the loop run
The diagram above is a map. In reality the loop never stops — it is always mid-circuit. The animation below starts on its own. Watch the single red dot — the one JavaScript thread — travel the circuit. Poll gets a visibly longer stop, because it is the resting phase. Use Step to freeze on exactly one phase at a time.
Animated
1.2The kitchens
libuv and the two kitchens
Every async operation in Node goes to one of two places. The question that decides which is simple: does the OS call return immediately and notify you later, or does it hold the calling thread until it is done?

When kitchen two runs out of chefs, the symptom is distinctive once you know it: event loop lag stays completely normal, but file, DNS, and compression latency spikes under load. It is not blocking — it is queueing behind four workers. That difference matters later, when reading a profile in Module 2 of this series: it shows up as off-CPU time (waiting), not on-CPU time (working).
Where the work goes
Watch it happen live
The animation below runs light traffic for the first few seconds, and both kitchens keep up easily. Then a burst: ten outbound dns.lookup calls fire at once. Kitchen one just lights up more bulbs on the board, with no slowdown at all. Kitchen two’s four chefs saturate, and a real queue forms and drains. Watch the live counters on each panel.
Animated
1.1 + 1.2Putting it together
Both halves, one system
These are not two unrelated diagrams. A request gets dispatched while some callback is executing, wherever the loop happens to be at that moment. The actual work then happens in a kitchen, entirely on its own clock, independent of the loop’s pace.

In the animation below, watch what happens after the burst. Kitchen two finishes cooking everything by around six seconds in — but poll already passed by then (it ran roughly 2.85 to 4.65 seconds into this lap). So the results just sit there, glowing, waiting. Poll does not come back around until about 9.65 seconds, and when it does, the entire backlog gets swept up and delivered at once.
Animated
Check your understanding
You do not need to write these down. Try each one in your head first, then open the answer to compare.
When does the event loop start, and what makes it stop?
It starts once, after your top-level code has finished running. Nothing restarts it, and incoming requests do not trigger it; they are just work it finds on the next lap. It stops when there is nothing left that could produce work: no pending timers, no open sockets or handles, and no outstanding thread-pool jobs. At that point the process exits on its own.
Which phase does the loop rest in when idle?
Poll. When every queue is empty, libuv asks the operating system to wait for I/O, and the thread sleeps using no CPU. The wait has a timeout equal to the time until the nearest timer is due, so the waiter wakes either when a bell rings or when a timer needs to fire. If a
setImmediatecallback is waiting, the timeout is zero and the loop does not rest at all.Why does
setImmediatebeatsetTimeout(0)inside an I/O callback?An I/O callback runs during the poll phase. The next stop on the circuit is check, which is where
setImmediatecallbacks run. The timers phase comes only after the loop finishes the lap and starts the next one. So from inside poll,setImmediateis always one stop away andsetTimeout(0)is most of a lap away. Outside an I/O callback, in the main module for example, the order between the two is not guaranteed.Describe microtask starvation in two sentences, without using the word “queue.”
Before the waiter takes a single step, they empty both pockets, and if emptying a pocket puts something new into it, they empty it again. A microtask that always schedules another microtask keeps refilling the pocket, so the waiter never takes that step, poll is never reached, and no I/O or timer is ever served while the CPU sits at 100%.
Name five blockers. Which one is most common in API servers?
Synchronous I/O inside a request handler, CPU-heavy JavaScript such as big loops or sorting, large
JSON.parseorJSON.stringifycalls, catastrophic regex backtracking, synchronous crypto such as hashing, and React server rendering. The most common in API servers is the large JSON work, because it never looks heavy in the code. A 10 MB body costs tens of milliseconds, and every request pays it.What is the one question that decides whether an operation goes to kitchen one or kitchen two?
Does the operating system call return immediately and notify you later, or does it hold the calling thread until the work is done? Sockets are the first kind, so network I/O goes to kitchen one, the order board, and costs no thread. File system calls,
dns.lookup, and most crypto and compression calls are the second kind, so libuv gives them to a worker in kitchen two, the thread pool.What is the default thread pool size, and name four things that share it.
Four threads. You can change it with the
UV_THREADPOOL_SIZEenvironment variable, but only before the process starts. The pool is shared by allfs.*calls,dns.lookup(which is the default DNS path for almost every library), the asynchronous crypto functions such aspbkdf2andrandomBytes, and zlib compression. Every concurrent request draws from the same four workers.Describe the thread-pool exhaustion signature. How does it differ from a blocked loop?
Event loop lag stays normal, but file, DNS, and compression latency spikes under load. The waiter is fine; the four chefs are all busy and new orders wait for a free one. In a profile this shows up as off-CPU time, meaning waiting. A blocked loop looks different: lag itself rises, and everything slows down at once, including network callbacks and timers that never touch the thread pool, because the waiter is stuck on one table.
Why is “just raise
UV_THREADPOOL_SIZEto 128” not automatically correct?Threads are not free. Each one holds a stack and adds scheduling work for the operating system, and 128 threads fighting over a few CPU cores or one disk can be slower than four. It also hides the real cause. If the pool is full of
dns.lookupcalls, the fix isdns.resolveor a cache, not more chefs. Raise it only after measuring that the pool is the bottleneck and that the underlying resource can absorb the extra parallel work.A kitchen finishes cooking a request at the 30% mark of a lap, but poll for that lap already happened at the 20% mark. When does the result actually get delivered?
At the poll phase of the next lap. Finished work is only collected when the waiter reaches the kitchen window, and the waiter has already passed it for this lap. So the plate sits ready for about 90% of a lap before anyone picks it up. This is why a busy loop delays I/O callbacks even when the actual work finished long ago, and it is what the last animation shows after the burst.
The bonus chapter stays with libuv and unpacks the DNS call teased earlier — dns.lookup versus dns.resolve, why almost every library defaults to the thread-pool path, and the fan-out trap in a backend-for-frontend that calls many upstream services. V8 — how the engine runs the JavaScript the waiter carries, and what that costs — comes after that.