Intermediate to senior

Frontend Interview Prep

Fourteen chapters on HTML and CSS, core JavaScript, the event loop, browser rendering, React, state, performance, accessibility, security, TypeScript, testing, machine-coding components and frontend system design, with tested code.

Chapter 4 of 14Foundations · Async JavaScript: Event Loop and Promises

Async JavaScript: The Event Loop, Promises and async/await

JavaScript runs your code on a single thread, yet handles timers, network requests and user input without freezing. The mechanism is the event loop. Interviewers ask you to predict the order of logs, to implement promise utilities, and to reason about race conditions. This chapter builds the model, then proves it with code you can run.

1. The model

Four pieces matter:

  • Call stack: the functions currently executing, one at a time.
  • Web APIs / host: timers, network, DOM events, handled outside the JavaScript thread by the browser or Node.
  • Task queue (macrotasks): callbacks from setTimeout, setInterval, I/O, UI events, MessageChannel.
  • Microtask queue: promise reactions (.then, .catch, .finally), await continuations and queueMicrotask.

The loop repeats:

  1. Run one macrotask to completion (the first one is your script).
  2. Drain the entire microtask queue, including microtasks that those microtasks add.
  3. Let the browser render if needed.
  4. Pick the next macrotask.
<!--fig:eventloop-->
Call stackruns one function at a time Web APIs / hosttimers, network, events Microtask queuepromise .then, await, queueMicrotask Task (macrotask) queuesetTimeout, I/O, UI events Event loopmicrotasks, then one task Order: finish the current script, drain every microtask, let the browser render, then take ONE macrotask and repeat. Figure 1. The event loop: microtasks always run before the next macrotask.

So the rule is: synchronous code first, then all microtasks, then the next macrotask. A promise callback always runs before a setTimeout(..., 0) callback scheduled in the same turn.

const log = [];
log.push("script start");
setTimeout(() => log.push("timeout"), 0);
Promise.resolve().then(() => log.push("promise 1")).then(() => log.push("promise 2"));
queueMicrotask(() => log.push("microtask"));
log.push("script end");

await new Promise(r => setTimeout(r, 10));        // let everything settle before checking
assert.deepEqual(log, ["script start", "script end", "promise 1", "microtask", "promise 2", "timeout"]);

Walk through it: the two synchronous logs run first. Then microtasks run in the order they were queued: promise 1, then microtask; promise 1's chained .then is queued after those, so promise 2 follows. Only then does the timer's macrotask run.

Consequences

  • A long synchronous computation blocks everything, including rendering and clicks. Break work into chunks (setTimeout, requestIdleCallback, scheduler.yield), or move it to a Web Worker.
  • An endless chain of microtasks starves rendering and timers.
  • setTimeout(fn, 0) does not mean "now": browsers clamp nested timers and the callback waits for the stack and microtasks to clear.
  • requestAnimationFrame runs before the next paint, which is the right place for visual updates.

2. Callbacks and their problems

Early asynchronous APIs took callbacks. Nesting them led to "callback hell", inconsistent error handling and inverted control (you trust the library to call you once and correctly). Promises standardised the contract.

3. Promises

A promise is an object representing a value that is not available yet. It is in one of three states: pending, fulfilled or rejected, and once settled it never changes.

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve(42), 5);
});
assert.equal(await p, 42);

// then returns a new promise; a returned value becomes the next result; a thrown error becomes a rejection
const result = await Promise.resolve(1)
  .then(x => x + 1)
  .then(x => { if (x === 2) throw new Error("boom"); return x; })
  .catch(e => e.message)
  .finally(() => {});                              // finally passes the value through
assert.equal(result, "boom");

Key behaviours:

  • Chaining: .then returns a new promise. If the handler returns a promise, the chain waits for it.
  • Errors propagate down the chain to the next .catch.
  • Handlers always run asynchronously, even on an already-resolved promise.
  • An unhandled rejection is an error (Node can crash on it; browsers log it). Always handle or return the promise.
  • Forgetting to return inside a .then is a classic bug: the next step runs early with undefined.

4. The combinators

MethodResolves whenRejects when
Promise.all([...])all fulfil (array of values in input order)any rejects (fails fast)
Promise.allSettled([...])all settle (array of {status, value/reason})never
Promise.race([...])the first settlesthe first settles with a rejection
Promise.any([...])the first fulfilsall reject (AggregateError)
const wait = (ms, v, fail) => new Promise((res, rej) => setTimeout(() => (fail ? rej(new Error(v)) : res(v)), ms));

assert.deepEqual(await Promise.all([wait(20, "a"), wait(5, "b")]), ["a", "b"]);      // input order, not finish order
assert.equal(await Promise.race([wait(20, "slow"), wait(5, "fast")]), "fast");
assert.equal(await Promise.any([wait(5, "x", true), wait(15, "y")]), "y");

const settled = await Promise.allSettled([wait(1, "ok"), wait(1, "bad", true)]);
assert.deepEqual(settled.map(s => s.status), ["fulfilled", "rejected"]);
await assert.rejects(Promise.all([wait(1, "fine"), wait(2, "bad", true)]), /bad/);

Implementing Promise.all

A very common coding question. Handle non-promise values, keep order, and reject fast.

function promiseAll(items) {
  return new Promise((resolve, reject) => {
    const results = [];
    let remaining = 0, index = 0;
    for (const item of items) {
      const i = index++;
      remaining++;
      Promise.resolve(item).then(value => {
        results[i] = value;
        if (--remaining === 0) resolve(results);
      }, reject);
    }
    if (remaining === 0) resolve(results);           // an empty input resolves immediately
  });
}
assert.deepEqual(await promiseAll([1, wait(5, 2), Promise.resolve(3)]), [1, 2, 3]);
assert.deepEqual(await promiseAll([]), []);
await assert.rejects(promiseAll([wait(20, "late"), wait(1, "early", true)]), /early/);

