It's that time of the year. People around the world make resolutions - promises to themselves - if you will. We might think about resolutions once a year, but our lovingly-crafted lines of JavaScript are constantly making, fulfilling, and resolving Promises. How does it all work? Join me as we scuba dive into the event loop.
the basic use case for Promises is, in fact, straightforward and simple. But they can become surprisingly confusing for anything beyond the simplest use cases. [...] It is worth taking the time to develop that deep understanding.
- David Flanagan, JavaScript: The Definitive Guide
This article assumes a basic knowledge of the JavaScript event loop and task queue. If you don't know what I'm talking about, you can watch this excellent video for an introduction.
Don't worry, I'll wait.
What that video won't tell you1 is that in all2 modern JS runtimes, there are actually two queues - the Task Queue and the Microtask Queue. The Microtask Queue is higher priority than the task queue, so it must be cleared before the next task can be run. Microtasks could in theory schedule more microtasks indefinitely and the next task would never ever ever be run. Please don't do this!
With this in mind, the JavaScript host environment looks something like this:
Each agent (for instance, web workers) has its own full environment.
One quick thing here, which I think is often misunderstood: the JavaScript Engine is only one part of this picture, it is not responsible for the whole thing. V8, JavascriptCore, etc. are mostly just responsible for executing our lovingly-crafted lines of JavaScript, while the host environment handles the queues and things that are used to interact with the system.5 For instance, Promise Rejections are handled differently by web browsers and desktop runtimes even when they have the same JavaScript engine.
To do justice to the title of this article, let's talk about this for a few seconds. When a Promise resolves, that means that its fate is decided - it has resolved what value it will become. This doesn't mean that it's done - the value it resolved to might be a pending Promise! When a Promise fulfills, that means that it really is done, and has resolved to something other than a pending promise. Promise handlers run when the Promise fulfills. This rarely matters, but when you're really getting into things its good to be precise. Settled means "fulfilled or rejected".6
Here's the knowledge we need to take with us:
await-ed, then "resuming the suspended execution of the code which contained the await" is essentially just added as a fulfillment handler,8 and "throwing an error in the code which contained the await" is essentially just added as a rejection handler.9 10await-ing an expression: synchronously evaluates the expression and wraps it in a Promise if it isn't already one.Promise.resolve() or Promise.reject(): synchronously creates an already-settled Promise.12new Promise constructor: synchronously calls the executor..then or .catch methods of a Promise: synchronously creates a new Promise.13Note on syntax: The async IIFE pattern - (async () => { ... })() - is used throughout this article. It might look strange or be confusing if you aren't used to it. You can imagine its just like writing fetch() or making a call to any other async function.
What is the difference in output between this code...
async function main() {
const promise = (async () => {
throw new Error('Out of air!');
})();
await Promise.resolve();
const results = await Promise.allSettled([promise]);
console.log("Made it...I hope!");
console.log(results);
}
main();
...and this code?
async function main() {
const promise = (async () => {
throw new Error('Out of air!');
})();
await new Promise((resolve) => setTimeout(resolve, 0));
const results = await Promise.allSettled([promise]);
console.log("Made it...I hope!");
console.log(results);
}
main();
There's a big difference, as it turns out, but I want to talk about some other things first. Let's queue this up to deal with later. I promise we'll come back and resolve it!
*Pause for laughter*
Q: What does this code output?
async function main() {
await (async () => {throw new Error("Out of air!")})()
.catch(() => console.log("Found a new air tank!"));
console.log("Successful dive");
}
main();
A:
Found a new air tank!
Successful dive
The short story here, inside of main, is that the async function call returns a rejected Promise, a catch handler is attached to it, and then it is awaited. Because the Promise rejected, the catch handler runs, logging "Found a new air tank!" and then the function keeps running where it let off, logging "Successful dive".
The code is quite simple, but there's a lot going on under the hood:
await statement is hit. The expression being awaited - the Promise chain - must be evaluated.The code execution is something like this now:
async function main() {
await <rejected Promise>.catch(() => console.log("Found a new air tank!"));
console.log("Successful dive");
}
main();
.catch method is called, which returns a Promise (Promise 2) and registers the callback as a rejection handler on Promise 1:• run .catch()
• continue main()
await had attached its continuation to the fulfillment of Promise 2, so main keeps running and "Successful dive" is logged to the console.Note on Example Questions: When thinking about what the output of logging a promise is, don't worry about the actual output. It will vary between host environments. Just think about the state of the promise, and if it is settled, what value it settled to.
Q: What does this code output?
async function main() {
const p = (async () => {throw new Error("Out of air!")})()
.catch(() => {
console.log("Found a new air tank!");
queueMicrotask(() => console.log("Check regulator"));
});
console.log(p);
await p;
console.log(p);
console.log("Successful dive");
}
main();
A:
Promise { <pending> }
Found a new air tank!
Check regulator
Promise { undefined }
Successful dive
Here's the full breakdown:
p is set to the value of the Promise chain, which must be evaluated..catch method is called, which returns a Promise (Promise 2) and registers the callback as a rejection handler on Promise 1.p is now equal to Promise 2, which has not resolved yet. A pending Promise is logged.p is awaited, adding the continuation of main to the fulfillment handler of Promise 2. Synchronous execution is done.• continue main()
• log "Check regulator"
await had attached its continuation to the fulfillment of Promise 2, so main keeps running. The Promise has now resolved to the value undefined, and this along with "Successful dive" are logged to the console.This example and the one above illustrate how the microtask queue works. I found it particularly interesting that in order for the await p to continue, two microtasks need to be executed, and you can even enqueue more microtasks during the first one, before the second one is queued!
This example also illustrates something about promises and promise chains, which we can examine even further by expanding the chain:
Q: What does this code output?
async function main() {
const p1 = (async () => {throw new Error("Out of air!")})()
const p2 = p1.catch(() => {
console.log("Found a new air tank!");
queueMicrotask(() => console.log("Check regulator"));
});
console.log(p1);
console.log(p2);
await p2;
console.log(p1);
console.log(p2);
console.log("Successful dive");
}
main();
A:
Promise { <rejected> Error: Out of air! }
Promise { <pending> }
Found a new air tank!
Check regulator
Promise { <rejected> Error: Out of air! }
Promise { undefined }
Successful dive
We don't need to go through the whole breakdown here but it's interesting to note that p1 really is immediately and synchronously rejected, which you can see from logging it. The rejection simply doesn't do anything until it comes off the Microtask Queue later. There's time to add a catch handler in a later line of code (sometimes quite a bit later - find out more in Episode 2 of Tales from the Event Loop!).
This example highlights that promises are generated and sometimes even settled synchronously, but their handling is deferred.
Bonus - If you really want to go crazy, what would happen if we did await p1 instead? Hint: read the footnotes about our scuba equipment
Here's that first question again:
What is the difference in output between this code...
async function main() {
const promise = (async () => {
throw new Error('Out of air!');
})();
await Promise.resolve();
const results = await Promise.allSettled([promise]);
console.log("Made it...I hope!");
console.log(results);
}
main();
...and this code?
async function main() {
const promise = (async () => {
throw new Error('Out of air!');
})();
await new Promise((resolve) => setTimeout(resolve, 0));
const results = await Promise.allSettled([promise]);
console.log("Made it...I hope!");
console.log(results);
}
main();
const promise = ... line will synchronously create a rejected Promise and put the rejection of that Promise onto the microtask queue.• continue main()
• no handlers
• resolve()
• no handlers
await here and the next microtask needs to come off the microtask queue. This is the promise rejection, and there are no handlers for it, but this isn't fatal yet. There are a lot of details here which we won't get into, but essentially the runtime gives you until the end of the event loop turn to deal with the rejection.Promise.allSettled does get called and the previously-rejected Promise is handled after all.Final answers (edited for brevity, tested in Node 22):
Made it...I hope!
[
{
status: 'rejected',
reason: Error: Out of air!
}
]
and
throw new Error('Out of air!');
^
Error: Out of air!
As you can see, the subtle differences between the task queue and the microtask queue can even decide whether your code fatally exits or runs without an issue!
If you've made it this far, thanks for sticking with me, I hope you found this as interesting as I did! And if you're just dying to know more about the unhandled rejection in the final example, tune in to the next episode of Tales from the Event Loop!
1 I believe this is because the video predates the common adoption of the microtask queue, not due to any inaccuracy on Philip's part. ↩
2 I think this is true, even though tc39 doesn't specify this and leaves it up to the implementation. ↩
3 Unless they are programmatically called? This article has an in-depth example where calling .click() on an element is different than actually clicking on it and appears to run the click handlers synchronously instead of scheduling the task. I didn't do any further research on this topic. ↩
4 I didn't research NodeJS EventEmitters for this article. If you know how they fit in here, please send me a message! ↩
5 I am among the people who misunderstand this, and I don't know exactly where the division of responsibility lies. In the video I linked at the start, he says that 'setTimeout' is not to be found anywhere in the V8 source code. At the time of writing, that same search returns 100 results, and a timeout job is something specifically defined by the ECMAScript spec. ↩
6 You can read more about "States and Fates" of JavaScript Promises here. ↩
7 The runnable distinction is important here. For instance, a setTimeout which hasn't timed out is not runnable, so a different task which was enqueued later might be dequeued first.14 ↩
8 I'm quite sure this is the correct mental model, but its possible I'm wrong. This blog post from the V8 authors goes into enormous detail about how await and other Promise handlers are implemented under the hood and interact with each other, and this was my simplified takeaway. ↩
9 Because the rejection of an await-ed Promise will throw an error in the caller, await-ing a Promise will improve the stack trace if the Promise rejects. It is therefore recommended to await Promises whenever possible, and there is no performance loss for doing so. ↩
10 The fact that await-ing a Promise adds a rejection handler which throws an error has the surprising side effect that the error is still thrown even if the rejection is otherwise caught, e.g. if you manage to await the promise at the start of a promise chain which includes a .catch later. This would be very rare in normal practice but we can construct situations like this (hint hint). If you're a really keen scuba diver, you can see in the spec that the rejectedClosure created by await will return the execution context to the caller of the await when the promise rejects, using a ThrowCompletion as a result of the operation ↩
11 Nothing really happens that our lines of lovingly-crafted JavaScript get to know about. Behind the scenes, a PromiseCapability is created, which is a Record that can hold information about what happens when the Promise is Fulfilled or Rejected. If you're a deep-sea diver and just can't get enough of the spec, you can see that PerformPromiseThen - which is also used by await - only calls HostEnqueuePromiseJob if the Promise is fulfilled or rejected. ↩
12 In these cases, the Promise is already settled and this will immediately add a microtask to the microtask queue, but that is arguably separate from the creation of the Promise object itself. ↩
13 Although Promise.prototype.then and Promise.prototype.catch synchronously return Promises, they are not Promise constructors and their callback functions do not run synchronously. Even if .then or .catch are called on an already-settled Promise, the callback will run as a job on the microtask queue.↩
14 Yes, I put a footnote in a footnote. Hey, at least its not recursive. You might suggest that the task just isn't put onto the task queue until the timeout expires, rather than saying that the task queue contains a task which isn't runnable yet. I don't think it really matters how you think about it, but the spec for HostEnqueueTimeoutJob suggests to me that the non-runnable job is actually enqueued immediately. ↩