open atlas
↑ Back to track
JavaScript Engine internals JSE · 07 · 03

Inside a Promise

A Promise is a settle-once state machine holding a state, a value, and a list of PromiseReaction records. .then registers reactions and returns a new promise; settling enqueues a PromiseReactionJob per reaction. Resolving with a thenable costs an extra tick.

JSE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

You write await fetchUser() and await fetchPosts() and someone asks: how many microtask ticks did that cost? Most engineers shrug. But the answer decides whether a hot async path adds one tick or four to every iteration — and at 10,000 iterations per render, that gap is the difference between a smooth list and a janky one. To count ticks, you have to know what a Promise actually is on the inside.

A Promise is three fields and a list

Strip away the API and a Promise object in V8 is small. Its essential internal slots are:

  • [[PromiseState]] — one of pending, fulfilled, rejected. It starts pending and transitions exactly once. This settle-once invariant is the heart of the machine: after it leaves pending, no later resolve/reject can change it.
  • [[PromiseResult]] — the value it fulfilled with or the reason it rejected with. Meaningless while pending.
  • [[PromiseFulfillReactions]] / [[PromiseRejectReactions]] — two lists of PromiseReaction records, the handlers registered by .then/.catch while the promise was still pending. Once settled, these lists are cleared and replaced by the result.

A PromiseReaction record bundles three things: the type (fulfill or reject), the handler (your onFulfilled / onRejected callback, or undefined), and the capability of the new promise that .then returned (the resolve/reject functions that will settle that downstream promise). That last part is why chaining works at all.

What .then actually does

promise.then(onF, onR) does three concrete things, in order:

  1. Creates a new promise (the “result capability”) and remembers its resolve/reject functions.
  2. Builds two PromiseReaction records — one fulfill, one reject — each wrapping the matching handler and that new capability.
  3. Either registers or schedules them. If the original promise is still pending, the reactions are appended to its reaction lists and nothing runs yet. If the promise is already settled, .then does not run the handler synchronously — it immediately enqueues a PromiseReactionJob as a microtask for the matching reaction.
const p = Promise.resolve(42);   // already fulfilled with 42
const q = p.then(v => v + 1);     // q is a NEW promise; one PromiseReactionJob queued now
// q settles with 43 after that job runs (one microtask later)

So .then never runs your handler “right now,” even on an already-settled promise. It always defers to a microtask. That single guarantee is what makes Promise ordering predictable: a .then callback is always one microtask removed from the settling, never synchronous.

Settling enqueues one job per reaction

When the producer calls the resolve function (settling the promise to fulfilled), the engine runs FulfillPromise: it sets [[PromiseState]] to fulfilled, stores the value, and then walks [[PromiseFulfillReactions]], enqueuing a PromiseReactionJob microtask for each registered reaction. Each job, when the microtask checkpoint runs it, calls the reaction’s handler with the result, and uses the return value to settle that reaction’s downstream promise (which may itself have reactions, cascading more jobs). Rejection is symmetric via RejectPromise and [[PromiseRejectReactions]].

This is why fan-out costs N microtasks: p.then(a); p.then(b); p.then(c) on a settled p registers three reactions and enqueues three PromiseReactionJobs — a, b, c run as three separate microtasks in registration order.

Tick costs you should be able to recite
Promise.resolve().then(fn) → fn runs
1 tick
N .then on one settled promise
N ticks
Chain .then().then().then()
1 tick per link
resolve(promise) — adopt a thenable
+1 tick (resolve job)
resolve(non-native thenable)
+1 tick (thenable job)
await nativePromise (V8 7.2+)
1 tick (was 3)

The extra tick: resolving with a thenable

Here is the subtlety that trips up tick-counting. If you resolve a promise with another promise (or any thenable), the engine cannot adopt its value synchronously — a thenable might settle later. So ResolvePromise schedules a PromiseResolveThenableJob: a microtask whose only purpose is to call the inner thenable’s .then to subscribe to it. That subscription is itself another deferral. The consequence: resolving promise A with promise B adds an extra microtask tick compared to resolving A with a plain value, because the engine must bounce through PromiseResolveThenableJob before A can even start adopting B’s eventual state.

