Every JavaScript developer eventually hits a wall: callbacks fire in an unexpected order, a setTimeout with 0ms does not run immediately, and async/await behaves differently from what the syntax suggests. The root of all of it is one mechanism - the event loop.

What the event loop actually is

JavaScript is single-threaded. There is one call stack, one piece of code executing at any time. But Node.js handles thousands of concurrent connections, and browsers render smooth 60fps animations while fetching data - all with one thread. The event loop is what makes this possible.

At its core, the event loop is a continuous cycle. First, execute synchronous code on the call stack until it is empty. Second, check the microtask queue (Promise callbacks, queueMicrotask) and run all of them. Third, check the macrotask queue (setTimeout, setInterval, I/O callbacks) and run one of them. Then repeat.

That is the mental model. The real implementation - libuv in Node.js, the browser rendering engine - is more nuanced, but this captures 95% of the behavior you will encounter in production code.

Why microtasks always run first

This is the source of most event loop confusion. Consider this classic example:

console.log('1');\nsetTimeout(() => console.log('2'), 0);\nPromise.resolve().then(() => console.log('3'));\nconsole.log('4');\n// Output: 1, 4, 3, 2

The output is always 1, 4, 3, 2 - never 1, 4, 2, 3. Here is why: console.log 1 and 4 are synchronous and run immediately on the call stack. setTimeout schedules a macrotask. Promise.resolve().then() schedules a microtask. Once the call stack empties, the engine drains all microtasks first (printing 3), and only then picks one macrotask (printing 2).

The key insight: the microtask queue is fully drained between every macrotask. A microtask that enqueues another microtask will delay the next macrotask further. This is both powerful (you can guarantee ordering between related async operations) and dangerous (an infinite microtask loop starves I/O completely and freezes your server).

The Node.js event loop phases

Node.js does not just have one macrotask queue - it splits that side into distinct phases, each with its own queue and purpose:

Timers phase: Executes callbacks from setTimeout and setInterval whose delay threshold has elapsed. Note that timers are not precise - they guarantee a minimum delay, not an exact one.

Pending callbacks phase: Executes I/O callbacks that were deferred to the next loop iteration. These are edge cases like TCP errors.

Poll phase: This is where the event loop spends most of its time. It retrieves new I/O events from the operating system and executes their callbacks. If nothing is pending, it will wait here for new events rather than spinning.

Check phase: setImmediate callbacks run here. This phase exists specifically so that code can execute after the poll phase completes, which is useful when you want something to run after I/O but before timers.

Close callbacks phase: Cleanup handlers like socket.on('close') run here.

Between every phase transition, Node.js drains the entire microtask queue - process.nextTick callbacks first, then Promise callbacks. This is why process.nextTick always fires before a resolved Promise .then() callback, even though both are microtasks: nextTick has its own priority lane.

Real-world impact: why your API is slow

Consider this common pattern in a production server:

app.get('/report', (req, res) => {\n  const data = generateHeavyReport(); // 200ms of synchronous computation\n  res.json(data);\n});

During those 200ms of synchronous computation, the event loop is completely blocked. No other request can be processed. No timer fires. No I/O callback runs. For a server handling 100 concurrent users, 99 of them are stuck waiting in line behind one report generation. Your p99 latency just spiked to whatever that one slow computation takes.

The fix is not just slapping async on it - the async keyword does not magically parallelize CPU-bound work. When you await a CPU-intensive function, the computation still blocks the thread during execution. Async only helps when the underlying operation yields to the event loop (like network I/O or file reads).

The real solutions for CPU-intensive work:

Worker threads: Offload the computation to a separate thread that has its own V8 isolate and its own event loop. The main thread stays responsive while the worker crunches numbers.

Chunking: Break the computation into small pieces (under 1-2ms each) and yield back to the event loop between chunks using setImmediate. This lets I/O callbacks interleave with your computation.

Separate process: Move heavy computation to a dedicated microservice. Your web server only does I/O (which it is designed for), and the computation service can scale independently.

async/await does not escape the loop

A common misconception is that await somehow pauses the function and frees the thread for other work. That is half right: it does pause the current function. But what runs next is still governed by the event loop queue priorities.

async function main() {\n  console.log('A');\n  await Promise.resolve();\n  console.log('B'); // This is a microtask continuation\n}\nmain();\nsetTimeout(() => console.log('C'), 0);\nconsole.log('D');\n// Output: A, D, B, C

B beats C because the code after await is a microtask continuation, and microtasks always run before the next macrotask. The setTimeout was scheduled first, but it is a macrotask - it has to wait until all microtasks are done.

This matters in practice when you have multiple async functions racing against timer-based logic. The ordering is deterministic once you understand the queue priorities, but it can be surprising if you think of await as a simple "pause and resume later."

Detecting event loop lag in production

If your event loop is being blocked, your users feel it as latency spikes - requests that normally take 5ms suddenly take 500ms because they had to wait for a long synchronous operation to finish before the event loop could pick them up.

The simplest detection method is measuring the gap between expected and actual timer intervals. But for production monitoring, Node.js 12+ provides monitorEventLoopDelay() in the perf_hooks module, which gives you a histogram of event loop delays without adding measurable overhead itself.

Set alerting thresholds: a p99 event loop delay above 50ms usually means something is blocking the loop periodically. Above 100ms and your users are definitely feeling it.

The takeaway

The event loop is not a mystery and it is not magic - it is a priority queue with clear, deterministic rules. Synchronous code runs to completion. Microtasks drain fully between macrotasks. Node.js phases give you fine-grained control over execution ordering.

Once you internalize these rules, every surprising output becomes predictable. More importantly, every production performance problem becomes diagnosable: if latency is spiking, something is blocking the loop. Find it, chunk it, or move it off-thread.

The single most impactful thing you can do for your Node.js server throughput: never block the event loop for more than a few milliseconds. Measure it continuously, chunk long computations, and move CPU-intensive work off the main thread. The event loop is your server's heartbeat - keep it beating steadily.