Node.js From Zero

The Event Loop: Timers, Promises and the Poll Phase

The loop phases, microtasks versus macrotasks, process.nextTick, setImmediate, and how a blocked loop freezes everything. Traced and run.

Lesson 0116 min read#nodejs#event-loop#async

On this page

  1. Words used in this lesson
  2. The mental model in four lines
  3. The phases of the loop
  4. Puzzle 1: the five ways to say "later"
  5. Microtasks versus macrotasks
  6. Timers: the delay is a minimum
  7. setTimeout(0) versus setImmediate
  8. What keeps the process alive?
  9. Blocking the loop: measure it
  10. Starvation: nextTick and promises can be worse than a loop
  11. Errors in callbacks do not travel up
  12. What the loop does not do
  13. Try it
  14. Interview questions
  15. Key takeaways
  16. What's next

In one line: the event loop is the loop that decides which of your callbacks runs next. It runs one callback at a time to the end, then picks the next one. Promise callbacks and process.nextTick jump the queue and run as soon as the current callback finishes. Because of this, one slow callback freezes every timer and every request, and you can measure it.

In lesson 0 we said that Node runs your JavaScript on one thread and hands the waiting to the operating system. Now we look at the machine that makes it work. We will not memorise a diagram. We will write small puzzles, run each one, paste the real output, and explain why. By the end you will be able to predict the order of setTimeout, setImmediate, promises and process.nextTick, and you will have seen a blocked loop with your own eyes.

