JS Internals
Chapter 06SpecHostEngine4 runnable probes

The Execution Model: Agents, Jobs, Promises, and the Event Loop

ECMA-262 defines Jobs. It does not define an event loop. The host does, and the difference matters.

Asynchrony in JavaScript is layered: the specification defines agents, execution contexts, and a Job queue abstraction with exactly one ordering guarantee; HTML and Node then define task sources, microtask checkpoints, and phase ordering on top. Promise reaction timing, the `await` tick count, and ordering puzzles are all derivable once the layers are separated.

In this chapter
  1. Agents, execution contexts, and the running execution context
  2. Jobs: the specification's minimal asynchrony primitive
  3. Promise internals: reactions, resolving functions, and the thenable job
  4. `await` desugared: PromiseResolve, PerformPromiseThen, and suspension
  5. Generators and async generators as suspended execution contexts
  6. Starvation and the cost model of microtasks

Agents, execution contexts, and the running execution context

An agent is the specification's unit of sequential execution: an execution context stack, a running execution context, a set of Jobs, a set of Realms, and a thread on which all of that executes. A browser main thread is one agent; each Web Worker is another; a Node.js process has one plus one per worker_thread. Agents are grouped into agent clusters that may share memory (SharedArrayBuffer); agents in different clusters cannot. The [[CanBlock]] property of an agent decides whether Atomics.wait is permitted: false on the browser main thread, true in workers and in Node.

Within an agent, evaluation is strictly single-threaded and stack-based. Each function call pushes an execution context (code evaluation state, Function, Realm, ScriptOrModule, LexicalEnvironment, VariableEnvironment, PrivateEnvironment) and each return pops it. Generators and async functions are the only constructs that suspend an execution context and remove it from the stack while keeping it alive for later resumption; this is done by the abstract operations GeneratorStart/GeneratorResume/GeneratorYield and AsyncBlockStart/Await, which store the suspended context in the generator object's [[GeneratorContext]] slot.

Jobs: the specification's minimal asynchrony primitive

