Order of Execution of JavaScript Promises (With Examples)

Matt Lim
The Startup
Published in
9 min readSep 13, 2020

--

Confused about the order in which JavaScript promises execute? I was too. Working through some examples and referencing the JavaScript spec helped better my understanding — hopefully it can do the same for you.

Promise States

Before we dive into the examples, let’s review some helpful background knowledge.

A promise can be in one of three mutually exclusive states: fulfilled, rejected, or pending. Here is how spec 26.6 defines these states.

  • A promise p is fulfilled if p.then(f, r) will immediately enqueue a Job to call the function f.
  • A promise p is rejected if p.then(f, r) will immediately enqueue a Job to call the function r.
  • A promise is pending if it is neither fulfilled nor rejected.

There are two more terms to be aware of: settled and resolved.

  • A promise is said to be settled if it is not pending, i.e. if it is either fulfilled or rejected.
  • A promise is resolved if it is settled or if it has been “locked in” to match the state of another promise. Attempting to resolve or reject a resolved promise has no effect. A promise is unresolved if it is not resolved. An unresolved promise is always in the pending state. A resolved promise may be pending, fulfilled or rejected.

Rules

We’ll go over things in more detail below, but here are some high-level rules that should be helpful when trying to understand promises. I recommend reading through the examples while referring to these rules, as opposed to trying to understand these rules thoroughly before going through the examples.

  1. A promise’s executor function runs synchronously.
  2. Calling Promise.prototype.then() on a fulfilled promise adds a job to the job queue (see spec 8.4, spec 26.6).
  3. Promise.prototype.then() returns a pending promise. The promise gets fulfilled (or rejected) when the job it enqueued runs (see spec 26.6.5.4)
  4. Jobs are run in the order they are enqueued.
  5. Jobs are only run when “there is no running execution context and the execution context stack is empty,” e.g. once all the synchronous code in a script has finished (see spec 8.4).
  6. You can think of await in terms of Promise.prototype.then() (see Example #5 and Example #6, see this StackOverflow post).

Get the Code

You can find all these examples on GitHub here.

Note that the examples make use of util.js, which you can find here.

Example #1 — A simple introduction

Code

getFulfilledPromise("foo")
.then(logThen.bind(null, "1"))
.then(logThen.bind(null, "2"));
console.log("After creating promise");

Logs

Promise executor, result will be foo
After creating promise
[1] Promise then, result = foo
[2] Promise then, result = foo

Explanation

We can understand this using rules #1, #2, #4, and #5.

Each call to .then() adds a job to the queue (if we want to be really specific, this is called the “microtask queue”, see here for more details). However, these jobs only run after the script finishes, which is why After creating promise gets logged before the last two lines.

Example #2 — Calling .then() on a fulfilled promise

Code

import {
addLoggingToPromiseThen,
getFulfilledPromise,
logThen,
} from "../util.js";
addLoggingToPromiseThen();const prom1 = getFulfilledPromise("prom1");
const prom2 = getFulfilledPromise("prom2");
prom2.then(logThen.bind(null, "1"));
prom2.then(logThen.bind(null, "2"));
prom1.then(logThen.bind(null, "3"));
prom1.then(logThen.bind(null, "4"));

Logs

Promise executor, result will be prom1
Promise executor, result will be prom2
In Promise.prototype.then Promise { 'prom2' }
In Promise.prototype.then Promise { 'prom2' }
In Promise.prototype.then Promise { 'prom1' }
In Promise.prototype.then Promise { 'prom1' }
[1] Promise then, result = prom2
[2] Promise then, result = prom2
[3] Promise then, result = prom1
[4] Promise then, result = prom1

Explanation

We can understand this using rules #1, #2, #4, and #5 (the same rules as the first example).

Both prom1 and prom2 are fulfilled. We know from spec 26.6 that calling p.then(f, r) on a fulfilled promise p will “immediately enqueue a Job to call the function f.” This means that the calls to logThen will be put into the job queue (see spec 8.4 for more info about the job queue) in the same order as they appear in the script. Finally, since jobs run in the same order as they were enqueued (see spec 8.4.4), the logs appear in sequential order.

Example #3 — Interleaved execution

Code

import {
addLoggingToPromiseThen,
getFulfilledPromise,
logThen,
} from "../util.js";
addLoggingToPromiseThen();const prom1 = getFulfilledPromise("prom1");
const prom2 = prom1.then(logThen.bind(null, "1"));
const prom3 = prom2.then(logThen.bind(null, "2"));
const prom4 = getFulfilledPromise("prom2");
const prom5 = prom4.then(logThen.bind(null, "3"));
const prom6 = prom5.then(logThen.bind(null, "4"));

Logs

Promise executor, result will be prom1
In Promise.prototype.then Promise { 'prom1' }
In Promise.prototype.then Promise { <pending> }
Promise executor, result will be prom2
In Promise.prototype.then Promise { 'prom2' }
In Promise.prototype.then Promise { <pending> }
[1] Promise then, result = prom1
[3] Promise then, result = prom2
[2] Promise then, result = prom1
[4] Promise then, result = prom2

Explanation

We can understand this using rules #1, #2, #3, #4, and #5. This one will be a bit more tricky because of rule #3…

First, note that getFulfilledPromise("prom1").then(...) does not return a fulfilled promise. We can confirm this by looking at the logs from Promise.prototype.then— only the initial call to then for each promise logs a fulfilled promise.

So, let’s go through this line-by-line and see what happens.

  1. const prom1 = getFulfilledPromise("prom1");. This just gets a fulfilled promise.
  2. const prom2 = prom1.then(logThen.bind(null, "1"));. Here, .then() is called on a fulfilled promise, which means that the corresponding job gets immediately enqueued (we also saw this in the last example). The call returns a pending promise that will get fulfilled when the enqueued job runs. That is, the job will run our onFulfilled callback, which is logThen.bind(null, "1"), and it will fulfill prom2. Note that jobs will only start running after the script completes (spec 8.4)! See spec 26.6.5.4.1 to read more about the details. The main important part is this line:
    “Perform HostEnqueuePromiseJob(fulfillJob.[[Job]], fulfillJob.[[Realm]]).”

    At this point, here is the state of things. "1" is shorthand for “the job that runs logThen.bind(null, "1").
    job_queue = ["1"]
  3. const prom3 = prom2.then(logThen.bind(null, “2”));. Here, .then() is called on a pending promise. Spec 26.6.5.4.1 describes the behavior in this scenario. Here’s the relevant line:
    “Append fulfillReaction as the last element of the List that is promise.[[PromiseFulfillReactions]].”
    In simple terms, this makes it so that when prom2 gets fulfilled, logThen.bind(null, "2")) will be called.

    At this point, here is the state of things.
    job_queue = ["1"]
    prom2_fulfill_reactions = ["2"]
  4. const prom4 = getFulfilledPromise("prom2");. This is basically the same as #1, it just gets a fulfilled promise.
  5. const prom5 = prom4.then(logThen.bind(null, "3"));. This is similar to #2. Since .then() is called on a fulfilled promise, the corresponding job gets immediately enqueued. Just as before, the job will run our callback and fulfill the promise returned by the call to .then() (which is prom5 in this case).

    At this point, here is the state of things.
    job_queue = ["1", "3"]
    prom2_fulfill_reactions = ["2"]
  6. const prom6 = prom5.then(logThen.bind(null, "4"));. This is basically the same as #3. However, this time, we add a reaction to the list for prom5, not prom2.

    At this point, here is the state of things.
    job_queue = ["1", "3"]
    prom2_fulfill_reactions = ["2"]
    prom5_fulfill_reactions = ["4"]

