Async patterns, timers and the event loop

Promises and async/await discipline, the timer functions, scheduling order between nextTick, microtasks and setImmediate, and cancellation.

What the loop actually runs

The event loop is a sequence of phases. Each phase runs its callbacks, then the microtask queue drains completely before the next phase begins. Blocking a phase blocks everything.

PhaseWhat runs thereReach for it when
timerssetTimeout and setInterval callbacksYou need a delay, not a precise schedule
pending callbacksDeferred I/O errors from the previous turnRarely visible in application code
pollMost I/O callbacks — the loop spends its time hereAnything you do with files, sockets or databases
checksetImmediate callbacksYou want to run right after the current poll phase
close callbacksSocket and handle close eventsCleanup bookkeeping
console.log("1 sync");

setTimeout(() => console.log("5 timeout"), 0);
setImmediate(() => console.log("6 immediate"));
queueMicrotask(() => console.log("3 microtask"));
process.nextTick(() => console.log("2 nextTick"));
Promise.resolve().then(() => console.log("4 promise"));

// sync, then nextTick, then promise/microtask,
// then timeout and immediate — which of the last two first is not guaranteed
// at the top level, but inside an I/O callback setImmediate always wins.

Think in microtasks and macrotasks rather than in milliseconds. Promise callbacks and queueMicrotask are microtasks; timers, I/O and setImmediate are macrotasks. A microtask that schedules another microtask can starve the loop entirely, because the microtask queue always drains completely before the next phase.

Timers and async discipline

import { setTimeout as sleep } from "node:timers/promises";

const id = setTimeout(() => console.log("late"), 5000);
clearTimeout(id);

// unref() lets the process exit even though the timer is pending
setTimeout(() => console.log("breathe"), 100).unref();

const started = performance.now();
await sleep(250);                     // awaitable, no callback pyramid
console.log(performance.now() - started);
  • A timer fires no earlier than the delay and later if the loop is busy, so never use setTimeout to measure elapsed time — use performance.now().
  • Sequential await sleep calls guarantee a minimum gap, not a steady rate; drift accumulates over many iterations.
  • node:timers/promises gives you sleep, setInterval and scheduler.wait that all accept an AbortSignal.
  • Unhandled promises inside a timer callback are floating promises — nothing reports them unless you handle rejections globally.

Cancellation and failures

const controller = new AbortController();
const { signal } = controller;

await fetch(url, { signal });
await sleep(1000, { signal });

controller.abort(new Error("deadline exceeded"));  // the reason is preserved

process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection", reason);
  process.exitCode = 1;
});
  • Since Node 15 an unhandled rejection terminates the process. Add a handler only to log and exit deliberately, never to swallow the failure.
  • Pass the same AbortSignal down through every layer of a request; a cancelled parent that leaves children running leaks sockets and timers.
  • Promise.all rejects on the first failure but does not cancel the rest, so sibling work keeps running — use allSettled when every result matters.
  • Always await or return a promise. A floating promise loses both its result and its stack context.
⚠️
An async function that fails inside Array.forEach or a bare event listener produces a rejection nobody sees until the process dies. Use for...of with await, or wrap the listener body in a try/catch that reports.

FAQ

When should I use process.nextTick instead of setImmediate?
Almost never in application code. nextTick runs before promise callbacks and can starve I/O if used recursively; setImmediate yields to the poll phase and is the safer way to defer work.
How do I run many async operations without opening thousands of sockets?
Bound the concurrency. Run a fixed number of workers pulling from a shared queue, or use a small pool helper, so in-flight requests stay near what the downstream service can handle.

Events, streams and buffers Modules, ESM and CommonJS

Last refreshed 2026-09-18.