JavaScript · 17 topics

Asynchronous JavaScript and the event loop

The largest and most-tested group in JavaScript. Seventeen topics from callbacks to the event loop, with the output-prediction puzzles interviewers actually use.

Published
Reading time
28 min read
Topics covered
17 topics

Eighteen topics, and the largest group in the whole JavaScript track. Async is where interviews go when they want to find the bottom of your knowledge, because there is always one more layer: promises, then the event loop, then microtask ordering, then "now implement it yourself".

Work through it in order. Each part explains the next.

Synchronous and asynchronous

In simple words: JavaScript runs one thing at a time on one thread. Synchronous code blocks that thread until it finishes. Asynchronous code hands the slow work to something else and carries on.

The thing to be precise about: JavaScript is single-threaded, but the environment around it is not. Timers, network requests and file reads are handled by the browser or by libuv in Node. Your code only gets involved again when the result comes back.

console.log("first");
 
setTimeout(() => console.log("third"), 0);   // handed to the environment
 
console.log("second");
 
// first, second, third

Callbacks

In simple words: the original approach. Pass a function to be called later, when the work is done.

Node standardised the error-first shape: the first argument is the error, and it is null when everything went well.

fs.readFile("data.json", "utf8", (err, data) => {
  if (err) return handleError(err);   // always check first
  console.log(data);
});

Callback hell is what happens when each step depends on the previous one:

getUser(id, (err, user) => {
  if (err) return done(err);
  getOrders(user.id, (err, orders) => {
    if (err) return done(err);
    getPayments(orders[0].id, (err, payments) => {
      if (err) return done(err);
      done(null, payments);            // three levels deep, error handling repeated
    });
  });
});

The real problem is not the indentation. It is that errors do not propagate on their own — you have to check and forward at every level, and forgetting once swallows the failure silently.

Promises

In simple words: an object representing a value you do not have yet. It is in one of three states, and once it leaves pending it can never change again.

  • pending — still working
  • fulfilled — finished with a value
  • rejected — failed with a reason
const promise = new Promise((resolve, reject) => {
  if (ok) resolve(value);
  else reject(new Error("failed"));
});
 
promise
  .then((value) => value * 2)          // returns a NEW promise
  .then((doubled) => console.log(doubled))
  .catch((err) => console.error(err))  // catches anything above it
  .finally(() => cleanup());           // runs either way, receives nothing

The chaining rules that actually matter

// Returning a plain value passes it to the next then
.then(() => 42).then((v) => console.log(v));          // 42
 
// Returning a promise waits for it before continuing
.then(() => fetch(url)).then((res) => res.json());    // res is the response
 
// Throwing skips to the nearest catch
.then(() => { throw new Error("x"); }).catch((e) => {});
 
// A catch that returns normally puts the chain back on the success path
.catch(() => "fallback").then((v) => console.log(v)); // "fallback"

all, allSettled, race and any

In simple words: four ways to run several promises together. Choosing the wrong one is a real production bug, not a trivia question.

Resolves whenRejects whenGives you
allevery one succeedsany one fails, immediatelyarray of values
allSettledevery one settlesneverarray of status objects
racethe first one settlesif the first to settle rejectsthat one result
anythe first one succeedsonly if all failthat one value
// all -- one failure loses every other result
const [user, orders] = await Promise.all([getUser(), getOrders()]);
 
// allSettled -- when partial success is acceptable
const results = await Promise.allSettled([getUser(), getOrders(), getStats()]);
results.forEach((r) => {
  if (r.status === "fulfilled") use(r.value);
  else log(r.reason);
});
 
// race -- classic timeout wrapper
const withTimeout = (promise, ms) =>
  Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("Timed out")), ms)
    ),
  ]);
 
// any -- first mirror that responds wins
const fastest = await Promise.any([fetch(mirror1), fetch(mirror2)]);

async and await

In simple words: the same promises, written to look synchronous. async makes a function return a promise. await pauses inside that function until a promise settles.

async function loadDashboard(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    return { user, orders };
  } catch (err) {
    logger.error({ err, userId }, "dashboard load failed");
    throw err;                      // rethrow, or the caller sees success
  } finally {
    stopSpinner();
  }
}

An async function always returns a promise. return 42 inside one gives the caller a promise resolving to 42, never the number itself.

Sequential versus parallel awaits

In simple words: the single most common performance bug in modern JavaScript, and one interviewers deliberately plant.