// Plain value: q settles one tick after p's reaction runs.
Promise.resolve(1).then(v => v);

// Thenable: returning a promise from a handler costs an extra tick,
// because the outer promise must adopt the inner one via a resolve job.
Promise.resolve(1).then(v => Promise.resolve(v));   // extra PromiseResolveThenableJob

This is the mechanical reason the original await cost three ticks (covered next lesson): the spec wrapped the awaited value in a throwaway promise and adopted it through a thenable job. V8 7.2 (2018) special-cased native promises to skip those extra jobs.

Unhandled-rejection tracking

The engine also watches for rejections with no reject handler. When RejectPromise runs and the reject reaction list is empty (no .catch/.then(_, onR) attached), the promise is flagged as having a potentially unhandled rejection. The host is notified via HostPromiseRejectionTracker, which is what fires the browser’s unhandledrejection event and Node’s process.on('unhandledRejection'). Crucially this is deferred: a handler attached later in the same turn clears the flag (fires rejectionhandled), because attaching .catch registers a reject reaction that consumes the stored rejection. This deferral is why “add a .catch on the next line” still suppresses the warning — but a .catch attached a macrotask later does not.

Quiz

What does `const q = p.then(fn)` create, and when does `fn` run if `p` is already fulfilled?

Quiz

Why does resolving promise `A` with another promise `B` cost an extra microtask tick versus resolving `A` with the number 5?

Order the steps

Order what the engine does from calling `.then` on a still-pending promise through running the handler after the promise settles.

  1. 1 .then creates a new downstream promise and remembers its resolve/reject
  2. 2 .then builds PromiseReaction records and appends them to the pending promise's lists
  3. 3 The producer calls resolve(); FulfillPromise stores the value and sets state to fulfilled
  4. 4 FulfillPromise enqueues a PromiseReactionJob microtask for each registered reaction
  5. 5 The microtask checkpoint runs each job: the handler runs and settles the downstream promise
Why this works

Why force .then to always defer to a microtask, even when the promise is already settled? Because a function that sometimes calls its callback synchronously and sometimes asynchronously is impossible to reason about — the infamous “releasing Zalgo.” By guaranteeing the handler always runs in a later microtask, Promises give you one invariant you can build on: the code after .then(...) on the current line always runs before the handler.

Recall before you leave
  1. 01
    Describe the internal structure of a Promise and the settle-once invariant.
  2. 02
    Walk through everything .then does and explain why the handler is never synchronous.
  3. 03
    Explain the extra tick from resolving a promise with another promise, and connect it to await.
Recap

A Promise is a compact settle-once state machine. Internally it is a state slot ([[PromiseState]]: pending → fulfilled | rejected, one-way), a result slot ([[PromiseResult]]), and two lists of PromiseReaction records ([[PromiseFulfillReactions]] / [[PromiseRejectReactions]]). Each reaction bundles a type, your handler, and the resolve/reject capability of the new promise .then returned — which is why chaining composes. .then always returns a new promise and never runs your handler synchronously: on a pending promise it appends reactions; on a settled one it immediately enqueues a PromiseReactionJob microtask. Settling (FulfillPromise/RejectPromise) walks the matching list and enqueues one job per reaction, so fan-out costs N microtasks and a chain costs one tick per link. The notorious extra tick comes from resolving a promise with a thenable: the engine schedules a PromiseResolveThenableJob to subscribe to the inner promise before it can adopt its state. Unhandled-rejection tracking fires when a rejected promise has no reject reaction, but a .catch added later in the same turn clears the flag. Knowing this machinery lets you count ticks exactly. Now when you see an async path that feels slower than it should, you can count: how many .then links, how many theable adoptions, how many fan-out reactions — and know exactly where the microtask budget is going before you profile.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.