Event Loop & Async
What is the Event Loop?
Section titled “What is the Event Loop?”JavaScript in Node.js runs on a single thread. Yet it can handle thousands of simultaneous connections without blocking. The secret is the event loop — a mechanism that delegates slow work (file reads, network calls) to the operating system, then processes the results when they arrive.
At its core, the runtime has three key structures:
- Call stack — where synchronous code executes, one frame at a time.
- Microtask queue — holds Promise callbacks and
queueMicrotaskcallbacks. Drains completely after every task. - Macrotask queue (task queue) — holds
setTimeout/setIntervalcallbacks, grouped into phases. Node runs a whole phase per event loop iteration (for example, every timer that has expired) — not just one macrotask.
The loop runs continuously: run a phase’s callbacks one at a time, and after every single callback drain process.nextTick then the microtask queue — before moving on to the next callback or the next phase.
What this module covers
Section titled “What this module covers”| Lesson | Topic |
|---|---|
| Call Stack | Synchronous execution and why blocking matters |
| Callbacks | The original async pattern and Node’s error-first convention |
| Promises | States, chaining, and combinators |
| Async/Await | Reading async code like synchronous code |
| Timers | setTimeout, setInterval, and the timers/promises API |
| Microtasks & Macrotasks | Queue priorities and drain order |
| libuv & the Threadpool | What actually runs where, underneath the JavaScript |
A first async demo
Section titled “A first async demo”The snippet below runs three pieces of code. Notice they do not execute top-to-bottom in the order written — the synchronous log fires first, the Promise callback fires next (microtask), and the setTimeout callback fires last (macrotask).
// What runs first?console.log('A — sync');Promise.resolve().then(() => console.log('B — microtask (Promise)'));setTimeout(() => console.log('C — macrotask (setTimeout)'), 0);Run it to confirm the order:
The output is always A, then B, then C — the event loop guarantees this ordering.
What runs where
Section titled “What runs where”“Async” does not mean “runs on another thread.” Different async operations are handled by completely different mechanisms, and knowing which one you are looking at explains a lot of surprising benchmarks later in this module:
| Operation | Where it actually runs |
|---|---|
Promise, async/await, queueMicrotask | JS microtask queue — still the one JS thread |
setTimeout, setImmediate, network sockets | libuv event loop, backed by the OS kernel’s async I/O (epoll/kqueue/IOCP) — no extra thread |
fs.readFile, crypto.pbkdf2/scrypt/randomBytes, dns.lookup, zlib | libuv’s fixed-size threadpool (4 threads by default) |
A synchronous loop, JSON.parse on a huge string, fs.readFileSync | The call stack — blocks everything, including the loop itself |
flowchart LR Code["Your JS code"] --> Stack["Call stack (one thread)"] Stack -->|Promise / await| Micro["Microtask queue"] Stack -->|fs, crypto, dns.lookup, zlib| Pool["libuv threadpool (4 threads)"] Stack -->|sockets, timers, signals| Kernel["OS kernel async I/O"] Pool --> Loop["Event loop"] Kernel --> Loop Micro --> Loop Loop --> Stack
The libuv & the Threadpool lesson at the end of this module measures the threadpool with real numbers and walks through libuv’s six phases in detail.
Mental Model
Section titled “Mental Model”Think of the event loop as a single worker (the JS thread) with two inboxes it checks between every task: a microtask inbox (always emptied completely, first) and a macrotask inbox (one phase’s worth of items per pass). Work that the worker itself cannot do without blocking — reading a file, hashing a password, resolving a DNS name — gets handed off to either a small pool of helper threads (libuv’s threadpool) or directly to the OS kernel (sockets), and comes back as a queued callback when it is done.
Trade-offs
Section titled “Trade-offs”A single JS thread means no data races in your own code — you never need locks around shared JavaScript state. The cost is that any synchronous CPU-bound work (a big loop, JSON.parse on megabytes of text, synchronous crypto) blocks every other request in the process. Node is a poor fit for CPU-heavy work unless you move that work off the main thread (worker_threads, a queue, a separate service) — it excels at I/O-heavy work with many concurrent, mostly-idle connections.
Common Misconceptions
Section titled “Common Misconceptions”- “Node is single-threaded, so it can’t handle concurrent I/O.” Wrong — the single thread is only for your JavaScript. The kernel and libuv’s threadpool do the actual waiting concurrently; your JS thread just gets notified when each one finishes.
- “Async means it runs on another CPU core.” Not for
Promise/await— those never leave the JS thread. Only threadpool and kernel-backed operations use anything other than the JS thread. - “One event loop iteration processes one macrotask.” Node processes an entire phase’s due callbacks per iteration (see the Microtasks & Macrotasks lesson for a runnable proof).
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Node.js snippets often label a function async and assume that alone makes any code inside it non-blocking. It does not — async only changes what the function returns (a Promise); the synchronous statements inside it still run on the call stack and still block everything else until they finish.
🐛 Spot the bug
async function computeReport(rows) {// "It's async, so this won't block other requests" — comment left by the AIlet total = 0;for (let i = 0; i < rows.length; i++) { total += heavySyncTransform(rows[i]); // synchronous, CPU-bound, no await anywhere}return total;}Show the answer
Marking a function async does not move its body off the call stack. heavySyncTransform runs synchronously for every row with no await in sight, so the entire loop blocks the event loop — every other request, timer, and I/O callback waits until it finishes. async only means “this function returns a Promise”; it says nothing about how the code inside runs. The fix is to actually yield (chunk the work with setImmediate/await between batches) or move it to a worker_thread.