JavaScript interview question
Explain the event loop, and why await inside forEach does not wait.
Short answer
JavaScript runs one call stack; when it empties, the event loop drains microtasks (promise callbacks) and then runs the next macrotask (timers, I/O). forEach ignores the promise an async callback returns, so nothing awaits it. Use for...of for sequential work or Promise.all with map for parallel.
Answer
JavaScript executes your code on a single call stack. Asynchronous work (timers, network, file I/O) is handled by the host (browser or Node.js), and when it completes, a callback is queued. The event loop runs a task to completion, then drains the microtask queue (promise reactions, queueMicrotask), then picks the next macrotask (a timer, an I/O callback, a UI event), and repeats. await pauses only the current async function: the rest of the function is scheduled as a microtask when the awaited promise settles, and the caller continues immediately with a pending promise.
Ordering example
console.log('1 sync');
setTimeout(() => console.log('4 macrotask'), 0);
Promise.resolve().then(() => console.log('3 microtask'));
console.log('2 sync');
// 1 sync, 2 sync, 3 microtask, 4 macrotask
Synchronous code finishes first, then all microtasks, then the timer, even with a delay of 0.
Why await inside forEach does not wait
Array.prototype.forEach calls your callback and throws away its return value. An async callback returns a promise immediately, at its first await. forEach never awaits those promises, and forEach itself returns undefined, so there is nothing for the caller to await either.
async function deleteUsers(ids) {
ids.forEach(async (id) => {
await api.delete(`/users/${id}`);
console.log('deleted', id);
});
console.log('done'); // logs BEFORE any deletion finishes
}
In a test this causes cleanup that has not happened when the next test starts, assertions running before data exists, and unhandled rejections that surface in a different test or crash the runner.
Fix 1: sequential with for…of
for (const id of ids) {
await api.delete(`/users/${id}`);
}
Use this when order matters, when calls depend on each other, or to avoid hammering a rate-limited API.
Fix 2: parallel with Promise.all and map
await Promise.all(ids.map((id) => api.delete(`/users/${id}`)));
map returns an array of promises, and Promise.all waits for all of them (rejecting on the first failure). Use Promise.allSettled if you need every result regardless of failures. For large lists, limit concurrency by processing in chunks or with a small pool.
The same issue applies to filter, some and reduce with async callbacks: filter(async ...) keeps every element because a promise is truthy.
In Playwright and other test tools
Every Playwright action returns a promise. A missing await (or an await hidden inside forEach) means the step may run after the test has ended, producing errors such as “Target page, context or browser has been closed”. The @typescript-eslint/no-floating-promises lint rule catches most of these at review time.
What interviewers look for
- An accurate model: call stack, microtasks before macrotasks.
- The precise reason:
forEachdiscards the promise returned by the callback. - Both fixes, with when to choose sequential versus parallel.
- Connecting it to real flaky-test symptoms and lint rules.
A strong answer covers
- Single-threaded call stack with an event loop
- Microtasks (promises) run before the next macrotask (timers, I/O)
- forEach ignores the promise returned by an async callback
- Fix with for...of for sequential execution
- Fix with Promise.all and map for parallel execution