Skip to content
Web Development

Node.js Event Loop, Explained Simply

Why a single-threaded runtime can handle thousands of connections, in what order callbacks run, and how to avoid blocking the loop.

Priya Mehta3 min read
JavaScript code logging sync, microtask and timer output beside a circular event loop diagram
On this page

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:

  1. The **process.nextTick queue**.
  2. The promise microtask queue (.then, await continuations).

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:

PhaseWhat runs
TimersCallbacks from setTimeout / setInterval whose time has passed
Pending callbacksSome deferred system callbacks
PollNew I/O events (network, files); may wait here for work
ChecksetImmediate callbacks
Close callbackse.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 *Sync calls 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.
  • nextTick and promise microtasks run before the loop continues.
  • Timers, I/O and setImmediate run 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.

Written by

Priya Mehta

Frontend developer · React & performance

Focuses on fast, accessible interfaces and the JavaScript fundamentals behind them. (Demo author profile.)

Comments

Join the discussion

Your email address will not be published. Be kind and stay on topic.