If the idea of a call stack and a callback queue is new to you, read Async JS and the Event Loop first (that lesson is about the browser's loop, which is similar). For promises see Promises and async/await From the Ground Up. This lesson is about the Node version of the loop.

What I ran / checked. Everything ran on Linux (4 cores) with Node.js v22.22.0 (libuv 1.51.0). Output blocks are real. Timing numbers change on every machine and every run, and I say so where it matters. Sources I read (told here in my own words): the official Node.js learn guide "The Node.js Event Loop" (rendered page, nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick), the libuv "Design overview" page (the exact steps of one loop iteration), the Node.js API docs for timers and process (raw markdown from the nodejs/node repository), and the goldbergyoni/nodebestpractices README (its rule about not blocking the event loop). Could not read: the raw markdown source of the learn guide returned 404, so I read the rendered page. The explanation of why ES modules run promise callbacks before process.nextTick is my own reading of the behaviour I measured, not a quote from the docs, so I flag it below. I did not verify GitHub star counts, so I give none.

Words used in this lesson

  • Call stack is where JavaScript keeps track of the function it is running now. Only one function runs at a time.
  • Callback is a function that Node will call later, when something is ready.
  • Task queue (a phase queue) is a waiting line of callbacks. Each phase of the loop has its own line.
  • Phase is one stop in the loop. Timers, poll and check are phases. Each phase runs the callbacks in its own line.
  • Macrotask is the informal name for a callback that comes from a phase queue (a timer, an I/O callback, a setImmediate).
  • Microtask is a smaller job that runs right after the current callback ends and before the loop moves on. Promise callbacks (.then, and the part of an async function after await) and queueMicrotask callbacks are microtasks.
  • process.nextTick is a Node-only way to say "run this right after the current operation, before even microtasks (in CommonJS)". It has its own queue.
  • Timer is a callback scheduled with setTimeout or setInterval. The delay you give is a minimum, not an exact time.
  • setImmediate schedules a callback for the check phase, which runs right after the poll phase.
  • Poll phase is where Node waits for and runs I/O callbacks (file reads finishing, data arriving on a socket).
  • Starvation means one kind of work never lets other kinds run.
  • Event loop lag (or delay) is how late the loop runs something compared to when it should have.
  • unref tells Node "do not keep the process alive just for this timer".

The mental model in four lines

Before any diagram, hold this short model:

  1. Node runs your main script from top to bottom. That is the first "turn".
  2. Whenever your code starts something that takes time (a timer, a file read, a network request), Node hands it off and keeps going.
  3. When a job is done, its callback goes into a waiting line. The loop takes callbacks from the lines one at a time and runs each one to the end. A running callback is never interrupted.
  4. After each callback ends, Node first empties the small "do it right now" lines (process.nextTick and promises), and only then takes the next callback from the phase lines.

Everything in this lesson follows from these four lines. Rule 3 is the reason a slow callback freezes everything. Rule 4 is the reason promises run before timers.

The phases of the loop

The Node.js learn guide and the libuv design page show the same ring of phases. This is the ring in plain words, in the order the loop visits them:

TEXT
        +-------------------------------+   +--> | timers                        |  setTimeout / setInterval callbacks that are due   |    +---------------+---------------+   |    | pending callbacks             |  a few system errors deferred from before   |    +---------------+---------------+   |    | idle, prepare                 |  internal use only   |    +---------------+---------------+   |    | poll                          |  wait for I/O, run I/O callbacks   |    +---------------+---------------+   |    | check                         |  setImmediate callbacks   |    +---------------+---------------+   |    | close callbacks               |  e.g. socket.on('close') after a destroy   +----+-------------------------------+

Points taken from the official guide and the libuv page:

  • Each phase has a first-in, first-out queue of callbacks. When the loop enters a phase, it runs callbacks from that queue until the queue is empty or a maximum number of callbacks has run. Then it moves on.
  • The poll phase does two jobs: work out how long it may wait for I/O, and run the I/O callbacks. If there is nothing to do and nothing is scheduled, Node blocks here and sleeps until something happens. This is how an idle server uses no CPU.
  • If a setImmediate callback is waiting when the poll queue is empty, poll does not wait. It goes straight to check.
  • The guide says that starting with libuv 1.45.0 (which arrived with Node 20), timers run after the poll phase in each loop iteration. In older versions they ran before. libuv still runs the timers once before entering the loop, for backward compatibility. The guide says this can change the ordering of setImmediate and timers in some cases. Keep that sentence in mind for the "timeout versus immediate" puzzle below.
  • A timer's delay is the time after which the callback may run, not the time it will run. The OS or other callbacks can delay it.
  • The loop ends when nothing is left to wait for: no active timers, no open sockets, no pending requests. Then the process exits.

The libuv page describes a loop as "alive" if it has active handles or requests. A server that is listening is a handle. A running setInterval is a handle. That is why a program with those does not exit.

Puzzle 1: the five ways to say "later"

Here is a small CommonJS script (a .cjs file). Before you run it, try to guess the order.

JavaScript
console.log("A sync start");setTimeout(() => console.log("E setTimeout 0"), 0);setImmediate(() => console.log("F setImmediate"));Promise.resolve().then(() => console.log("C promise.then"));process.nextTick(() => console.log("B nextTick"));queueMicrotask(() => console.log("D queueMicrotask"));console.log("A2 sync end");
TEXT
$ node p1b.cjsA sync startA2 sync endB nextTickC promise.thenD queueMicrotaskF setImmediateE setTimeout 0

I labelled the lines A to F in the order I expected them. Let us read the result:

  1. The two sync lines come first. The main script runs to its end before anything "later" runs. That is rule 1 and rule 3.
  2. nextTick runs before the promise. After the main script (one callback) ends, Node empties the nextTick queue first.
  3. Then the microtask queue: the promise callback and the queueMicrotask callback, in the order they were queued.
  4. Then the loop proper starts. Here the timers phase and the check phase compete. In this run F setImmediate came before E setTimeout 0. That is not guaranteed, and I explain it in a later puzzle.

I ran this script three times. Twice the order of the last two lines was E then F, and once it was F then E. That tells us something important: never write code that depends on setTimeout(0) versus setImmediate order from the main script.

The same file as an ES module behaves differently

I copied the same code into p1b.mjs (an ES module) and ran it:

TEXT
$ node p1b.mjsA sync startA2 sync endC promise.thenD queueMicrotaskB nextTickF setImmediateE setTimeout 0

Now the promise and queueMicrotask ran before nextTick. This surprises many people. The reason, as I understand it: in an ES module, Node evaluates the module as part of a promise job, so by the time your main code finishes, Node is already draining the microtask queue. Microtasks run to completion first, and only after that does Node look at the nextTick queue. In CommonJS the main script is plain code, so after it ends Node drains the nextTick queue first and then the microtasks. This explanation is my reading of the measured behaviour. The practical lesson is certain: the order of nextTick versus promises depends on whether the code is CommonJS or ESM. If you write only ESM and avoid nextTick, the question disappears.

Microtasks versus macrotasks

Two kinds of "later" exist:

KindExamplesWhen it runs
Microtask.then / .catch / .finally callbacks, code after await, queueMicrotaskRight after the current callback ends, before the loop moves on. The whole microtask queue is emptied, including new microtasks added meanwhile.
MacrotasksetTimeout, setInterval, setImmediate, I/O callbacksWhen the loop reaches the phase that owns them. One phase queue at a time.
process.nextTickNode onlyIts own queue. Emptied right after the current operation, with the exact order versus microtasks depending on CJS or ESM, as seen above.

The practical result: if you chain many promises, they all run before any timer, even a timer that is already overdue.

Puzzle 2: async and await

JavaScript
async function a() { console.log("a start"); await b(); console.log("a after await b"); }async function b() { console.log("b runs sync"); }setTimeout(() => console.log("timeout"), 0);a().then(() => console.log("a resolved"));Promise.resolve().then(() => console.log("p1")).then(() => console.log("p2"));process.nextTick(() => console.log("tick"));console.log("script end");

I ran it as CommonJS and as an ES module:

TEXT
CommonJS:                       ES module:a start                         a startb runs sync                     b runs syncscript end                      script endtick                            a after await ba after await b                 p1p1                              a resolveda resolved                      p2p2                              ticktimeout                         timeout

Read the left column slowly:

  • a start and b runs sync print immediately, before script end. An async function runs like a normal function until its first await. Only the part after await is delayed. b() is called inside a(), so b runs sync appears at once too. I confirmed this with a separate script: calling an async function runs its first part synchronously and returns a promise.
  • await b() waits for b's promise. The rest of a is a microtask, queued first.
  • Promise.resolve().then(p1) queues p1 second.
  • When a's continuation runs, a finishes, which makes the promise returned by a() resolve. That queues the a resolved callback behind p1. And p1 finishing queues p2 behind a resolved. So the interleaving is a after await b, p1, a resolved, p2.
  • timeout is last in both columns. The timer is a macrotask. It waits for the loop.

You do not need to memorise such chains. You need the rule: microtasks are processed in the order they are queued, and a microtask can queue more microtasks, which run in the same pass.

Puzzle 3: callbacks queued from inside other callbacks

JavaScript
process.nextTick(() => {  console.log("tick 1");  Promise.resolve().then(() => console.log("promise inside tick"));  process.nextTick(() => console.log("tick 2 (queued by tick 1)"));});Promise.resolve().then(() => {  console.log("promise 1");  process.nextTick(() => console.log("tick queued by promise"));  Promise.resolve().then(() => console.log("promise 2"));});
TEXT
CommonJS:                          ES module:tick 1                             promise 1tick 2 (queued by tick 1)          promise 2promise 1                          tick 1promise inside tick                tick queued by promisepromise 2                          tick 2 (queued by tick 1)tick queued by promise             promise inside tick

In CommonJS the nextTick queue is emptied fully (including tick 2, which was added while it was draining) before the microtask queue gets a turn. Then the microtask queue is emptied fully, including promise inside tick, which was added by a tick. A tick queued during microtasks (tick queued by promise) waits until the microtask queue is empty. The ES module column shows the same two queues with the other one first. Two queues, each emptied completely, one after the other, round and round, until both are empty.

Do not use process.nextTick in new application code. The Node docs and the learn guide describe it as something that runs before the loop continues, and say it can starve I/O if you keep adding to it. Use queueMicrotask or a promise when you want "right after this", and setImmediate when you want "after I/O has had a chance". The guide's main use of nextTick is inside libraries, for example to make sure an event is emitted after the caller has had time to attach a listener.

Timers: the delay is a minimum

A timer says "run this callback no sooner than N milliseconds from now". A few facts, each tested:

JavaScript
setTimeout(() => console.log("timeout 1"), 1);setTimeout(() => console.log("timeout 0"), 0);setTimeout(() => console.log("timeout 2"), 2);setTimeout(() => console.log("timeout 1 (second)"), 1);
TEXT
timeout 1timeout 0timeout 1 (second)timeout 2

setTimeout(fn, 0) ran after setTimeout(fn, 1) that was written before it. The reason is that Node treats a delay of 0 as 1 millisecond, so those two timers are equal and run in the order they were created. (The timers documentation says the same: a delay smaller than 1 is set to 1.) The timer with 2 ms came last.

Timers are checked when the loop reaches the timers phase. If the loop is busy, they are late. The next section shows by how much.

setTimeout(0) versus setImmediate

This is the most famous Node puzzle. Run both in the main script:

JavaScript
setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));