A Job is an Abstract Closure with no parameters that is run at some later point when the execution context stack is empty. The specification only ever enqueues two kinds: PromiseReactionJob (run a then callback) and PromiseResolveThenableJob (call a thenable's then method). It hands them to the host through HostEnqueuePromiseJob(job, realm) with a small set of requirements: jobs run in FIFO order per realm, each job runs to completion with an empty stack, and a job enqueued by another job in the same realm runs before any job from a different source. Everything else, including when relative to rendering, I/O, or timers a job runs, is the host's decision.

Node.js implements the loop with libuv and has a different topology: timers phase, pending callbacks, poll (I/O), check (setImmediate), close callbacks; plus two Node-specific queues that drain between every phase transition and after every callback: the process.nextTick queue first, then the promise microtask queue. nextTick therefore runs before promise reactions even when enqueued after them, and setImmediate versus setTimeout(fn, 0) ordering from the main module is non-deterministic (it depends on whether the timer has expired by the time the loop enters the timers phase) while from within an I/O callback setImmediate always wins.

ProbeTask versus microtask ordering
const log = [];
setTimeout(() => log.push("setTimeout 0"), 0);
Promise.resolve().then(() => log.push("promise.then #1"));
queueMicrotask(() => {
  log.push("queueMicrotask");
  Promise.resolve().then(() => log.push("promise.then inside microtask (still same checkpoint)"));
});
const ch = new MessageChannel();
ch.port1.onmessage = () => log.push("MessageChannel (a task, usually before setTimeout 0)");
ch.port2.postMessage(null);
(async () => { log.push("async fn body runs synchronously"); await null; log.push("after await"); })();
log.push("sync end");

await new Promise((r) => setTimeout(r, 20));
console.log(log.join("\n"));

MessageChannel is often the fastest way to schedule a task (not a microtask) in browsers, because HTML clamps nested setTimeout to ≥ 4 ms after five levels of nesting.

Promise internals: reactions, resolving functions, and the thenable job

A Promise has [[PromiseState]] (pending/fulfilled/rejected), [[PromiseResult]], and two lists of PromiseReaction records: [[PromiseFulfillReactions]] and [[PromiseRejectReactions]]. then(onFulfilled, onRejected) creates a reaction pair with a derived promise (via SpeciesConstructor and NewPromiseCapability) and either appends them (if pending) or immediately enqueues a PromiseReactionJob (if already settled). Settling a promise (FulfillPromise/RejectPromise) enqueues one job per registered reaction, in registration order. This is why then callbacks on an already-resolved promise still run asynchronously: the job is enqueued, never run inline.

The resolving functions created by CreateResolvingFunctions implement the assimilation rule that makes Promise a monad-like structure that never nests. resolve(v): if v is the promise itself, reject with TypeError; if v is not an Object, fulfil; otherwise Get(v, "then") (which can throw, and if so rejects); if then is not callable, fulfil with v; otherwise enqueue a NewPromiseResolveThenableJob that will call then.call(v, resolve2, reject2) with a fresh pair of resolving functions. Resolving with a thenable therefore costs two extra Jobs compared to resolving with a plain value: one for the thenable job itself, and one for the reaction that then registers.

ProbeCounting ticks: plain value, native promise, and thenable
// Each line records at which microtask "tick" it observed its value.
let tick = 0;
const bump = () => { tick++; if (tick < 12) Promise.resolve().then(bump); };
Promise.resolve().then(bump);              // a metronome: it is always first in each "round" of the queue

const at = (label) => (v) => console.log(String(tick).padStart(2), label, v === undefined ? "" : v);

Promise.resolve(1).then(at("resolved with a plain value"));
new Promise((res) => res(Promise.resolve(2))).then(at("resolved with a native promise (+2: thenable job, then its reaction)"));
new Promise((res) => res({ then(r) { r(3); } })).then(at("resolved with a synchronously-resolving thenable (+1: only the thenable job)"));
Promise.resolve().then(() => 4).then(at("chained .then returning a value (+1 per hop)"));
Promise.resolve().then(() => Promise.resolve(5)).then(at("chained .then returning a promise (+2 extra, thenable assimilation)"));
(async () => 6)().then(at("async fn returning a value"));
(async () => Promise.resolve(7))().then(at("async fn returning a promise (also +2, not optimised)"));
(async () => { const v = await Promise.resolve(8); return v; })().then(at("await a native promise then return (await is 1 tick since 2019)"));

await new Promise((r) => setTimeout(r, 30));

The await fast path (V8 7.2, spec change in ES2019 via PromiseResolve) applies only to awaiting a native promise; returning a promise from an async function still goes through the resolving-function thenable path.

Why `return await p` is not the same as `return p` inside try/catch

return p in an async function resolves the async function's promise with p, via the resolving functions, which schedules the thenable job; the function's own body has already finished, so a catch around the return cannot observe p's rejection. return await p suspends the body until p settles and, if it rejects, throws inside the body where catch can see it. Outside a try block the two are observationally equivalent apart from tick count; inside one they differ in behaviour, which is why lint rules for no-return-await exempt the try/catch case.

`await` desugared: PromiseResolve, PerformPromiseThen, and suspension

Await(v) performs: promise = ? PromiseResolve(%Promise%, v) (returns v itself if v is a native promise whose constructor is %Promise%, otherwise wraps it); creates two Abstract Closures that, when called, resume the suspended execution context with a normal or throw completion; calls PerformPromiseThen(promise, onFulfilled, onRejected) with no derived promise; then removes the current execution context from the stack and returns to the caller. Because PerformPromiseThen is used rather than then, user-visible Promise.prototype.then patches are not invoked by await, and no intermediate promise is allocated. The constructor check is the one observable hook: an object with [[PromiseState]] but a different constructor is treated as a foreign thenable and re-wrapped.

Probeawait does not call a monkey-patched then
const p = Promise.resolve("value");
const originalThen = Promise.prototype.then;
let patchedCalls = 0;
Promise.prototype.then = function (...args) { patchedCalls++; return originalThen.apply(this, args); };

try {
  const v = await p;                         // PerformPromiseThen directly: no .then lookup
  console.log("awaited:", v, "| patched then called:", patchedCalls, "time(s)");
  const q = { then(resolve) { resolve("thenable"); } };
  console.log("awaited thenable:", await q, "| the thenable's own then IS called via NewPromiseResolveThenableJob");
  const sub = Object.assign(Promise.resolve("sub"), { constructor: function Sub() {} });
  await sub;                                 // constructor !== %Promise% -> wrapped, so .then IS looked up
  console.log("after awaiting a promise with a foreign constructor, patched then called:", patchedCalls, "time(s)");
} finally {
  Promise.prototype.then = originalThen;
}

Generators and async generators as suspended execution contexts

A generator object holds [[GeneratorState]] (suspended-start, suspended-yield, executing, completed) and [[GeneratorContext]], the execution context that was on the stack when yield ran. next(v) (GeneratorResume) checks the state (re-entrancy throws TypeError, hence generators are not re-entrant even indirectly), pushes the saved context back on the stack, and resumes evaluation with `v` as the completion value of the `yield` expression. return(v) and throw(e) (GeneratorResumeAbrupt) resume with a return or throw Completion Record at the yield point, which is what makes try/finally inside a generator run when the consumer stops early; the for…of loop calls IteratorClose, which calls return(), on break, throw, or a destructuring pattern that does not consume the whole sequence.

Async generators add a request queue ([[AsyncGeneratorQueue]]): every next()/return()/throw() call pushes a request with its own promise capability and returns immediately, even if the generator is executing. Requests are processed strictly in order when the generator reaches a yield or completes, which is why concurrent next() calls on an async generator are safe and serialised, unlike concurrent read() calls on a hand-written async iterator. Additionally, yield v inside an async generator awaits v before yielding it (AsyncGeneratorYield performs Await(value)), and for await awaits each next() result's value, so an async generator never yields a promise as a value.

ProbeResumption values, abrupt completions, and IteratorClose
function* gen() {
  try {
    const a = yield "first";                 // `a` is whatever next() was called with
    console.log("resumed with", a);
    const b = yield "second";
    console.log("resumed with", b);
  } finally {
    console.log("finally: the consumer stopped, or we completed");
  }
}
const g = gen();
console.log(g.next("ignored: first next() argument is dropped"));
console.log(g.next("A"));
console.log(g.return("early"));             // resumes at yield with a return completion -> finally runs
console.log(g.next(), "<- completed; no re-entry");

// Destructuring calls IteratorClose (return()) after taking what it needs.
const [x, y] = gen();
console.log("destructured", x, y);

// Async generator: requests are queued, then served in order.
async function* agen() { yield 1; yield 2; }
const it = agen();
const p1 = it.next(), p2 = it.next(), p3 = it.next();   // three concurrent requests
console.log((await Promise.all([p1, p2, p3])).map((r) => JSON.stringify(r)).join(" "));

Starvation and the cost model of microtasks

Because a microtask checkpoint drains until the queue is empty, a microtask that enqueues another microtask never yields to tasks. async function loop() { while (true) await null; } blocks rendering and I/O indefinitely, while while (true) await new Promise(r => setTimeout(r)) does not. The rule for latency-sensitive code: await a microtask to sequence steps that must appear atomic to the outside world (no rendering, no event handlers can interleave), and yield a task (setTimeout, scheduler.yield(), MessageChannel) whenever the outside world must be allowed to run. The same analysis explains why unhandled-rejection tracking (HostPromiseRejectionTracker) fires only after a microtask checkpoint: a rejection handled later in the same checkpoint is not "unhandled".

Origin

Where these ideas come from

The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.

MechanismOriginPath into JavaScript
Promises / futuresDaniel Friedman & David Wise; Barbara Liskov & Liuba Shrira, CONS Should Not Evaluate its Arguments (1976); Promises: Linguistic Support for Efficient Asynchronous Procedure Calls (1988) (1988)The promise as a placeholder for a value with attached continuations; JavaScript's Promises/A+ (2012) descended from E's promises via Mark Miller.
Event loop with run-to-completionNetscape (Brendan Eich); HTML Living Standard editors, Netscape Navigator 2.0 (1995); HTML5 event loop specification (2008–) (2008)Single-threaded callbacks were a browser expedient; the task/microtask model was specified by HTML, not ECMAScript, and ES2015 Jobs fit into it.
Coroutines and generatorsMelvin Conway; CLU iterators (Barbara Liskov), Design of a Separable Transition-Diagram Compiler (1963); CLU Reference Manual (1979) (1979)Suspending a running context and resuming it with a value; ES2015 generators follow Python's, which followed CLU's iterators.
async/awaitAnders Hejlsberg, Mads Torgersen, et al. (C# 5.0); F# async workflows (Don Syme), C# 5.0 asynchronous functions (2012)JavaScript's async functions (ES2017) are C#'s design compiled onto promises and generators.
Structured event ordering: microtasksIan Hickson (HTML5), Rafael Weinstein & Anne van Kesteren, HTML microtask checkpoint; MutationObserver (2011) (2011)Microtasks were introduced for MutationObserver batching; promise reactions reused the queue in 2014.

Primary sources