// SLOW -- three round trips, one after another. 300ms.
const user = await getUser();        // 100ms
const orders = await getOrders();    // 100ms, did not need user
const stats = await getStats();      // 100ms, did not need either
 
// FAST -- all three start together. 100ms.
const [user, orders, stats] = await Promise.all([
  getUser(),
  getOrders(),
  getStats(),
]);

The rule: await sequentially only when the next call genuinely needs the previous result. Everything else should start together.

// The subtle version: this is still sequential
for (const id of ids) {
  await processItem(id);             // one at a time
}
 
// Parallel, but unbounded -- 5,000 ids means 5,000 simultaneous requests
await Promise.all(ids.map((id) => processItem(id)));

Neither is right for a large list. That is what the concurrency pool further down solves.

The event loop

In simple words: the rule that decides what runs next. Everything about async ordering comes from this one picture.

CALL STACKyour synchronouscode runs here,one frame at a timeENVIRONMENTtimers, fetch, I/Ohandled outsidethe JS threadMICROTASKSpromises, queueMicrotaskMACROTASKSsetTimeout, I/O, eventsdrain ALLthen ONEEACH TURN1. run stack to empty2. drain all microtasks3. render if needed4. take one macrotask
The loop repeats forever: empty the call stack, then drain every microtask, then take one macrotask. Rendering happens between iterations, never in the middle of one.

The rule in one line: the entire microtask queue is emptied before a single macrotask runs.

Microtasks versus macrotasks

In simple words: promises jump the queue. Timers wait their turn.

Microtasks (high priority)Macrotasks (normal)
.then, .catch, .finallysetTimeout, setInterval
await continuationssetImmediate (Node)
queueMicrotaskI/O callbacks, DOM events
MutationObserverrequestAnimationFrame (before paint)

Now predict this. It is asked constantly:

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

Why: 1 and 5 are synchronous, so they run first. The stack empties. Then every microtask runs in order — 3, then 4. Only then does the loop take one macrotask, 2.

A harder one:

async function run() {
  console.log("A");
  await null;              // await always yields, even on a non-promise
  console.log("B");
}
 
console.log("C");
run();
console.log("D");
 
// C, A, D, B

Everything up to the first await runs synchronously. The rest of the function becomes a microtask.

Node's event loop phases

In simple words: Node's loop has named phases that run in a fixed order. Two extra queues sit above them.

Order per iteration: timers → pending callbacks → poll (I/O) → check (setImmediate) → close callbacks.

Between every phase, Node drains process.nextTick first, then the promise microtask queue.

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
 
// nextTick, promise, then timeout / immediate in a non-deterministic order

Debounce and throttle

In simple words: both limit how often a function runs, but they are not interchangeable.

  • Debounce — wait until the activity stops, then run once. Search-as-you-type.
  • Throttle — run at most once per interval, no matter how much activity. Scroll handlers.
function debounce(fn, delay = 300, { leading = false } = {}) {
  let timer = null;
 
  return function (...args) {
    const callNow = leading && timer === null;
    clearTimeout(timer);
 
    timer = setTimeout(() => {
      timer = null;
      if (!leading) fn.apply(this, args);
    }, delay);
 
    if (callNow) fn.apply(this, args);
  };
}
 