Alright, that’s it for the execution of the script itself. After the script runs, jobs from the job queue will start running. Here’s how that goes.

Jobs get run in the order they were enqueued, so the job we’ve named "1" will execute first. This explains why [1] Promise then, result = prom1 is the first line that’s logged. Remember that this job not only runs our callback, but also fulfills prom2. If we take a look at spec 26.6.1.4 and spec 26.6.1.8, we can see that fulfilling a promise enqueues a job for each element in its “fulfill reactions” list. So, after this job runs, here is the state of things.

job_queue = ["3", "2"]
prom5_fulfill_reactions = ["4"]

Job "1" gets run and popped from the queue and job "2" gets enqueued.

The next job in the queue is "3", so that’s the next job that will execute. Just as with the first job, it will run the callback (thus logging [3] Promise then, result = prom2) and enqueue job "4". After this job runs, here is the state of things.

job_queue = ["2", "4"]

It should be clear what happens at this point :).

Example #4 — Interleaved execution, again

Code

import { getFulfilledPromise, logThen } from "./util.js";const main = () => {
new Promise((resolve, reject) => {
console.log("Start main");
resolve();
})
.then(() => {
console.log("Intermediate main");
})
.then(() => {
console.log("End main");
});
};
getFulfilledPromise("outer")
.then(logThen.bind(null, "1"))
.then(logThen.bind(null, "2"));
main();

