Why does JavaScript Promise then handler run after other code?

The relevant specs are here:

  1. Promises/A+ point 2.2.4:

    onFulfilled or onRejected must not be called until the execution context stack contains only platform code. [3.1].

    And note 3.1 (emphasis mine):

    Here “platform code” means engine, environment, and promise implementation code. In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code, it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.

  2. ECMAScript 6.0 (based on Promises/A+) is a little harder to excerpt cleanly, but then resolves as in section 25.4.5.3.1:

    1. Else if the value of promise‘s [[PromiseState]] internal slot is "fulfilled",

      a. Let value be the value of promise‘s [[PromiseResult]] internal slot.

      b. Perform EnqueueJob("PromiseJobs", PromiseReactionJob, «‍fulfillReaction, value»).

    2. Else if the value of promise’s [[PromiseState]] internal slot is "rejected",

      a. Let reason be the value of promise‘s [[PromiseResult]] internal slot.

      b. Perform EnqueueJob("PromiseJobs", PromiseReactionJob, «‍rejectReaction, reason»).

    And the important EnqueueJob operation is defined in section 8.4 (“Jobs and Job Queues”), featuring this in its preface (bold is mine):

    Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty. […] Once execution of a Job is initiated, the Job always executes to completion. No other Job may be initiated until the currently running Job completes.

In practice, this lets you make a few simple and consistent statements:

  • You can count on then or catch (etc) to always behave asynchronously, never synchronously.
  • You’ll never see multiple then or catch handlers on the same stack, even if one Promise is explicitly resolved within another Promise. This also means that recursive Promise execution doesn’t risk stack overflows as a normal function call might, though you can still run out of heap space if you’re careless with recursive closures in a pathological case.
  • Time-consuming operations queued in a then or catch handler will never block the current thread, even if the Promise is already settled, so you can queue up a number of asynchronous operations without worrying about the order or promise state.
  • There will never be an enclosing try block outside of a then or catch, even when calling then on an already-settled Promise, so there’s no ambiguity about whether the platform should handle a thrown exception.

Leave a Comment