I ran it 20 times and counted which line came first:

TEXT
     18 immediate      2 timeout

Mostly immediate first on my machine, but twice timeout was first. In an earlier batch of 10 runs I saw the split 8 to 2. The result depends on how long the process took to start the loop. If more than 1 ms has passed by the time the loop begins, the timer is due and runs first. If not, the check phase wins. It is non-deterministic from the main script.

Now put both inside an I/O callback:

JavaScript
import { readFile } from "node:fs";readFile(import.meta.filename, () => {  setTimeout(() => console.log("timeout"), 0);  setImmediate(() => console.log("immediate"));});
TEXT
$ for i in 1 2 3 4 5; do node p2.mjs | tr '\n' ' '; echo; doneimmediate timeoutimmediate timeoutimmediate timeoutimmediate timeoutimmediate timeout

From inside an I/O callback, setImmediate always wins. The reason is the phase order: the I/O callback runs in the poll phase, and the check phase comes right after poll. The timer must wait for the loop to go around to the timers phase again. That is the main reason setImmediate exists: "run this once I/O callbacks are done, but without waiting for a timer".

One more trace with all the phases

This script uses a file read and a server close, and prints in the order things happen:

JavaScript
const fs = require("node:fs");const net = require("node:net");console.log("main script");fs.readFile(__filename, () => {  console.log("readFile callback (poll phase)");  setTimeout(() => console.log("timeout from inside I/O (timers phase, next loop)"), 0);  setImmediate(() => console.log("immediate from inside I/O (check phase, this loop)"));  process.nextTick(() => console.log("nextTick from inside I/O"));  Promise.resolve().then(() => console.log("promise from inside I/O"));});const server = net.createServer().listen(0, () => {  server.close(() => console.log("net server close callback"));});
TEXT
main scriptnet server close callbackreadFile callback (poll phase)nextTick from inside I/Opromise from inside I/Oimmediate from inside I/O (check phase, this loop)timeout from inside I/O (timers phase, next loop)