Logs

Promise executor, result will be outer
Start main
[1] Promise then, result = outer
Intermediate main
[2] Promise then, result = outer
End main

Explanation

We can use the same exact reasoning as Example #3 in order to understand this example. The differences are just syntactical:

  • Instead of assigning all of the intermediate promises to variables, the .then() calls are chained inline.
  • One of the promises is created in a function named main.

Example #5 — Interleaved execution, with await

Code

const main = async () => {
console.log("Start main");
await null;
console.log("Intermediate main");
await null;
console.log("End main");
};
getFulfilledPromise("outer")
.then(logThen.bind(null, "1"))
.then(logThen.bind(null, "2"));
main();

Logs

Promise executor, result will be outer
Start main
[1] Promise then, result = outer
Intermediate main
[2] Promise then, result = outer
End main

Explanation

This example involves all the rules!

The main reason I included Example #4 was to set up this example :). Spec 6.2.3.1 tells us how await works; it’s fairly complicated to read through it all, but it boils down to the fact that await is mainly just syntactic sugar. For example, these two code blocks are analogous (they are not exactly equivalent, e.g. when using await you must use try/catch to handle rejected promises).

await foo();  // foo is an async function that returns a promise
console.log("hello");
foo().then(() => {
console.log("hello");
});

Credit for this example goes to jfriend00, see the original post here.

This means if we write main like this, it’s functionally equivalent.

const main = () => {
console.log("Start main");
new Promise((resolve) => {
resolve();
}).then(() => {
console.log("Intermediate main");
new Promise((resolve) => {
resolve();
}).then(() => {
console.log("End main");
});
});
};

Further, instead of nesting the promises, we can flatten them out. If we do that, then the code looks exactly like Example #3! So if we understand how Promise.prototype.then() works, we should also be able to understand await.

Example #6 — Await, again

Code

const func1 = async () => {
console.log("Start func1");
await null;
console.log("Intermediate func1, before calling func2");
func2();
console.log("Intermediate func1, after calling func2");
await null;
console.log("End func1");
};
const func2 = async () => {
console.log("Start func2");
await null;
console.log("Intermediate func2");
await null;
console.log("End func2");
};
func1();

Logs

Start func1
Intermediate func1, before calling func2
Start func2
Intermediate func1, after calling func2
Intermediate func2
End func1
End func2

Explanation

Again, this example involves all the rules.

Example #5 was our first taste of async/await — this example will help us solidify the same concepts. Before, we said that these two code blocks are analogous:

await foo();  // foo is an async function that returns a promise
console.log("hello");
foo().then(() => {
console.log("hello");
});

This means that you can think of of await in terms of Promise.prototype.then(), which is quite nice. This is actually all you need to know to understand how this example works. That is, you can rewrite this example like so.

const func1Alt = () => {
new Promise((resolve) => {
console.log("Start func1");
resolve();
})
.then(() => {
console.log("Intermediate func1, before calling func2");
func2Alt();
console.log("Intermediate func1, after calling func2");
})
.then(() => {
console.log("End func1");
});
};
const func2Alt = () => {
new Promise((resolve) => {
console.log("Start func2");
resolve();
})
.then(() => {
console.log("Intermediate func2");
})
.then(() => {
console.log("End func2");
});
};
func1Alt();

The output is be exactly the same, and understanding this just requires understanding Promise.prototype.then(). Nice!

Some resources explain await differently, For example, MDN says the following:

An await can split execution flow, allowing the caller of the await's function to resume execution before the deferred continuation of the await's function. After the await defers the continuation of its function, if this is the first await executed by the function, immediate execution also continues by returning to the function's caller a pending Promise for the completion of the await's function and resuming execution of that caller.

In the context of our example, this says that when the first await in func2 is hit, control flow returns to func1 (meaning Intermediate func1, after calling func2 will be logged), and the call to func2() in func1 returns a pending Promise. This makes sense, and it can be helpful to view the first await as returning control flow back to the caller, but I don’t think this is the best way to think about. Thinking about await in terms of Promise.prototype.then() is much more general, and lets you understand complicated scenarios fairly easily.

If you really want to know exactly how things are supposed to work, take a look at spec 6.2.3.1.

--

--

Matt Lim
The Startup

Software Engineer. Tweeting @pencilflip. Mediocre boulderer, amateur tennis player, terrible at Avalon. https://www.mattlim.me/