Topics in this subject
JavaScript 4 min read Updated 4 Aug 2026

Part 16 — Asynchronous JavaScript

Why async exists, The event loop, precisely 🎯, Callbacks & callback hell, Promises, async / await

This is the chapter that separates juniors from seniors. Read it twice.

Why async exists#

JS is single-threaded. If a network call blocked the thread, the page would freeze. So slow work is offloaded to the environment (browser/Node), and JS is notified when it's done via the event loop.

The event loop, precisely 🎯#

graph TD
    CS["Call Stack<br/>(runs synchronous code)"] -->|calls| WA["Web/Node APIs<br/>timers, fetch, I/O"]
    WA -->|timer fires / IO done| MAC["Macrotask Queue<br/>(setTimeout, setInterval, I/O, UI events)"]
    WA -->|promise resolves| MIC["Microtask Queue<br/>(.then, await, queueMicrotask)"]
    EL{"Event Loop"}
    EL -->|"1. stack empty?"| EL
    EL -->|"2. drain ALL microtasks"| MIC
    EL -->|"3. run ONE macrotask"| MAC
    MIC --> CS
    MAC --> CS

The algorithm: run all synchronous code (stack empties) → drain the entire microtask queue → render (in browsers) → take one macrotask → repeat. Microtasks always beat macrotasks and even starve them if they keep enqueuing.

console.log(1);
setTimeout(() => console.log(2), 0);      // macrotask
Promise.resolve().then(() => console.log(3)); // microtask
console.log(4);
// Output: 1, 4, 3, 2   🎯
// sync (1,4) → microtask (3) → macrotask (2)
  • Microtasks: promise callbacks (.then/.catch/.finally), await continuations, queueMicrotask, MutationObserver.
  • Macrotasks: setTimeout, setInterval, setImmediate (Node), I/O, UI events, MessageChannel.

Callbacks & callback hell#

getUser(id, (u) => {
  getOrders(u, (o) => {
    getDetails(o, (d) => {          // 😱 the pyramid of doom
      console.log(d);
    });
  });
});

Problems: nesting, error handling at every level, inversion of control (you trust the callee to call your callback correctly, once).

Promises#

A Promise is an object representing a future value in one of three states: pending → fulfilled | rejected (settling is one-way and permanent).

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("done"), 100);
});
p.then(v => console.log(v))        // "done"
 .catch(e => console.error(e))
 .finally(() => console.log("cleanup"));

Chaining — each .then returns a new promise; returning a value passes it on, returning a promise waits for it:

fetch(url)
  .then(r => r.json())      // returns a promise → chain waits
  .then(data => data.items)
  .then(items => render(items))
  .catch(handle);           // catches any rejection in the chain

⚠️ Common promise traps 🎯:

  • Not returning inside .then breaks the chain (.then(x => { doAsync(x); }) doesn't wait).
  • A missing .catch → unhandled rejection.
  • .then(fn) where fn throws → next .catch handles it.

async / await#

Syntactic sugar over promises. async functions always return a promise; await pauses the function until the awaited promise settles (without blocking the thread).

async function load() {
  try {
    const r = await fetch(url);
    if (!r.ok) throw new Error(r.status);
    const data = await r.json();
    return data;
  } catch (e) {
    console.error(e);
    throw e;               // rethrow to let callers handle
  }
}

Sequential vs parallel — the single most common performance mistake 🎯:

// ❌ sequential — waits 3x
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();

// ✅ parallel — waits 1x (they're independent)
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

Promise combinators#

Method Resolves when Rejects when Result
Promise.all all fulfill any rejects array of values (fail-fast)
Promise.allSettled all settle never array of {status, value/reason}
Promise.race first settles first settles rejected that value/reason
Promise.any first fulfills all reject value / AggregateError
await Promise.allSettled(tasks);   // when you want every result, success or fail
await Promise.any([mirror1, mirror2]); // first success wins

Promise.withResolvers() 🟢 (ES2024) returns { promise, resolve, reject } — handy for deferred patterns.

Timers & scheduling#

const id = setTimeout(fn, 1000);  clearTimeout(id);
const iv = setInterval(fn, 1000); clearInterval(iv);
queueMicrotask(fn);               // schedule a microtask directly
requestAnimationFrame(fn);        // before next paint (~60fps), browser only

⚠️ setTimeout(fn, 0) is not 0ms — it's "after the current task and any queued work, min ~4ms clamp for nested timers."

fetch & AbortController#

const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 5000);   // timeout
try {
  const res = await fetch(url, { signal: ctrl.signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`); // ⚠️ fetch only rejects on network error, not 404/500
  const data = await res.json();
} catch (e) {
  if (e.name === "AbortError") console.log("cancelled");
} finally { clearTimeout(t); }

for await...of (async iteration)#

for await (const chunk of streamReader) {
  process(chunk);   // handles async iterables / streams sequentially
}

Interview questions (Part 16):

  1. Predict the log order of the console.log(1); setTimeout; Promise.then; console.log(4) snippet and explain micro vs macro.
  2. What are the promise states and are they reversible?
  3. Promise.all vs allSettled vs race vs any?
  4. Show sequential vs parallel await and when each is correct.
  5. Does fetch reject on a 404? How do you time out a fetch?
  6. Why does await inside forEach not work?

Practice:

  • Implement promisify(fn) converting a callback-style function to a promise.
  • Implement Promise.all from scratch.
  • Build a retry-with-backoff wrapper around fetch.