Reading it: main script first. The server close happened before the file read finished (opening a port is faster than a file read through the thread pool here, but the order of two unrelated I/O events is not promised, and I would not rely on it). Inside the file callback the order is nextTick, then the promise, then setImmediate (check phase, same loop), then the timer (next loop).

What keeps the process alive?

A program ends when the loop has nothing left to wait for. We can watch it:

JavaScript
const t = setInterval(() => console.log("tick"), 100);setTimeout(() => { console.log("unref the interval"); t.unref(); }, 350);process.on("exit", () => console.log("exit: nothing left to wait for"));
TEXT
tickticktickunref the intervalexit: nothing left to wait for

An active setInterval keeps Node alive forever. After t.unref(), the interval no longer counts as "something to wait for", so Node exited. unref is useful for background jobs (a cache cleanup timer, for example) that should not stop the program from ending. ref() undoes it.

Blocking the loop: measure it

Now the main event. Remember rule 3: a running callback is never interrupted. What if one callback takes a long time?

JavaScript
const t0 = performance.now();const at = () => Math.round(performance.now() - t0);function busy(ms) { const end = performance.now() + ms; while (performance.now() < end) {} }setTimeout(() => console.log(`timer A: asked for 100 ms, ran at ${at()} ms`), 100);setTimeout(() => console.log(`timer B: asked for 500 ms, ran at ${at()} ms`), 500);setTimeout(() => {  console.log(`blocking for 1000 ms, starting at ${at()} ms`);  busy(1000);  console.log(`finished blocking at ${at()} ms`);}, 250);
TEXT
timer A: asked for 100 ms, ran at 101 msblocking for 1000 ms, starting at 251 msfinished blocking at 1251 mstimer B: asked for 500 ms, ran at 1252 ms