5. async and await

async functions return a promise. await pauses the function (not the thread) until the promise settles; the rest of the function becomes a microtask continuation. Errors become try/catch.

async function fetchUser(id) {
  if (id < 0) throw new Error("bad id");
  await wait(2);
  return { id };
}
assert.deepEqual(await fetchUser(1), { id: 1 });
try { await fetchUser(-1); assert.fail("should throw"); } catch (e) { assert.equal(e.message, "bad id"); }

Sequential versus parallel

Awaiting in a loop runs requests one after another. If they are independent, start them together.

const t0 = Date.now();
for (const ms of [30, 30, 30]) await wait(ms);                       // sequential: about 90 ms
const sequential = Date.now() - t0;

const t1 = Date.now();
await Promise.all([30, 30, 30].map(ms => wait(ms)));                 // parallel: about 30 ms
const parallel = Date.now() - t1;
assert.ok(sequential > 80 && parallel < 70);

array.forEach(async ...) does not wait for its callbacks; use for...of with await for sequential work or Promise.all(array.map(...)) for parallel work.

Order of logs with await

Code after await runs as a microtask. This is the typical puzzle:

const order = [];
async function a() { order.push("a start"); await null; order.push("a end"); }
a();
order.push("sync");
await null;
assert.deepEqual(order, ["a start", "sync", "a end"]);

6. Concurrency control

Firing 1,000 requests at once can overload the server or the browser. A concurrency limiter runs at most tasks at a time.

async function mapLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0, running = 0, peak = 0;
  async function lane() {
    while (next < items.length) {
      const i = next++;
      running++; peak = Math.max(peak, running);
      results[i] = await worker(items[i], i);
      running--;
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
  return { results, peak };
}
const { results: out, peak } = await mapLimit([1, 2, 3, 4, 5, 6], 2, async x => { await wait(5); return x * 10; });
assert.deepEqual(out, [10, 20, 30, 40, 50, 60]);
assert.equal(peak, 2);                                  // never more than two at once

7. Timeouts, retries and cancellation

Timeout a promise by racing it against a timer. Retry with exponential backoff and jitter for transient failures (only for idempotent requests). Cancel with an AbortController: pass its signal to fetch (or your own function) and call abort().

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error("timeout")), ms); });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
assert.equal(await withTimeout(wait(5, "ok"), 50), "ok");
await assert.rejects(withTimeout(wait(100, "slow"), 10), /timeout/);

async function retry(fn, attempts, baseMs = 1) {
  let lastError;
  for (let i = 0; i < attempts; i++) {
    try { return await fn(i); } catch (e) { lastError = e; await wait(baseMs * 2 ** i); }
  }
  throw lastError;
}
assert.equal(await retry(async i => { if (i < 2) throw new Error("flaky"); return "done"; }, 5), "done");

function abortable(ms, signal) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => resolve("finished"), ms);
    signal.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")); });
  });
}
const ctl = new AbortController();
const pending = abortable(100, ctl.signal);
ctl.abort();
await assert.rejects(pending, /aborted/);

8. Race conditions in UIs

A classic bug: the user types "ab", then "abc"; the response for "ab" arrives after the one for "abc" and overwrites it, showing stale results.

Fixes:

  • Cancel the previous request with AbortController when a new one starts.
  • Ignore stale responses by tagging each request with an ID and applying only the latest.
  • Debounce input so fewer requests fire.
  • In React effects, return a cleanup that sets an ignore flag or aborts.
function latestOnly(asyncFn) {
  let current = 0;
  return async (...args) => {
    const id = ++current;
    const value = await asyncFn(...args);
    return id === current ? { stale: false, value } : { stale: true, value };
  };
}
const search = latestOnly(async (q, ms) => { await wait(ms); return q; });
const first = search("ab", 30);      // slow
const second = search("abc", 5);     // fast, issued later
assert.equal((await second).stale, false);
assert.equal((await first).stale, true);               // the older response is recognised as stale

9. Other asynchronous tools

  • async iteration (for await ... of) for streams and paginated data.
  • Web Workers for CPU-heavy work off the main thread.
  • requestIdleCallback for low-priority tasks, requestAnimationFrame for animation.
  • Event emitters and observables for streams of events.
  • Streams API for incremental reading of large responses.
  • Server-sent events and WebSockets for push updates.

10. Common mistakes

  • Forgetting to await or return a promise, so errors vanish or ordering breaks.
  • forEach with async callbacks.
  • Sequential await in a loop for independent work.
  • Swallowing errors with an empty catch.
  • Unhandled rejections.
  • No cancellation, leading to stale state updates and leaks.
  • Assuming setTimeout(0) runs immediately, or that timers are precise.
  • Blocking the main thread with synchronous loops.

11. Practice questions

  1. Predict and explain the order of logs for a script mixing setTimeout, promises and await.
  2. What is the difference between microtasks and macrotasks? Can microtasks starve rendering?
  3. Implement Promise.all, Promise.race and Promise.allSettled.
  4. Why does array.forEach(async ...) not wait? What do you use instead?
  5. How do you run many requests with a concurrency limit?
  6. How do you cancel a fetch? What does AbortController do?
  7. Explain a race condition in a search box and two ways to fix it.
  8. What happens when a promise rejects and nothing handles it?
Header Logo