Jul 28, 2026

Node.js Event Loop: how I think about it in practice

The event loop matters not only for interviews, but for understanding latency, blocking code, queues, timers, Promise microtasks and backend behavior under load.

The event loop is often explained with diagrams: circles, arrows and phases. Those diagrams help, but for a long time I felt that I understood the picture without fully understanding the consequences.

In practice, the event loop matters not because it helps answer interview questions. It matters because it explains why a backend can “freeze”, why setTimeout(..., 0) does not run immediately, why Promise callbacks can delay timers, why CPU-heavy code hurts Node.js responsiveness and why a task queue is not magic parallelism.

Node.js Does Not Make JavaScript Parallel

JavaScript code in Node.js runs on one main thread. If that thread is busy with long synchronous work, the event loop cannot move to the next task.

Simple example:

setTimeout(() => {
  console.log('timer');
}, 0);

const startedAt = Date.now();

while (Date.now() - startedAt < 3000) {
  // blocking work
}

console.log('sync finished');

timer will not run after 0 milliseconds. It will run only after the call stack is free. That is the key idea: the event loop does not interrupt current JavaScript execution. It waits until synchronous work finishes.

What the Event Loop Does

The event loop coordinates callbacks from different sources:

  • timers: setTimeout, setInterval;
  • I/O callbacks: network, filesystem, sockets;
  • poll: waiting for new I/O events;
  • check: setImmediate;
  • close callbacks: closed sockets and resources;
  • microtasks: Promise.then, queueMicrotask;
  • process.nextTick, which has special priority in Node.js.

A rough model is this: synchronous code runs first, then Node.js drains microtasks, then moves through event loop phases and takes callbacks from the corresponding queues.

Microtasks Can Be More Dangerous Than They Look

Promise callbacks are often understood as “almost immediately after current code”. That is true, but it has a cost: a long microtask chain can delay timers and I/O.

function spin(count: number) {
  if (count === 0) return;
  Promise.resolve().then(() => spin(count - 1));
}

setTimeout(() => console.log('timer'), 0);
spin(100_000);

The timer waits until the microtask queue is empty. In a real backend this may be less obvious: many Promise chains, large serialization work, synchronous callbacks after await - and latency starts growing.

process.nextTick Is Even More Sensitive

In Node.js, process.nextTick runs before normal Promise microtasks. It is useful for some low-level APIs, but overusing it can starve the event loop.

function loop() {
  process.nextTick(loop);
}

loop();

This code prevents the event loop from moving to I/O properly. In application code, I avoid process.nextTick unless there is a very clear reason.

setTimeout(0) and setImmediate

A classic question: which runs first, setTimeout(..., 0) or setImmediate?

The answer depends on context. At the top level the order can be non-obvious. Inside an I/O callback, setImmediate usually runs before the timer because it lands in the check phase after poll.

For application development, memorizing “which is first” is less important than understanding intent:

  • setTimeout means run no earlier than the given delay;
  • setImmediate means defer execution to the check phase;
  • queueMicrotask means run after the current call stack before moving to new macrotasks.

The Main Enemy Is Blocking Code

Node.js is great for I/O-heavy workloads: APIs, database calls, network calls, queues, file operations and real-time features. But if a request handler contains heavy synchronous work, the whole process becomes worse at responding to other requests.

Typical problems:

  • large JSON parse/stringify;
  • synchronous cryptography;
  • generating large documents on the main thread;
  • image processing;
  • complex loops over large arrays;
  • regular expressions with catastrophic backtracking;
  • synchronous filesystem operations.

If a task is CPU-heavy, it is worth considering worker threads, a separate service, a queue or moving the work out of the user-facing request.

How I Apply This in Backend Work

When I design an endpoint, I ask:

  • does it contain heavy synchronous work;
  • can I return earlier and move processing to a job;
  • can a Promise chain become too long;
  • can I stream data instead of building the whole result in memory;
  • what happens to latency if many requests hit this endpoint at once;
  • do I have enough observability to notice event loop lag.

The event loop is not an abstract JavaScript topic. It is part of backend runtime behavior.

Takeaway

Understanding the event loop means understanding the difference between asynchronous and parallel. await does not make CPU-bound code safe. A Promise does not remove blocking. A timer does not guarantee execution exactly after the given delay.

For a backend engineer, this is practical knowledge: it helps design queues, avoid blocking request handlers, explain latency and choose the right place for heavy work.

Comments

No comments yet — be the first.