Timer B was due at 500 ms. It ran at 1252 ms, about 750 ms late. During the one-second busy loop, the thread was the only worker and it was busy. The loop could not look at the timer line until the callback finished. That is what "blocking the event loop" means. busy is a stand-in for any long synchronous work: a huge loop, JSON.parse of a giant string, a very slow regular expression, readFileSync of a large file, a sync hash.

What it looks like in a server

The same thing, with a real HTTP server. One route does 2 seconds of synchronous work. The other returns at once.

JavaScript
import http from "node:http";const server = http.createServer((req, res) => {  if (req.url === "/slow") { const e = Date.now() + 2000; while (Date.now() < e) {} return res.end("slow done\n"); }  res.end("fast\n");});server.listen(3101);
TEXT
fast alone: 0.005474sfast while /slow is running: 1.802005s

I called / alone (5 ms). Then I started /slow and 200 ms later called /. The fast route took 1.8 seconds, because the fast request had to wait for the slow one to finish. Remember: the problem is not that /slow is slow. It is that /slow made every other user wait. On a real server, one such request means all users see a frozen site.

Measuring lag properly: monitorEventLoopDelay

You can measure this in production with the perf_hooks module. It samples how late a timer fires:

JavaScript
import { monitorEventLoopDelay } from "node:perf_hooks";const h = monitorEventLoopDelay({ resolution: 10 });h.enable();const busy = (ms) => { const e = performance.now() + ms; while (performance.now() < e) {} };setTimeout(() => busy(300), 100);setTimeout(() => {  h.disable();  console.log("max lag:", (h.max / 1e6).toFixed(0), "ms   median:", (h.percentile(50) / 1e6).toFixed(0), "ms");}, 800);
TEXT
max lag: 309 ms   median: 10 ms

The values are in nanoseconds, so I divided by a million. The median is 10 ms, which is just the sampling resolution I asked for. The max of 309 ms is the 300 ms block I created. A rising max or p99 in a dashboard is the first sign that something is blocking the loop. The nodebestpractices guide lists event loop lag among the Node-specific metrics worth monitoring.

Blocking is silent. Nothing is logged, there is no error, and CPU usage may even look normal. The only sign is that everything gets slow together. Watch event loop lag, and watch for requests where the fast routes get slow.

Fix 1: break the work into chunks

If you must do a big loop in the main thread, do a little, then let the loop breathe, then do a little more. The easy way is to await a setImmediate promise every N steps:

JavaScript
const busyWork = (i) => { let x = 0; for (let k = 0; k < 20000; k++) x += Math.sqrt(k + i); return x; };const N = 40000;// heartbeat timer measures how late it runs: lateness = (time since last beat) - 10for (let i = 0; i < N; i++) {  busyWork(i);  if (i % 500 === 0) await new Promise(r => setImmediate(r));   // let the loop run other callbacks}

I ran this loop twice with a 10 ms heartbeat timer watching: once straight through, once with the chunking line.

