{}The Interview
Handbook

Tracks / JavaScript

The event loop, promises & async

mid 10 questions · 5 min read asyncevent-looppromises

Questions in this set 10
  1. 01Explain the event loop. Predict the output of this snippet.
  2. 02Why is setTimeout(fn, 0) not actually zero?
  3. 03Promise states, and the difference between .then(f, g) and .then(f).catch(g).
  4. 04Compare Promise.all, allSettled, race, any.
  5. 05async/await — what does it desugar to, and how do you run things in parallel?
  6. 06How do you handle errors in async code correctly?
  7. 07How do you cancel an in-flight request?
  8. 08Implement a concurrency-limited mapLimit.
  9. 09Implement debounce and throttle.
  10. 10What is an event loop "starvation" or "blocking" bug, and how do you fix it in Node?
01

Explain the event loop. Predict the output of this snippet.

JavaScript has one call stack. When it empties, the runtime drains the microtask queue completely (promise callbacks, queueMicrotask, MutationObserver), then takes one macrotask (timers, I/O, events), then drains microtasks again, and so on. Rendering happens between macrotasks.

js
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
console.log("5");
// 1 5 3 4 2

Sync first, then all microtasks in FIFO order, then the timer. The key sentence: microtasks starve macrotasks — an infinite chain of .then() freezes the page, because the loop never gets to render or run timers.

Node addition: process.nextTick runs before other microtasks; the loop has ordered phases (timers → pending → poll → check → close), where setImmediate fires in check and so beats a setTimeout(…, 0) when both are scheduled from an I/O callback.

02

Why is setTimeout(fn, 0) not actually zero?

It queues a macrotask with a minimum clamp (4ms after 5 nested timers, per spec) and it can only run once the stack is empty and microtasks are drained. If a long synchronous function is running, your "0ms" callback waits for it. For "run after the browser paints", requestAnimationFrame is the right tool; for "yield to the browser so it can paint", await scheduler.yield() or setTimeout(0) in a chunked loop.

03

Promise states, and the difference between .then(f, g) and .then(f).catch(g).

A promise is pending, then fulfilled or rejected — settled once, immutably.

js
p.then(onOk, onErr);        // onErr does NOT catch errors thrown by onOk
p.then(onOk).catch(onErr);  // onErr catches rejection of p AND errors thrown in onOk

.catch(f) is .then(undefined, f); .finally(f) runs regardless and passes the value/reason through unchanged (its return value is ignored unless it throws).

04

Compare Promise.all, allSettled, race, any.

combinator resolves when rejects when use
all all fulfil (array, in order) first rejection (others keep running) you need every result
allSettled all settle never dashboards, partial data, cleanup
race first settles (either way) first settles as rejection timeouts
any first fulfils all reject (AggregateError) fastest-mirror fetch
js
// timeout via race — note AbortController is better because it actually cancels the work
const withTimeout = (p, ms) => Promise.race([
  p,
  new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms)),
]);

Trap: Promise.all does not cancel siblings on failure. If those are network requests, they continue and their rejections may become unhandled.

05

async/await — what does it desugar to, and how do you run things in parallel?

An async function always returns a promise; await pauses it and resumes in a microtask when the awaited value settles.

js
// sequential: 3 round trips
const a = await getA(); const b = await getB(); const c = await getC();

// parallel: 1 round trip's worth of latency
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);

The subtlety: Promise.all([getA(), getB()]) starts both at call time — the promises are already running before await. Assigning them first and awaiting later works too. But beware: if you start a promise and await it much later, an intermediate rejection can be reported as unhandled.

06

How do you handle errors in async code correctly?

js
try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new HttpError(res.status);      // fetch does NOT reject on 4xx/5xx
  return await res.json();                            // note: `return await` inside try, so
} catch (err) {                                       // the catch actually sees a JSON parse error
  if (err.name === "AbortError") return fallback;
  throw new Error("load failed", { cause: err });     // preserve the chain
} finally {
  hideSpinner();
}

Three points interviewers listen for: fetch only rejects on network failure (a 500 is a successful fetch); return await inside try is meaningful; and unhandled rejections crash Node by default since v15, so top-level handlers (process.on("unhandledRejection"), window.addEventListener("unhandledrejection")) are for logging, not control flow.

07

How do you cancel an in-flight request?

AbortController — the standard cancellation primitive, honoured by fetch, addEventListener, streams and many libraries.

js
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal }).catch(e => { if (e.name !== "AbortError") throw e; });
ctrl.abort();

// React: cancel on unmount / on new query
useEffect(() => {
  const c = new AbortController();
  search(q, { signal: c.signal }).then(setResults).catch(ignoreAbort);
  return () => c.abort();
}, [q]);

This also solves the race condition where a slow earlier response overwrites a fast later one.

08

Implement a concurrency-limited mapLimit.

js
async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const i = next++;                 // safe: single-threaded, no await between read and write
      results[i] = await fn(items[i], i);
    }
  });
  await Promise.all(workers);
  return results;
}

This is a very common live-coding task. The details that earn points: results in input order, the index captured before any await, and the worker count clamped to the input length.

09

Implement debounce and throttle.

js
function debounce(fn, wait) {            // fire once, `wait` after activity STOPS
  let t;
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
}

function throttle(fn, interval) {        // fire at most once per interval
  let last = 0, timer;
  return (...args) => {
    const now = Date.now(), remaining = interval - (now - last);
    if (remaining <= 0) { last = now; fn(...args); }
    else if (!timer) {                                    // trailing call
      timer = setTimeout(() => { last = Date.now(); timer = null; fn(...args); }, remaining);
    }
  };
}

Debounce a search box; throttle a scroll or resize handler. Follow-ups: add a cancel() method, support leading/trailing options, and preserve this by using a regular function rather than an arrow.

10

What is an event loop "starvation" or "blocking" bug, and how do you fix it in Node?

Any long synchronous computation (JSON parsing a 50 MB payload, a crypto operation, a giant for loop, a catastrophic regex backtrack) blocks every other request on that process. Fixes, in order: do less work (stream/paginate the payload); move it to a worker thread (node:worker_threads) or a child process; use the async variant of the crypto/zlib API so it runs on libuv's thread pool; or split the work and yield with setImmediate between chunks. Monitor with event-loop lag metrics (perf_hooks.monitorEventLoopDelay) — rising p99 lag is the tell.