Node.js runs your JavaScript on a single main thread, yet it can serve many thousands of concurrent connections. The trick is the event loop: instead of waiting for slow operations, Node hands them off and continues with other work, returning to your code when results are ready.
A quick puzzle
What does this print?
console.log("1: sync");
setTimeout(() => console.log("4: timer"), 0);
Promise.resolve().then(() => console.log("3: microtask"));
process.nextTick(() => console.log("2b: nextTick"));
console.log("2: sync");
The output is:
1: sync
2: sync
2b: nextTick
3: microtask
4: timer
Understanding why is the whole point of this article.
The call stack runs synchronous code first
Node executes the current script from top to bottom. Synchronous code always finishes before any callback runs. setTimeout, the promise and nextTick only schedule work; the two console.log("…sync") calls run immediately.
Microtasks run before the loop moves on
When the stack is empty, Node drains two high-priority queues before continuing:
- The **
process.nextTickqueue**. - The promise microtask queue (
.then,awaitcontinuations).
That is why nextTick and the promise callback run before the timer, even though the timer’s delay is 0.
The phases of the event loop
After microtasks, the loop cycles through phases. Simplified:
| Phase | What runs |
|---|---|
| Timers | Callbacks from setTimeout / setInterval whose time has passed |
| Pending callbacks | Some deferred system callbacks |
| Poll | New I/O events (network, files); may wait here for work |
| Check | setImmediate callbacks |
| Close callbacks | e.g. socket close events |
Between each callback, Node drains the microtask queues again.
Where does the real work happen?
Network I/O is handled by the operating system’s non-blocking facilities. Some operations — certain file system calls, DNS lookups, compression and crypto — run on libuv’s thread pool behind the scenes. Your JavaScript stays on the main thread; only the results come back as callbacks.
Blocking the loop: the one thing to avoid
Because there is one main thread, a slow synchronous task freezes every request.
app.get("/report", (req, res) => {
// Bad: blocks the loop for every user while it runs
const data = fs.readFileSync("big-export.csv", "utf8");
const rows = data.split("\n").map(parseRow);
res.json(summarise(rows));
});
Better options:
import { createReadStream } from "node:fs";
import readline from "node:readline";
app.get("/report", async (req, res) => {
const rl = readline.createInterface({ input: createReadStream("big-export.csv") });
const summary = createSummary();
for await (const line of rl) {
summary.add(parseRow(line));
}
res.json(summary.result());
});
- Use async APIs (
fs/promises, streams) instead of*Synccalls in request handlers. - Move CPU-heavy work (image processing, large JSON transforms) to worker threads or a background job queue.
- Avoid huge synchronous loops; process data in chunks.
- Watch for accidental blocking: complex regular expressions on untrusted input can hang the loop.
async/await does not mean parallel
A common misunderstanding is that await runs code in parallel. It does not — it pauses the current function until a promise settles, letting the event loop do other work meanwhile. Sequential awaits run one after another:
// Sequential: total time ≈ a + b
const user = await getUser(id);
const orders = await getOrders(id);
// Concurrent: total time ≈ max(a, b)
const [user2, orders2] = await Promise.all([getUser(id), getOrders(id)]);
Use Promise.all when requests do not depend on each other, and Promise.allSettled when you want every result even if some fail. For large batches, limit concurrency so you do not overwhelm a database or a third-party API.
Measuring event loop health
Node exposes event loop delay through perf_hooks.monitorEventLoopDelay(). If the delay climbs under load, something is blocking. Many APM tools chart this metric for you, and it is one of the best early warnings for a struggling Node service.
Summary
- Synchronous code runs first, always.
nextTickand promise microtasks run before the loop continues.- Timers, I/O and
setImmediaterun in their phases. - Never block the main thread in request handlers.
Once this model clicks, many “random” ordering bugs and latency spikes in Node applications stop being mysterious.
Comments