Interview Questions: Event Loop & Async
The event loop is the single most common source of “gotcha” interview questions for Node.js, because it touches everything: timers, I/O, Promises, and the illusion of concurrency on a single thread. Interviewers use ordering puzzles to test whether you actually understand the runtime or merely memorized that “Node is asynchronous.” This page collects the questions that come up most, with precise answers and runnable examples using modern Node.js (20/22 LTS) and ES modules.
How does the event loop work if Node is single-threaded?
Your JavaScript runs on one thread with a single call stack, so two lines of your code never execute literally at the same time. Concurrency comes from libuv, the C library underneath Node, which hands blocking work (file reads, network sockets, DNS) to the OS kernel and a small worker thread pool. When that work completes, libuv queues a callback, and the event loop pushes it back onto the JS thread when the stack is empty.
The loop runs in a fixed sequence of phases, each with its own FIFO queue:
| Phase | Processes |
|---|---|
| timers | setTimeout / setInterval callbacks whose threshold elapsed |
| pending callbacks | Deferred system callbacks (e.g. some TCP errors) |
| poll | Retrieves and runs I/O callbacks; may block here waiting for work |
| check | setImmediate callbacks |
| close | close event handlers, e.g. socket.on('close', ...) |
Because there is one thread, a CPU-bound synchronous loop blocks everything — no timers fire, no I/O callbacks run, and no new connections are accepted until it finishes.
What is the difference between microtasks and macrotasks?
Macrotasks are the callbacks queued in the phases above — timers, I/O, setImmediate. Microtasks are not part of the phase rotation and have higher priority. There are two microtask queues: process.nextTick (Node-specific, highest priority) and the Promise job queue (.then, await, queueMicrotask).
After every macrotask completes — and after each phase transition — Node fully drains the nextTick queue first, then the entire Promise microtask queue, before running the next macrotask. This is why a resolved Promise’s .then always beats a setTimeout(fn, 0).
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('end');
Output:
start
end
nextTick
promise
timeout
Synchronous code runs first, then nextTick (highest priority microtask), then Promises, and only then the timer macrotask.
process.nextTick vs setImmediate — which runs first?
Despite the names, process.nextTick does not wait for the next loop tick — it fires immediately after the current operation, before any I/O or timers. setImmediate fires in the check phase, after the poll phase. So nextTick always runs before setImmediate.
import { readFile } from 'node:fs/promises';
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
await readFile(import.meta.filename);
console.log('after file read');
Output:
nextTick
immediate
after file read
Overusing
process.nextTickcan starve the event loop: because it drains fully before I/O, a recursivenextTickchain blocks all other phases. PrefersetImmediatewhen you just want to yield.
Why is setTimeout vs setImmediate ordering non-deterministic?
At the top level, setTimeout(fn, 0) and setImmediate(fn) race: the result depends on how long the process took to start and reach the timers phase. The 0ms timeout might not be “ready” yet when the loop first checks. But inside an I/O callback, the order is guaranteed — the loop is already past timers and enters the check phase next, so setImmediate always wins.
import { readFile } from 'node:fs/promises';
await readFile(import.meta.filename);
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Output:
immediate
timeout
Callbacks vs Promises vs async/await — what should I say?
All three describe asynchronous control flow, but they differ in ergonomics and error handling. Callbacks are the original Node convention ((err, result) => {}) but lead to nesting and easy-to-miss error checks. Promises flatten the nesting and compose. async/await is syntactic sugar over Promises that lets asynchronous code read sequentially while still being non-blocking.
| Approach | Error handling | Composition | Notes |
|---|---|---|---|
| Callbacks | Manual if (err) per call | Hard (callback hell) | Still used by some core APIs |
| Promises | .catch() | Promise.all, chaining | Foundation for await |
| async/await | try/catch | Sequential reads cleanly | Built on Promises; non-blocking |
// async/await with a try/catch
async function loadUser(id) {
try {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Failed to load user:', err.message);
throw err;
}
}
Key point for interviews: await does not block the thread — it suspends the async function and returns control to the event loop, resuming via the Promise microtask queue when the awaited value settles.
How do I run async work concurrently?
A common follow-up. Sequential await in a loop is slow; use Promise.all to run independent operations concurrently and Promise.allSettled when you want every result regardless of failures.
const ids = [1, 2, 3];
// Concurrent — all requests start immediately
const users = await Promise.all(ids.map((id) => loadUser(id)));
// Tolerate partial failures
const results = await Promise.allSettled(ids.map((id) => loadUser(id)));
const ok = results.filter((r) => r.status === 'fulfilled');
Best Practices
- Keep per-tick work small; offload CPU-bound tasks to Worker Threads or a child process so the loop stays responsive.
- Reach for
setImmediateoverprocess.nextTickto yield without starving I/O. - Always attach a
.catch()or wrapawaitintry/catch— an unhandled rejection can crash the process in modern Node. - Use
Promise.allfor independent async work instead of awaiting in a loop sequentially. - Remember the priority order: synchronous code →
process.nextTick→ Promise microtasks → macrotask phases. - Don’t rely on top-level
setTimeoutvssetImmediateordering; it is only deterministic inside an I/O callback.