JavaScript executes on a single thread -- one Call Stack, one thing happening at a time. Yet a browser tab can run a network request, an animation, and a click handler without freezing. That apparent concurrency is not inside the JavaScript engine at all -- it is a runtime-level orchestration between the engine and the host environment (the browser or Node's libuv). This module dissects exactly how that orchestration works.


1. The Single-Threaded Runtime: Four Moving Parts

A JavaScript runtime has four cooperating pieces, and confusing any two of them is the root of most Event Loop misunderstandings: the Call Stack (part of the JS engine), the Web APIs / Node C++ APIs (part of the HOST, not the engine), the Macrotask (Callback) Queue, and the Microtask Queue.

What each piece actually does

  • Call Stack: tracks currently executing function frames, LIFO. The engine can ONLY execute code that is on the Call Stack -- if the stack isn't empty, nothing else runs, period
  • Web APIs / Node C++ APIs: live OUTSIDE the JS engine entirely. setTimeout, fetch, DOM events, and file I/O are handled by the browser or by libuv's thread pool -- the engine hands off the work and immediately continues, never blocking
  • Macrotask Queue: when a Web API finishes (a timer fires, a click happens, an I/O callback is ready), its callback is placed here -- one macrotask is processed per Event Loop tick
  • Microtask Queue: Promise .then/.catch/.finally callbacks and queueMicrotask() land here -- and this queue is drained COMPLETELY (including microtasks that schedule more microtasks) before the next macrotask is allowed to run
  • The Event Loop is the mechanism that continuously checks: is the Call Stack empty? If yes, drain the entire Microtask Queue, then pull exactly ONE task from the Macrotask Queue onto the Call Stack, then repeat
event-loop-ordering.jsjavascript
console.log('1: sync');

setTimeout(() => console.log('2: macrotask (setTimeout)'), 0);

Promise.resolve().then(() => console.log('3: microtask (promise)'));

queueMicrotask(() => console.log('4: microtask (explicit)'));

console.log('5: sync');

// Output order: 1, 5, 3, 4, 2
// WHY: synchronous code (1, 5) runs first because it's already on the Call Stack.
// Once the stack empties, ALL queued microtasks (3, 4) drain BEFORE the engine
// even looks at the macrotask queue -- this is true even though setTimeout(..., 0)
// requests zero delay. Only after the microtask queue is fully empty does the
// single macrotask (2) get pulled onto the stack.

2. Promises Under the Hood

A Promise is a state machine wrapping an eventual value. Internally (per spec) it holds [[PromiseState]] (pending, fulfilled, or rejected), [[PromiseResult]] (the value or reason once settled), and two internal reaction lists ([[PromiseFulfillReactions]] / [[PromiseRejectReactions]]) that queue up the callbacks registered via .then() while the promise is still pending.

The state machine's hard rules

  • A promise starts pending and can transition to fulfilled or rejected EXACTLY ONCE -- once settled, it is permanently locked; calling the executor's resolve or reject again is a no-op
  • Every .then(onFulfilled, onRejected) call returns a BRAND NEW promise -- this is what makes chaining possible, since each link in the chain is its own independent state machine
  • If onFulfilled/onRejected returns a plain value, the new promise resolves with that value; if it returns another thenable/promise, the new promise 'adopts' that promise's eventual state (this adoption step is why you can return a promise from inside a .then and the chain still flattens correctly)
  • If onFulfilled/onRejected throws, the new promise automatically rejects with the thrown error -- this is how try/catch-like propagation happens through a .then chain without you manually calling reject()
mini-promise.jsjavascript
// A barebones, functionally correct Promise/A+-style implementation, to expose
// the exact mechanics .then() and the microtask queue provide for free.
class MiniPromise {
  #state = 'pending';
  #value;
  #callbacks = []; // { onFulfilled, onRejected, resolveNext, rejectNext } pairs waiting for settlement

  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; // settle exactly once -- ignore all further calls
    this.#state = state;
    this.#value = value;
    // Flush every callback registered while we were pending. queueMicrotask
    // is what gives Promises their microtask-queue scheduling semantics.
    this.#callbacks.forEach((cb) => queueMicrotask(() => this.#run(cb)));
    this.#callbacks = [];
  }

  #run({ onFulfilled, onRejected, resolveNext, rejectNext }) {
    const handler = this.#state === 'fulfilled' ? onFulfilled : onRejected;
    if (typeof handler !== 'function') {
      // No handler for this branch -- propagate the current state to the next promise
      this.#state === 'fulfilled' ? resolveNext(this.#value) : rejectNext(this.#value);
      return;
    }
    try {
      const result = handler(this.#value);
      // Adoption: if the handler returned a thenable, chain onto ITS eventual state
      if (result && typeof result.then === 'function') {
        result.then(resolveNext, rejectNext);
      } else {
        resolveNext(result);
      }
    } catch (err) {
      rejectNext(err); // a thrown error becomes the next promise's rejection
    }
  }

  then(onFulfilled, onRejected) {
    return new MiniPromise((resolveNext, rejectNext) => {
      const callback = { onFulfilled, onRejected, resolveNext, rejectNext };
      if (this.#state === 'pending') {
        this.#callbacks.push(callback);
      } else {
        queueMicrotask(() => this.#run(callback)); // already settled -- still async, per spec
      }
    });
  }
}

new MiniPromise((resolve) => resolve(1))
  .then((v) => v + 1)
  .then((v) => { throw new Error(`failed at ${v}`); })
  .then(null, (err) => console.log('caught:', err.message)); // 'caught: failed at 2'

3. Async/Await: Generators in a Trench Coat

async/await is syntax, not a new concurrency primitive -- every async function returns a Promise, and await is sugar over exactly the pattern that generator-runner libraries used before async/await shipped: a generator that yields a promise, paused until that promise settles, then resumes with the result injected back in.

async-await-desugared.jsjavascript
// What you write:
async function fetchUserAge(id) {
  const user = await fetchUser(id);   // pauses here without blocking the thread
  const age = await fetchAge(user);   // pauses here too
  return age;
}

// The conceptual desugaring using a generator + a runner function:
function fetchUserAgeGenerator(id) {
  return function* () {
    const user = yield fetchUser(id); // `yield` hands control back to the runner
    const age = yield fetchAge(user);
    return age;
  };
}

// A minimal generator runner -- this loop is what `async` effectively compiles to.
function run(generatorFn) {
  const iterator = generatorFn();
  return new Promise((resolve, reject) => {
    function step(input) {
      let result;
      try {
        result = iterator.next(input); // resume the generator, injecting the resolved value
      } catch (err) {
        return reject(err); // a thrown error inside the generator maps to Promise rejection
      }
      if (result.done) return resolve(result.value);
      // result.value is the promise the generator yielded -- wait for it, then step again
      Promise.resolve(result.value).then(step, (err) => iterator.throw(err));
    }
    step();
  });
}

4. Error Handling & Orchestration

Because await desugars to .then(), a try/catch wrapped around an await expression catches promise rejections exactly the way it catches thrown synchronous errors -- but only for promises you actually await. A common trap is 'fire and forget' async calls that reject with nobody listening.

bad-vs-architect-async-errors.jsjavascript
// BAD WAY (Don't Do This): the try/catch can't see this rejection --
// processPayment() is called but never awaited, so its rejection becomes
// an UNHANDLED PROMISE REJECTION, invisible to the surrounding try/catch.
async function checkoutBad(cart) {
  try {
    processPayment(cart); // missing `await` -- fires, catch block never sees the failure
    return 'success';
  } catch (err) {
    return 'failed'; // never reached even if processPayment ultimately rejects
  }
}

// ARCHITECT WAY (Do This): await it, so its rejection is routed into the catch block.
async function checkoutGood(cart) {
  try {
    await processPayment(cart);
    return 'success';
  } catch (err) {
    return 'failed';
  }
}
CombinatorSettles whenRejection behavior
Promise.allEvery promise fulfillsRejects immediately on the FIRST rejection -- other results are discarded
Promise.allSettledEvery promise settles (fulfilled or rejected)Never rejects -- resolves with a status/value-or-reason object per input
Promise.raceThe first promise to settle, fulfilled or rejectedResolves or rejects matching whichever promise settled first
Promise.anyThe first promise to FULFILLOnly rejects if ALL promises reject, with an AggregateError
orchestration-patterns.jsjavascript
// Sequential -- each request waits for the previous one. Use ONLY when request N+1
// genuinely depends on the result of request N.
async function sequential(ids) {
  const results = [];
  for (const id of ids) {
    results.push(await fetchUser(id)); // total time = sum of all request latencies
  }
  return results;
}

// Parallel -- all requests fire immediately, independent of each other.
// Use whenever requests do NOT depend on each other's results.
async function parallel(ids) {
  const promises = ids.map((id) => fetchUser(id)); // fired eagerly, before any await
  return Promise.all(promises); // total time = the SLOWEST single request latency
}

// Parallel, fault-tolerant -- get partial results even if some requests fail.
async function parallelResilient(ids) {
  const outcomes = await Promise.allSettled(ids.map((id) => fetchUser(id)));
  return outcomes
    .filter((o) => o.status === 'fulfilled')
    .map((o) => o.value);
}

Module 3 Recap

You now know precisely why setTimeout(fn, 0) never runs before a pending .then() (the Microtask Queue always drains first), how a Promise's internal state machine and reaction queues produce chaining and error propagation, how async/await is a generator-runner pattern that pauses an execution context without blocking the Event Loop, and when to reach for Promise.all versus allSettled versus race versus any. Module 4 goes one level deeper into the engine itself: how V8's JIT compiler, Hidden Classes, and garbage collector turn this code into fast, running machine instructions -- and how to avoid deoptimizing it.