TEXT
all at once: took 1597 ms, worst heartbeat delay 1587 mschunked:     took 1601 ms, worst heartbeat delay 16 ms

The total time is the same (about 1.6 seconds). But in the first version nothing else could run for 1.6 seconds, and in the chunked version the heartbeat was never more than 16 ms late. The work is not faster, it is fair: other requests get a turn between chunks.

Chunking is a stop-gap. It spreads CPU work out; it does not use more CPU cores. For serious CPU work the right answer is a worker thread (lesson 7).

Fix 2: do not use sync APIs in request handlers

Lesson 2 measures readFileSync on a big file against the async version. The short result: the sync call froze a 10 ms heartbeat for about 1.3 seconds, and the async one for about 1 ms. Same idea.

Starvation: nextTick and promises can be worse than a loop

The loop moves forward only when the nextTick and microtask queues are empty. What if a callback keeps adding more to them?

JavaScript
let n = 0;setTimeout(() => { console.log("timer finally ran after", n, "ticks"); process.exit(); }, 10);function loop() { if (n++ < 2_000_000) process.nextTick(loop); }loop();console.log("scheduled; the timer wants to run at 10 ms");
TEXT
scheduled; the timer wants to run at 10 mstimer finally ran after 2000001 tickselapsed 178 ms

The timer asked for 10 ms. It waited for all 2 million ticks and ran after 178 ms. Each tick added the next one, so the nextTick queue was never empty, and the loop could never go on to the timers phase. I tried the same shape with promises:

TEXT
timer ran after 1000001 promise callbacks

Also starved: the timer waited for a million promise callbacks. Compare with setImmediate:

JavaScript
let n = 0;setTimeout(() => { console.log("timer ran after only", n, "immediates"); process.exit(); }, 10);function loop() { if (n++ < 2_000_000) setImmediate(loop); }loop();
TEXT
timer ran after only 3010 immediates

With setImmediate each step goes through the full loop. The timer got its turn after about 10 ms (3010 immediates ran in that time). This is why setImmediate is the safe way to say "continue later" and nextTick and promises are not. In real code this shows up as a recursive async function that never touches I/O or a timer, for example a retry loop on an already-resolved promise.

Errors in callbacks do not travel up

A try/catch only catches errors that happen while the try block is on the call stack. A callback runs later, from a clean stack:

JavaScript
try { setTimeout(() => { throw new Error("late"); }, 0); } catch (e) { console.log("caught?", e.message); }process.on("uncaughtException", (e, origin) => { console.log("uncaughtException:", e.message, "| origin:", origin); });
TEXT
uncaughtException: late | origin: uncaughtException

The catch never ran. The error showed up later at the top, as an uncaught exception. With promises and await, errors become rejections and try/catch works around await again. That is one of the main reasons to prefer promises. Lesson 6 covers error handling and what to do with uncaughtException (short version: log it and exit, do not carry on).

What the loop does not do

Common mistakes about the loop:

  • "The loop runs my async code in parallel." No. It runs your callbacks one at a time. Things wait in parallel (timers, files, sockets) but your JavaScript does not run in parallel.
  • "async makes a function run in the background." No. An async function runs on your thread. If it does heavy CPU work before its first await, it blocks like any other function.
  • "await blocks the thread." No. await pauses that function and lets the thread do other work. This is the opposite of blocking.
  • "setTimeout(fn, 100) runs fn at exactly 100 ms." No. It runs no sooner than 100 ms, and later if the loop is busy.