function throttle(fn, interval = 300) {
  let lastRun = 0;
  let trailing = null;
 
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastRun);
 
    if (remaining <= 0) {
      clearTimeout(trailing);
      trailing = null;
      lastRun = now;
      fn.apply(this, args);
    } else if (!trailing) {
      // guarantee the final call is not lost
      trailing = setTimeout(() => {
        lastRun = Date.now();
        trailing = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

Implement your own Promise

In simple words: a senior-round favourite. You need state, stored handlers, and the microtask scheduling that makes then asynchronous.

class MyPromise {
  #state = "pending";
  #value = undefined;
  #callbacks = [];
 
  constructor(executor) {
    const resolve = (value) => this.#settle("fulfilled", value);
    const reject = (reason) => this.#settle("rejected", reason);
 
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
 
  #settle(state, value) {
    if (this.#state !== "pending") return;   // can only settle once
 
    // adopting a thenable: wait for it instead of resolving with it
    if (state === "fulfilled" && value && typeof value.then === "function") {
      value.then(
        (v) => this.#settle("fulfilled", v),
        (e) => this.#settle("rejected", e)
      );
      return;
    }
 
    this.#state = state;
    this.#value = value;
    this.#callbacks.forEach((cb) => queueMicrotask(cb));
    this.#callbacks = [];
  }
 
  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const run = () => {
        try {
          if (this.#state === "fulfilled") {
            resolve(typeof onFulfilled === "function" ? onFulfilled(this.#value) : this.#value);
          } else {
            if (typeof onRejected === "function") resolve(onRejected(this.#value));
            else reject(this.#value);
          }
        } catch (err) {
          reject(err);
        }
      };
 
      if (this.#state === "pending") this.#callbacks.push(run);
      else queueMicrotask(run);
    });
  }
 
  catch(onRejected) {
    return this.then(undefined, onRejected);
  }
 
  finally(onFinally) {
    return this.then(
      (v) => { onFinally(); return v; },
      (e) => { onFinally(); throw e; }
    );
  }
 
  static resolve(value) { return new MyPromise((res) => res(value)); }
  static reject(reason) { return new MyPromise((_, rej) => rej(reason)); }
}

The three details interviewers look for: settle only once, then handlers run as microtasks even if the promise is already settled, and resolving with a thenable waits for it.

Implement Promise.all

function promiseAll(promises) {
  return new Promise((resolve, reject) => {
    const items = Array.from(promises);
    const results = new Array(items.length);
    let remaining = items.length;
 
    if (remaining === 0) return resolve([]);       // the empty case
 
    items.forEach((item, index) => {
      // wrap so non-promise values work too
      Promise.resolve(item).then((value) => {
        results[index] = value;                    // index preserves order
        remaining -= 1;
        if (remaining === 0) resolve(results);
      }, reject);                                  // first rejection wins
    });
  });
}

Two things people miss: an empty array must resolve immediately, and results must be stored by index rather than pushed, or the order comes back scrambled.

Retry with exponential backoff

In simple words: retry a failing call, waiting longer each time, with a bit of randomness so a thousand clients do not all retry at the same instant.

const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
 
async function retry(fn, { attempts = 3, baseDelay = 300, factor = 2 } = {}) {
  let lastError;
 
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastError = err;
      if (attempt === attempts - 1) break;
 
      const backoff = baseDelay * factor ** attempt;
      const jitter = Math.random() * backoff * 0.3;   // spread the herd
      await sleep(backoff + jitter);
    }
  }
 
  throw lastError;
}

Limiting concurrency

In simple words: run many tasks, but only N at a time. This is the answer to "you have 5,000 URLs to fetch — how?".

async function pool(items, worker, limit = 5) {
  const results = new Array(items.length);
  let cursor = 0;
 
  async function runner() {
    while (cursor < items.length) {
      const index = cursor++;            // claim an index, then await
      results[index] = await worker(items[index], index);
    }
  }
 
  const runners = Array.from({ length: Math.min(limit, items.length) }, runner);
  await Promise.all(runners);
  return results;
}
 
const pages = await pool(urls, (url) => fetch(url).then((r) => r.text()), 5);

Five workers pull from a shared cursor until the list is empty. No batching, so a slow item never stalls the others.

promisify, AbortController and timers

// Turning an error-first callback API into a promise
function promisify(fn) {
  return (...args) =>
    new Promise((resolve, reject) => {
      fn(...args, (err, result) => (err ? reject(err) : resolve(result)));
    });
}
// Cancelling work that is genuinely in flight
const controller = new AbortController();
 
fetch(url, { signal: controller.signal })
  .then((r) => r.json())
  .catch((err) => {
    if (err.name === "AbortError") return;   // expected, not a failure
    throw err;
  });
 
controller.abort();                          // actually stops the request

Timer details worth knowing:

  • setTimeout(fn, 0) does not mean immediately. It means "after the stack empties and all microtasks are done", and browsers clamp nested timers to about 4ms.
  • setInterval drifts. If the callback takes longer than the interval, calls stack up. A self-scheduling setTimeout is safer.
  • requestAnimationFrame runs before the next paint — the right place for animation.
  • requestIdleCallback runs when the browser is doing nothing — the right place for analytics.

Race conditions

In simple words: two async operations finish out of order and the stale one overwrites the fresh one. Extremely common in search boxes.

// BROKEN -- a slow request for "ab" can land after a fast one for "abc"
async function search(term) {
  const results = await api.search(term);
  setResults(results);
}
 
// FIX 1 -- ignore anything that is not the latest
let latestRequest = 0;
async function search(term) {
  const id = ++latestRequest;
  const results = await api.search(term);
  if (id !== latestRequest) return;      // a newer request has started
  setResults(results);
}
 
// FIX 2 -- cancel the previous request outright
let controller;
async function search(term) {
  controller?.abort();
  controller = new AbortController();
  try {
    const results = await api.search(term, { signal: controller.signal });
    setResults(results);
  } catch (err) {
    if (err.name !== "AbortError") throw err;
  }
}

Async iterators and generators

In simple words: for await...of lets you loop over values that arrive over time — paginated APIs, streams, queues.

async function* paginate(url) {
  let next = url;
  while (next) {
    const res = await fetch(next);
    const page = await res.json();
    yield* page.items;              // yield each item individually
    next = page.nextUrl;
  }
}
 
for await (const item of paginate("/api/orders")) {
  process(item);                    // pages fetched lazily, as needed
}

The win is memory: you never hold all pages at once, and you can break early and stop fetching.

Ten questions

  1. 01
    Predict the output: a sync log, a setTimeout zero, a Promise.resolve().then, a queueMicrotask, then another sync log.
  2. 02
    You have three independent API calls and this code takes 300ms. What is wrong?
  3. 03
    Five API calls, one may fail, and the page can still render without it. all or allSettled?
  4. 04
    Does Promise.all cancel the other requests when one rejects?
  5. 05
    What is the difference between debounce and throttle, with a use case for each?
  6. 06
    In your own Promise implementation, why must then callbacks be scheduled as microtasks even when the promise is already settled?
  7. 07
    When implementing Promise.all, why store results by index instead of pushing?
  8. 08
    You have 5,000 URLs to fetch. What is wrong with Promise.all(urls.map(fetch)), and what do you do instead?
  9. 09
    A user types quickly in a search box and sees results for an earlier query. What is happening and how do you fix it?
  10. 10
    In Node, what is the difference between process.nextTick, a promise callback, and setImmediate?

Seven exercises

  1. 01

    Implement MyPromise

    Support then, catch, finally and the static resolve and reject. It must settle only once, schedule callbacks as microtasks, and wait when resolved with another thenable. Test it by chaining four steps with a throw in the middle.

  2. 02

    Implement promiseAll and promiseAllSettled

    Preserve input order, handle an empty array, and accept plain values mixed in with promises. Then write promiseAny and decide what it should do when every input rejects.

  3. 03

    Write debounce with leading and trailing options

    Support both edges, add a cancel method, and make sure this and the arguments are forwarded correctly. Then write throttle and prove with logs that they behave differently under a burst of 20 rapid calls.

  4. 04

    Build a concurrency pool

    Write pool(items, worker, limit) that keeps exactly limit tasks in flight. Prove it with 20 tasks of random duration and a limit of 3, logging start and finish times.

  5. 05

    Retry with backoff and jitter

    Retry only on the failures that deserve it — network errors, timeouts, 429 and 5xx — and never on a 400. Add a maximum total wait so it cannot retry forever.

  6. 06

    Fix a race condition twice

    Write a search function with the stale-response bug, confirm it by making the first request artificially slow, then fix it once with a request id and once with AbortController. Say which you would ship.

  7. 07

    Paginate with an async generator

    Write async function* that walks a paginated API and yields items one at a time. Consume it with for await...of, break after 10 items, and confirm that no further pages were requested.

Common questions

What is the difference between microtasks and macrotasks?
Microtasks include promise callbacks, await continuations and queueMicrotask. Macrotasks include setTimeout, setInterval, I/O callbacks and DOM events. The event loop empties the entire microtask queue before taking a single macrotask, which is why a resolved promise callback always runs before a setTimeout of zero.
Why is awaiting several API calls one after another slow?
Each await pauses until that call finishes, so three independent 100ms calls take 300ms in total. If none of them needs the previous result, start them together with Promise.all and the total becomes the slowest single call, roughly 100ms. Only await sequentially when the next call genuinely depends on the previous one.
When should you use Promise.allSettled instead of Promise.all?
Use Promise.all when the result is worthless unless every call succeeds, because it rejects as soon as any one fails. Use Promise.allSettled when partial success is acceptable, since it waits for every promise to settle and reports each outcome separately rather than discarding successful results because of one failure.
What is the difference between debounce and throttle?
Debounce waits until activity stops and then runs once, which suits search-as-you-type where you only want a request after the user pauses. Throttle runs at most once per interval regardless of how much activity there is, which suits scroll and resize handlers where you need regular updates during the activity itself.
How do you fix a race condition when fetching data?
A race condition happens when a slower earlier request resolves after a faster later one and overwrites it. Fix it either by tracking a request id and discarding any response that is not the latest, or by aborting the previous request with an AbortController before starting a new one. In React, do this in the effect cleanup function.