JavaScript Async and the Event Loop


  • Description: The event loop, callbacks, Promises, async/await, error handling across async boundaries, microtasks vs macrotasks, and concurrency patterns (Promise.all, Promise.race, …).
  • My Notion Note ID: K2A-F4-6
  • Created: 2018-03-23
  • Updated: 2026-05-17
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. The Single-Threaded Model

JavaScript runs on a single thread. There is one call stack, one heap, one task queue. Async APIs don't introduce parallelism — they let the runtime do work elsewhere (network, timer, disk) while JS waits, then queue a callback for the engine to pick up later.

Two consequences:

  • No race conditions on shared variables. A function runs to completion before any other code can mutate the same state.
  • A blocking computation freezes the page — UI thread is the same thread. Heavy work belongs on a Worker or WebAssembly thread.

Web Workers / Service Workers / Worklets run on separate threads, communicating via message passing. They have separate event loops.

2. The Event Loop

┌──────────────────┐
│   Call Stack     │ ← currently executing
└────────┬─────────┘
         │ (pop when empty)
┌────────▼─────────┐
│ Microtask queue  │ ← promise callbacks, queueMicrotask
└────────┬─────────┘
         │ (drain completely between macrotasks)
┌────────▼─────────┐
│ Macrotask queue  │ ← setTimeout, setInterval, I/O, UI events
└──────────────────┘

One iteration ("tick") of the loop:

  1. Take one macrotask, run it to completion (synchronous code + scheduled microtasks).
  2. Drain the microtask queue completely before the next tick.
  3. (Browser only) Render if needed.
  4. Repeat.

Microtasks scheduled inside microtasks keep extending the current drain — a poorly written promise-recursion can starve rendering.

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

// Output: 1, 5, 3, 4, 2

Sync code runs first (1, 5), then microtasks (3, 4), then the next macrotask (2).

3. Timers and Scheduling

setTimeout(fn, ms);            // macrotask after delay
setInterval(fn, ms);            // recurring macrotask
clearTimeout(id);
clearInterval(id);
queueMicrotask(fn);             // before next macrotask
requestAnimationFrame(fn);      // before next paint (~60Hz)
requestIdleCallback(fn);        // when main thread is idle

setTimeout(fn, 0) is not immediate — it queues a macrotask. Minimum delay is clamped (4ms after 5 nested timeouts in browsers).

setInterval drifts if a callback runs longer than the interval. For precise pacing, use chained setTimeout or requestAnimationFrame.

In Node-only environments: process.nextTick(fn) runs before microtasks; rarely needed.

4. Callbacks — the Original Async Pattern

fs.readFile('a.txt', (err, data) => {
  if (err) return handle(err);
  // ...
});

Node-style: err-first callback. Errors as first arg, data as second.

The pain:

  • Deeply nested code ("callback hell").
  • Inconsistent error handling — easy to forget to check err.
  • Hard to compose (parallel, race, retry).

Promises were designed to fix these.

5. Promises

A Promise is an object representing the eventual completion (or failure) of an async operation. Three states:

  • pending — not settled.
  • fulfilled — settled with a value.
  • rejected — settled with a reason.

Once settled, it's immutable.