Try it

  1. Predict the output order, then run:
    JavaScript
    setTimeout(() => console.log("T"), 0);setImmediate(() => console.log("I"));Promise.resolve().then(() => console.log("P"));process.nextTick(() => console.log("N"));console.log("S");
    Put it in a .cjs file and in a .mjs file. Which differences do you see?
  2. Write a script with setInterval printing the time every 100 ms, and after 250 ms run a busy(800) call. What does the interval print, and what does that tell you about missed ticks?
  3. Use monitorEventLoopDelay to measure a JSON.parse of a very large string (for example a 50 MB JSON array you build with JSON.stringify). Is the lag high?
  4. Write function yieldToLoop() { return new Promise(r => setImmediate(r)); } and use it to make a long loop fair, as in the chunking example. Try other chunk sizes.
  5. Put setTimeout(0) and setImmediate inside a fs.readFile callback and run it ten times. Does the order ever change?

Answers

  1. In my runs, the CommonJS file printed S N P T I and the ES module printed S P N T I. The last two can swap from the main script, as shown above. The difference between N and P is the CJS versus ESM effect shown above.
  2. In my run (interval 100 ms, block of 800 ms started at 250 ms) it printed tick at 101, tick at 201, then nothing until block ended at 1050, then tick at 1051, 1151, 1252 and so on. Six ticks that were due during the block were not replayed. Node does not catch up on missed ticks. It runs one late tick and goes on.
  3. Yes. I parsed a 74 MB string (1.5 million small objects). JSON.parse took 995 ms and the max loop lag was 1007 ms. It is one synchronous call, so the lag is about equal to the parse time. This is why people stream or chunk big JSON (lesson 3).
  4. The total time stays about the same; the worst lag drops. Very small chunks add overhead from many loop turns, very large chunks bring the lag back.
  5. In my five runs it was always immediate then timeout. From inside an I/O callback the check phase comes before the next timers phase, so immediate is always first.

Interview questions

Explain the Node.js event loop in simple words. (Must know)

Node runs my JavaScript on one thread. When I start slow work like a timer, a file read or a network call, Node hands it to libuv or the operating system and keeps going. When the work is done, its callback goes into a queue. The event loop goes round a ring of phases (timers, pending, poll, check, close) and runs the callbacks in each phase's queue, one at a time, each to the end. Between callbacks it empties the nextTick and promise queues. When there is nothing left to wait for, the process exits. If something is long-running in one callback, nothing else runs until it ends.

What is the difference between microtasks and macrotasks? Give examples. (Must know)

Microtasks are promise callbacks (.then, code after await) and queueMicrotask. They run right after the current callback finishes, and the whole queue is emptied, including new ones added meanwhile, before the loop moves on. Macrotasks are callbacks from the loop phases: setTimeout and setInterval (timers phase), I/O callbacks (poll phase), and setImmediate (check phase). So in setTimeout(a, 0); Promise.resolve().then(b);, b runs first, even though the timer delay is 0.

What is the order of process.nextTick, a promise callback, setTimeout(0) and setImmediate? (Must know)

In a CommonJS file: the synchronous code, then nextTick, then promises, then the loop starts: the timer and the immediate, where the order of those two from the main script is not guaranteed. In an ES module I measured promises before nextTick. From inside an I/O callback the order is fixed: setImmediate first, because the check phase follows the poll phase, and the timer waits for the next loop. I usually say this and then add that I would not write code that depends on it.

Why is setTimeout(fn, 0) versus setImmediate(fn) order unpredictable in the main script? (Senior signal)

A zero delay is treated as 1 ms. When the loop starts, if 1 ms has already passed since the timer was created, the timer is due and runs first. If not, the loop skips to the check phase and runs the immediate first. How long startup takes varies with the machine's load, so the answer varies. In a test of 20 runs I got immediate first 18 times and timeout first twice. Inside an I/O callback the order is deterministic and setImmediate wins.

What does it mean to block the event loop, and how do you detect it? (Must know)

It means a callback runs for a long time without returning to the loop, so timers, requests and I/O callbacks all wait. In a test, a 1 second loop made a 500 ms timer run at 1252 ms, and a fast HTTP route took 1.8 seconds because a slow route was running. I detect it by watching event loop lag with monitorEventLoopDelay from perf_hooks, or by checking that fast routes get slow at the same time as a slow one. Common causes are big loops, sync file calls, huge JSON.parse, bad regular expressions and sync crypto.

