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
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()
// 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.
// 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 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';
}
}| Combinator | Settles when | Rejection behavior |
|---|---|---|
| Promise.all | Every promise fulfills | Rejects immediately on the FIRST rejection -- other results are discarded |
| Promise.allSettled | Every promise settles (fulfilled or rejected) | Never rejects -- resolves with a status/value-or-reason object per input |
| Promise.race | The first promise to settle, fulfilled or rejected | Resolves or rejects matching whichever promise settled first |
| Promise.any | The first promise to FULFILL | Only rejects if ALL promises reject, with an AggregateError |
// 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.