const p = new Promise((resolve, reject) => {
  doAsync((err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

p
  .then(value => /* fulfilled */)
  .catch(err  => /* rejected */)
  .finally(() => /* settled either way */);

5.1 Chaining

Each then / catch returns a new promise:

fetch(url)
  .then(resp => resp.json())
  .then(data => transform(data))
  .then(result => render(result))
  .catch(err => showError(err));

Throwing inside a then becomes a rejection. Returning a promise from then causes the chain to wait on it. Returning a value forwards it as the next then's resolution.

5.2 Static Combinators

Method Settles when
Promise.all([p1, p2, …]) All fulfilled (→ array of values) or any rejected (→ that reason).
Promise.allSettled([…]) All settled. Returns { status, value? , reason? }[]. Never rejects.
Promise.race([…]) First to settle, either way.
Promise.any([…]) First to fulfil. Rejects with AggregateError if all reject.
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);

const results = await Promise.allSettled(urls.map(fetch));
const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value);

Promise.all short-circuits on first rejection — other operations keep running, but you stop waiting for them. Use allSettled if you want every result, success or failure.

5.3 Promise.resolve / reject / try

Promise.resolve(42);                  // pre-resolved
Promise.reject(new Error('nope'));    // pre-rejected
Promise.try(() => maybeAsync());       // ES2025 — wraps sync throws as rejections

Promise.try simplifies "this function might be sync or async, errors might be thrown or returned":

// Without
const p = new Promise((res) => res()).then(() => maybeAsync());

// With
const p = Promise.try(() => maybeAsync());

5.4 Promise.withResolvers (ES2024)

const { promise, resolve, reject } = Promise.withResolvers();
// pass `resolve` and `reject` elsewhere, return `promise` now

Cleaner than the let res; const p = new Promise(r => res = r); dance.

6. async / await

async functions return a Promise. await pauses execution until the awaited value settles.

async function load(url) {
  const resp = await fetch(url);
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  const data = await resp.json();
  return data;          // becomes the fulfilled value
}

const data = await load('/api/users');   // top-level await in modules

Equivalent without async/await:

function load(url) {
  return fetch(url)
    .then(resp => {
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      return resp.json();
    });
}

async/await is sugar over Promises. Same runtime semantics, much easier to read.

6.1 Sequential vs Parallel

// Sequential — second request waits for first
const a = await fetch('/a');
const b = await fetch('/b');

// Parallel — both kicked off, then awaited
const [a, b] = await Promise.all([fetch('/a'), fetch('/b')]);

The mistake is easy to miss. If await'ed values are independent, kick them off in parallel.

6.2 Top-Level await

Available in ES modules:

// module.mjs
const config = await loadConfig();
export { config };

Top-level await delays module evaluation. Consumers transitively wait.

7. Error Handling

async function safe() {
  try {
    return await load(url);
  } catch (err) {
    if (err instanceof NetworkError) return null;
    throw err;     // re-throw what you don't handle
  } finally {
    cleanup();
  }
}

Promise chain equivalent:

load(url)
  .then(...)
  .catch(err => {
    if (err instanceof NetworkError) return null;
    throw err;
  })
  .finally(cleanup);

7.1 Unhandled Rejections

A promise that rejects with no .catch (and never gets one) triggers unhandledrejection in the browser, or process.unhandledRejection in Node. Always:

  • Add .catch to the end of a promise chain you start.
  • await inside a try/catch, or attach .catch to the promise you store.

Easy footgun:

async function go() { throw new Error('x'); }
go();                  // unhandled rejection — no await, no .catch

ESLint's no-floating-promises (TS) and no-misused-promises catch most of these.

7.2 AbortController

Cancel an in-flight request:

const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);

try {
  const resp = await fetch(url, { signal: ctrl.signal });
} catch (err) {
  if (err.name === 'AbortError') return null;
  throw err;
}

AbortSignal.timeout(ms), AbortSignal.any([…]) and AbortSignal.abort() are the modern combinators.

8. Concurrency Patterns

8.1 Sleep

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

await sleep(1000);

No built-in sleep in JS — this pattern is universal.

8.2 Timeout

function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
  ]);
}

const data = await withTimeout(fetch(url), 5_000);

AbortSignal.timeout(5_000) is the modern way — also cancels the underlying request.

8.3 Retry With Backoff

async function retry(fn, { attempts = 3, delay = 1000 } = {}) {
  let lastErr;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      await sleep(delay * 2 ** i);    // exponential
    }
  }
  throw lastErr;
}

8.4 Concurrency-Limited Map

Process N items with at most K in-flight at once:

async function pMap(items, fn, concurrency) {
  const results = [];
  const executing = new Set();
  for (const item of items) {
    const p = Promise.resolve().then(() => fn(item));
    results.push(p);
    executing.add(p);
    p.finally(() => executing.delete(p));
    if (executing.size >= concurrency) await Promise.race(executing);
  }
  return Promise.all(results);
}

const data = await pMap(urls, fetch, 6);

Libraries: p-limit, p-map. Inline this when you don't want a dep.

9. Streams and Async Iteration

fetch response bodies, files, server-sent events are async iterables:

const resp = await fetch(url);
for await (const chunk of resp.body) {
  process(chunk);
}

Build your own with async generators:

async function* tail(url) {
  while (true) {
    const data = await fetch(url).then(r => r.json());
    yield data;
    await sleep(1000);
  }
}

for await (const update of tail('/api/status')) {
  render(update);
}

Web Streams API (ReadableStream, WritableStream, TransformStream) is the lower-level primitive — most app code uses async iteration directly.

10. References