#Event loop and async questions
These questions check whether you can explain how one thread serves many requests: what await actually suspends, where libuv does the work, why timer order sometimes varies, and what Promise.all and emit really do. Answer each one out loud before you open it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning you should be able to give when they ask "why?". Every predict-the-output question was run on Node 26.7, many times where the order could vary.
app.get('/orders', async (req, res) => {
const orders = await db.query('SELECT * FROM orders WHERE user_id = $1', [req.user.id]);
res.json(orders);
});Show answer
Only this handler's continuation — the rest of the async function after the await — is suspended. No thread is created. The main thread goes back to the event loop and runs other requests while the query is in flight, and the continuation later runs on that same main thread.
- Express calls the handler on the main thread. It runs synchronously up to the
await, which meansdb.query(...)is called first: the driver writes the query onto a pooled socket and returns a pending promise. awaitregisters "resume here" on that promise, and the handler returns its own pending promise to Express. The call stack is empty, so the loop is free to parse and run other requests.- The database does the work on its own machine. Node's thread pool isn't involved: a socket is watched by the OS (epoll, kqueue or IOCP), not by a libuv worker thread.
- When the result arrives, the poll phase sees the socket is readable, the driver parses the rows and resolves the promise, and the continuation runs as a microtask:
res.json(orders). - The trap is that the awaited expression is evaluated before anything is suspended.
await expensiveSyncCalculation()blocks the thread for the whole calculation — in the site's example,handler()only returned after 300 ms.asyncchanges when a result is delivered, not where the work runs.
Learn more: Blocking the event loop and How one process serves many requests.
fs.readFile('report.csv', (err, data) => {
// …
});Show answer
Your JavaScript starts it, on the main thread, by calling into Node's fs binding. The blocking file-system calls run on libuv's thread pool (4 threads by default, set with UV_THREADPOOL_SIZE). When they finish, the completion goes back to the event loop and your callback runs on the main thread, in the poll phase.
fs.readFileis a JavaScript function in Node'slib/fs.js. It validates its arguments, calls the C++ binding, which asks libuv to queue the work, and returnsundefinedstraight away.- A pool thread performs the blocking system calls.
readFileis really open, stat, read and close, each a separate pool request. None of your JavaScript runs on that thread. - When the last step finishes, libuv signals the loop. In the poll phase the loop runs the completion, and your callback is called with
(err, data)on the main thread. - Not everything uses the pool. Sockets (HTTP, database drivers) are non-blocking and watched by the OS. The pool is for file-system work,
dns.lookup,crypto.pbkdf2,scryptand friends, and asynczlib. That's why four slowpbkdf2calls can delay a fifthreadFile: all four threads are busy. - So "single-threaded" means your JavaScript runs on one thread. Node itself uses several.
Learn more: Call stack, libuv and the thread pool.
Two users hit a route that awaits a 300 ms query, one millisecond apart.
Show answer
Yes. That's concurrency without parallelism: both queries are waiting in the database at the same time, but the short JavaScript segments before and after each await take turns on the one main thread.
- 0 ms: request A's handler runs about 1 ms of JavaScript, sends query A and suspends at
await. - 1 ms: the thread is free, so request B's handler runs, sends query B and suspends. Two queries are now in flight and no JavaScript is running at all.
- ~300 ms: query A's result arrives; its continuation runs and sends the response. Then B's does the same. Even if both results arrived in the same instant, the two continuations would run one after the other, never at the same time.
- Total ≈ 300 ms for both instead of 600 ms. In the site's three-user demo, three 300 ms requests finished in about 326 ms.
- So the limit is main-thread time per request, not the number of open connections. Running JavaScript in parallel needs worker threads or more processes.
Learn more: Concurrency vs parallelism and How one process serves many requests.
app.get('/bad', (req, res) => {
const start = Date.now();
while (Date.now() - start < 2000) {} // 2 s of CPU on the main thread
res.send('done');
});
app.get('/hello', (req, res) => res.send('hello'));Show answer
/hello waits until /bad's loop finishes. The OS accepts its connection and buffers the bytes, but reading the socket, parsing the HTTP and calling the handler all need the main thread. Measured in the event-loop chapter: a /hello sent 0.1 s after /bad took 1.89 s instead of 6 ms.
/bad's handler starts itswhileloop. The thread can't get back to the event loop until the handler returns.- 0.1 s later
/helloarrives. The kernel completes the TCP handshake and queues the request bytes in the socket buffer. Nothing in Node runs yet. - Timers, other I/O callbacks and every other request queue up behind the loop as well. Event-loop delay jumps to about 2 s.
- At 2 s
/badreturns. The next poll phase reads the/hellosocket, and its handler takes microseconds — so almost all of its 1.89 s was waiting. - The fixes: keep this work off the request path. Chunk it with
setImmediate, move it to a worker thread, or put it on a queue. More processes add capacity, but a request that lands on the blocked process still waits.
Learn more: Blocking the event loop and Strategies for CPU-intensive work.
const fs = require('node:fs');
setTimeout(() => console.log('top: timeout'), 0);
setImmediate(() => console.log('top: immediate'));
fs.readFile(__filename, () => {
setTimeout(() => console.log('io: timeout'), 0);
setImmediate(() => console.log('io: immediate'));
});Show answer
The two top: lines come first, in either order. Then always io: immediate, then io: timeout. In 200 runs with stdout piped, top: immediate came first 172 times and top: timeout 28 times; the io: pair was in the same order every time.
- The script queues a timer (
setTimeout(fn, 0)really means 1 ms), an immediate, and starts the file read, which needs several thread-pool round trips. Then the script ends and the loop starts. - Top level: the loop's first timers check runs
top: timeoutonly if 1 ms has already passed sincesetTimeoutwas called. If it has, the timer runs, then poll, then check runs the immediate. If it hasn't, check runstop: immediatefirst, and the timer runs in the next timers phase. - Whether that 1 ms has passed depends on startup work, where stdout goes and machine load, which is why the chapter saw anything from 1% to 100% "timeout first" for the same lines. Never rely on it.
- Later the read finishes and its callback runs in the poll phase. It queues a new timer and a new immediate.
- The phase after poll is always check, so
io: immediateruns first. The timer can only run in a later timers phase. No timing is involved: this is the guaranteed case. - The interview answer: "Inside an I/O callback,
setImmediatealways wins. In the main module the order isn't deterministic. If I need an order, I chain one from the other."
Learn more: setTimeout vs setImmediate and The phases of the event loop.
const delay = (ms, value, fail = false) =>
new Promise((resolve, reject) =>
setTimeout(() => {
console.log(`${value} settled`);
fail ? reject(new Error(`${value} failed`)) : resolve(value);
}, ms));
async function main() {
const results = await Promise.all([delay(60, 'P1'), delay(20, 'P2')]);
console.log('all:', results);
try {
await Promise.all([delay(60, 'P3'), delay(20, 'P4', true)]);
} catch (err) {
console.log('caught:', err.message);
}
}
main();Show answer
Results come back in input order, [ 'P1', 'P2' ], even though P2 settled first. When P4 rejects, Promise.all rejects immediately — but P3 isn't cancelled: it still runs and settles afterwards.
P2 settled
P1 settled
all: [ 'P1', 'P2' ]
P4 settled
caught: P4 failed
P3 settled- The array literal calls both
delayfunctions, so both timers start at once.Promise.alldoesn't start anything; it waits for promises whose work has already begun. - At 20 ms P2 settles first (
P2 settled), andPromise.allstores'P2'at index 1. At 60 ms P1 settles and goes in index 0. The array is complete, so it fulfils with[ 'P1', 'P2' ]. That's why array destructuring of the result is safe. - Second call: at 20 ms P4 rejects.
Promise.allrejects straight away with that error, and thecatchprintscaught: P4 failedwithout waiting for P3. - At 60 ms P3 still logs
P3 settled. Fail-fast means "stop waiting", not "stop the work". If P3 were an HTTP call it would still use a socket — cancel it with anAbortSignalif that matters. - Nor does it roll anything back: if P3 had charged a card, the charge stands. Consistency needs transactions, compensation and idempotency.
Learn more: Promise.all and running work concurrently and Cancelling work with AbortController.
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const saved = [];
async function main() {
[30, 10, 20].forEach(async (ms) => {
await wait(ms);
saved.push(ms);
});
console.log('after forEach:', saved);
await Promise.all([30, 10, 20].map(async (ms) => {
await wait(ms);
saved.push(ms);
}));
console.log('after map + Promise.all:', saved);
}
main();Show answer
after forEach: [], then after map + Promise.all: [ 10, 10, 20, 20, 30, 30 ] (the same in 50 of 50 runs). forEach ignores the promises its callback returns, so nothing waits for them. And Promise.all over a huge array starts every operation at once — it has no concurrency limit.
forEachcalls the async callback three times. Each call runs until itsawait, returns a promise, andforEachthrows that promise away.forEachreturnsundefinedat once, sosavedis still[]when it's logged.- Those three saves keep running in the background. Nothing holds their promises, so if one rejected it would be an unhandled rejection — a crash on Node 15+.
mapreturns the three promises andPromise.allwaits for them. While it waits, the forgottenforEachsaves finish too, interleaved by their timers: 10 ms (forEach), 10 ms (map), 20, 20, 30, 30. Hence every value twice — a neat illustration of work you didn't know was still running.- For one-at-a-time in input order, use
for (const ms of list) await …; for concurrent,map+Promise.all. - The danger with a huge populated array is unbounded fan-out:
Promise.all(ids.map(fetchUser))over a million ids opens a million requests, holds a million promises and results in memory, and floods the database pool or the remote API. Use a concurrency limit (a pool of N workers) or batches. - Correcting a common snippet:
Promise.all(new Array(1_000_000).map(() => fetchUser()))actually does nothing, becausenew Array(n)is sparse andmapskips empty slots. UseArray.from({ length: n }, …).
Learn more: Promise.all and running work concurrently and Limiting concurrency.
- You need every result, and one failure makes the whole answer useless.
- You need every outcome, failures included (a batch report).
- You want the first success from three mirrors.
- You want whichever settles first, success or failure (a timeout race).
Show answer
1 → Promise.all, 2 → Promise.allSettled, 3 → Promise.any, 4 → Promise.race. None of them cancels the losing or remaining operations, and none rolls back side effects.
Promise.allfulfils with all values in input order, or rejects with the first rejection. Right when the results only make sense together (user + orders + preferences for one page).Promise.allSettlednever rejects. It fulfils with{ status: 'fulfilled', value }or{ status: 'rejected', reason }for every input, so you can report "48 sent, 2 failed".Promise.anyfulfils with the first fulfilment and ignores rejections. Only if all reject does it reject, with anAggregateErrorwhoseerrorsarray holds every reason.Promise.racesettles like the first input to settle, either way.Promise.race([fetchData(), timeout(2000)])is the classic use — but the slow fetch keeps running unless you abort it;AbortSignal.timeout(2000)is usually better.- Empty input, checked on Node 26:
all([])andallSettled([])fulfil with[],any([])rejects with anAggregateErrorwith no errors, andrace([])stays pending forever.
Learn more: Promise.allSettled, any and race.
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
bus.on('order', (id) => { console.log('listener 1', id); return 'ignored'; });
bus.on('order', (id) => console.log('listener 2', id));
console.log('A');
const had = bus.emit('order', 42);
console.log('B', had);
console.log('C', bus.emit('nobody-listens'));Show answer
A, listener 1 42, listener 2 42, B true, C false. emit calls the listeners synchronously, in registration order, before it returns, and its return value only says whether the event had any listeners.
- Two listeners are registered for
'order'. Nothing runs yet. Aprints.bus.emit('order', 42)loops over a copy of the listener array and calls each one, right now, on the same call stack:listener 1 42,listener 2 42.- Listener 1's return value
'ignored'really is ignored.emitreturnstruebecause'order'had listeners, soB trueprints. 'nobody-listens'has no listeners, soemitreturnsfalse:C false.- Consequences: a slow listener blocks the emitter (and the event loop), and a listener that throws throws out of
emit, so later listeners don't run. If you need results back, use a function call or a promise, not an event.
Learn more: emit runs listeners synchronously.
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
bus.on('save', async (id) => {
console.log('listener start', id);
await new Promise((r) => setTimeout(r, 10));
console.log('listener end', id);
});
console.log('emit returned', bus.emit('save', 1));
console.log('after emit');
try {
bus.emit('error', new Error('disk full'));
} catch (err) {
console.log('emit threw:', err.message);
}Show answer
listener start 1, emit returned true, after emit, emit threw: disk full, and 10 ms later listener end 1 (50 of 50 runs). An async listener starts synchronously, but emit doesn't wait for it. And an 'error' event with no 'error' listener throws from emit.
emit('save', 1)calls the async listener synchronously, solistener start 1prints first. At theawaitthe listener returns a pending promise, whichemitignores, andemitreturnstrue.after emitprints while the listener is still waiting for its timer. Code afteremitcan't assume the listener has finished.emit('error', …)with no'error'listener is special: Node throws the error (a non-Error value would be wrapped inERR_UNHANDLED_ERROR). Here atry/catchcatches it because theemitis in the same synchronous code. When a stream or socket emits'error'from inside an event-loop callback, nothing is there to catch it, and the process crashes — so every emitter that can fail needs an'error'listener.- At 10 ms the listener resumes:
listener end 1. - If the async listener had rejected, the rejection would be unhandled (and crash on Node 15+). Catch inside the listener, or create the emitter with
{ captureRejections: true }so rejections are routed to the'error'event.
Learn more: Async listeners and The error event.