Skip to content

Event Loop & Async

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 queueMicrotask callbacks. Drains completely after every task.
  • Macrotask queue (task queue) — holds setTimeout/setInterval callbacks, 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.

LessonTopic
Call StackSynchronous execution and why blocking matters
CallbacksThe original async pattern and Node’s error-first convention
PromisesStates, chaining, and combinators
Async/AwaitReading async code like synchronous code
TimerssetTimeout, setInterval, and the timers/promises API
Microtasks & MacrotasksQueue priorities and drain order
libuv & the ThreadpoolWhat actually runs where, underneath the JavaScript

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:

JavaScript

The output is always A, then B, then C — the event loop guarantees this ordering.

“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:

OperationWhere it actually runs
Promise, async/await, queueMicrotaskJS microtask queue — still the one JS thread
setTimeout, setImmediate, network socketslibuv 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, zliblibuv’s fixed-size threadpool (4 threads by default)
A synchronous loop, JSON.parse on a huge string, fs.readFileSyncThe 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
Where an async call actually goes

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.

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.

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.

  • “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).

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 AI
let 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.

In what order does the event loop process queues after executing a task?
Which of the following is placed in the microtask queue?
Which of these never leaves the single JS thread, even though it looks async?
What does this print?
async function work() {
  for (let i = 0; i < 3e8; i++) {} // no await
  console.log('done');
}
console.log('before');
work();
console.log('after');
What is wrong with this reasoning: "I put my file hashing inside an async function, so it will not block other requests"?