How do you fix a long CPU task that blocks the loop? (Senior signal)

First I check whether it really needs to be in the request path. If it is, I have three choices. Break it into chunks and yield with setImmediate between chunks (in my test the worst lag fell from 1587 ms to 16 ms, with the same total time). Move it to a worker_threads worker so it runs on another thread. Or move it out of the process entirely, to a queue and a separate worker service. Chunking is the cheapest change but does not use more cores. Workers are the real fix for heavy CPU work.

Can process.nextTick or promises starve the event loop? (Trap)

Yes. The loop moves on only when both the nextTick queue and the microtask queue are empty. If each callback queues another one, the loop never advances. In my test, a 10 ms timer waited for two million chained nextTick calls (178 ms total) and for a million chained promise callbacks. The same chain with setImmediate let the timer run after about 10 ms. So recursion with nextTick or already-resolved promises is dangerous, and setImmediate is the safe way to continue later.

Does await block the thread? (Trap)

No. await pauses only the async function it is in. Node keeps running other callbacks on the same thread, and the function resumes as a microtask when the awaited promise is settled. The part of an async function before its first await runs synchronously, so a long computation there does block. readFileSync and await readFile() look similar but behave very differently: the first holds the thread, the second does not.

Why does a try/catch around setTimeout not catch an error thrown in the callback? (Must know)

The callback runs later from the event loop, with a fresh call stack. The try block has already ended. In my test the error appeared as an uncaughtException. With async/await, the error is a rejection of a promise, and a try/catch around the await does catch it. This is one reason I prefer promises to raw callbacks.

What is unref() on a timer, and when is it useful? (Senior signal)

By default an active timer keeps the process alive. unref() says: do not keep the process alive for this timer. In my test, after calling unref on a running setInterval, the process exited at once. I use it for background maintenance work, like a cache cleanup or a metrics flush, that should not stop the program from ending. It also helps in tests: a leftover interval can keep a test runner from exiting.

Does the libuv thread pool run my JavaScript callbacks? (Trap)

No. The pool runs the slow native part of some jobs (file system calls, DNS lookups, some crypto and compression). When the job is done, the callback is queued on the main thread, and my JavaScript runs there. So my JavaScript is still on one thread. Network I/O does not even use the pool; it uses non-blocking sockets in the loop thread.

How would you explain "Node is good at I/O-bound work and bad at CPU-bound work" with this lesson? (Must know)

I/O-bound work is mostly waiting. While one request waits for the database, the single thread serves another one. The loop makes waiting almost free. CPU-bound work keeps the thread busy, so while one request computes, nobody else is served, as in the two-second /slow route that made a fast route take 1.8 seconds. So I keep each callback short, use async I/O, and send heavy computation to worker threads or other services.

Key takeaways

  • The loop runs one callback at a time, each to its end. Nothing interrupts a running callback.
  • After each callback, process.nextTick and promise queues are emptied before the loop moves on. In CommonJS nextTick goes first; in ES modules I measured promises first.
  • The phases: timers, pending, idle/prepare, poll, check, close. setImmediate runs in check, right after poll. Inside an I/O callback, setImmediate always beats setTimeout(0); in the main script the order is not guaranteed.
  • A timer delay is a minimum. A blocked loop makes timers late: a 500 ms timer ran at 1252 ms after a 1 second block.
  • One blocking route made a 5 ms route take 1.8 seconds. Measure lag with monitorEventLoopDelay.
  • Chunk work with setImmediate, or use worker threads for real CPU work. Do not recurse with nextTick or resolved promises: they can starve timers and I/O.
  • async functions run on your thread. await pauses the function, not the thread.

What's next

Next: Files, Paths and Buffers, Lesson 2. You will read and write files with fs/promises, build paths safely (including a path traversal check), and learn what a Buffer is by looking at real bytes. Practise now: take the chunking example and replace the busy loop with something you actually do, such as parsing a big JSON file, and see how large the worst lag is.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series