The Architecture of JavaScript Promises
Promises are very difficult to understand! This is the first statement I come across when I try to learn them before even coming across the definition. But I believe that is a very funny thing to say. Why? Well, because a promise is not a programming concept. It is a real-world concept implemented in programming. Allow me to explain myself.
Suppose you made a wonderful application that a certain client absolutely fell in love with. They offered you a million dollars, but they are a little old school. Instead of an online transaction, they handed you a cheque of 1 million dollars. Now that check represents 1 million dollars. But this cheque is not cash, which is the thing you actually want. So naturally, you'd go to the bank and ask them to convert it. Now, that cheque can either be cashed or it could (I pray not) bounce. If it gets cashed, then you can happily take the money home. But if it gets bounced, personally, I'd hire a good lawyer.
But anyway, that is all that promises are all about! It was that simple. That cheque is not money. It is a promise for the money. That promise can either be fulfilled if you manage to cash it, or be rejected (bounced). Until then, it is pending. That is basically all that promises are. Beyond this is just us trying to understand how promises are actually implemented in JavaScript. If you are still unsure, don't worry - because in this blog, I am not here to teach you how to use promises, as MDN can do that better. I am here to make you understand their architecture and workings by which you can develop a feel of when and where to use them, and not just how to use them.
The special object called a Promise
I hope you are used to it by now, but yes, a promise is also an object. However, it is not a normal object. It doesn't actually have any keys which you can access. It has inner slots that hold some values, and it is never something that we could or should modify by ourselves. It can be thought of as looking something like:
{
[[PromiseState]]: "pending",
[[PromiseResult]]: undefined,
[[PromiseFulfillReactions]]: [...],
[[PromiseRejectReactions]]: [...]
}
The above code is just a representation and may not truly represent what promises actually look like. Some environments can reveal promises' internal states (data) using their own format, but it usually doesn't appear as a normal object.
To visualise promises, despite the fact that their fields are hidden, we will use imaginary keys to represent their internal states that are actually relevant to us. For this blog, we will refer to promises as this object (even tho it actually does not look like this):
const myPromise = {
status:"pending" || "fulfilled" || "rejected",
value: undefined || data || error
}
// || separates the possible data in the field
Possible Promise States
When we have a promise object, it can be only in one of the pre-defined 3 states:
pending:
Represents that the promise is not yet "settled" (fulfilled or rejected). It is an ambiguous state where the system is not sure if it will be fulfilled or rejected. At this state, any tasks associated with the fulfilment or rejection of the promise cannot be carried out, and hence the system simply waits for settlement.const myPromise = { status:"pending", value: undefined }fulfilled:
Represents that the promise has been fulfilled and returned the kind of value that was expected from it without any error. In such a case, callbacks associated with the fulfilled state can be given the return value and be scheduled for execution.const myPromise = { status:"fulfilled", value: data }rejected:
Represents that the promise has been rejected due to some problem or error. Callbacks associated with such rejection can now be scheduled instead of the one that would be if the promise were fulfilled. These callbacks can have an error argument to run logic based on the error or simply display it.const myPromise = { status:"rejected", value: error }
Using Promises
Before trying to understand the internals of promises, let us try to have an overview of what promises look like in code and how they are used.
Creation of a Promise
A promise is usually returned by async APIs, but since it is a language concept, we can create promises ourselves. Promise is an object and hence can be instantiated from the constructor of the Promise class. This constructor takes a function (called an executor) as an argument.JavaScript calls it immediately and passes two handlers to it: resolve and reject, which have the capacity to fulfil or reject a promise.
const accountBalances = {
client1: 20000,
client2: 300000000,
client3: 70000000
};
function getUserBalance(clientId) {
return accountBalances[clientId];
}
function depositChequeInBank(cheque) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (cheque.amount <= getUserBalance(cheque.issuer)) {
resolve({
message: "Cheque Cleared",
money: cheque.amount
});
} else {
reject({
error: "Cheque Bounced"
});
}
}, 3000);
});
}
const cheque = {
issuer:'client2',
amount:1000000,
to: 'you'
};
const depositPromise = depositChequeInBank(cheque);
depositPromise
.then(data => invest(data.money))
.catch(error => contactLawyer(error));
Let us now try to understand the above code.
We get a cheque from our client. We go to deposit it in the bank. The bank takes 3 seconds to complete the process of checking, verifying and providing a final decision of whether the cheque should be passed or bounced.
So it returns a promise which will remain pending for at least 3 seconds. Internally, the function checks if the amount is valid. If the amount is valid, it resolves the promise using the resolve callback or else it rejects the promise using the reject callback. These resolve and reject callbacks are provided by the Promise constructor in the callback to settle the promise once the task is done.
Now, finally, when the promise is fulfilled, the callback in .then() runs. Since this callback can only run if the promise is resolved, it correctly expects a data object which would have the money attribute. It is also important to note that since the promise is resolved and there is no other promise in the chain that could be rejected, the callback in .catch will never be executed.
Similarly, if the promise had been rejected, the callback in .then would never be scheduled, and instead the callback inside .catch would be the one to be scheduled to run.
Just as we created our own promise, some APIs like fetch return promises by themselves, which can be consumed in the same way using .then and .catch.
The Promise LifeCycle Architecture
While promises can be used to represent immediate values, for this section, we will talk mostly about asynchronous behaviour because it is more generally used.
When we first call the deposit function, it would immediately return us a pending promise. For up to 3 seconds, absolutely nothing will happen in front of us. But in the background, the environment will be processing the timer. Now, as soon as the timer expires, the callback passed to setTimeout will be pushed into the task queue. When the call stack becomes empty, the queued task will be pushed into it. Now the function will get processed. Now, say that the account actually has enough balance, the resolve() callback will be executed immediately (as this function is being processed synchronously). Calling resolve does two important things. First, it would settle the promise as fulfilled. Then JavaScript schedules attached .then() handlers in the microtask queue. The .then() function has the callback that needs to be scheduled after fulfilment of the promise. It also processes the value returned by the promise and passes it on to the callback inside it. As we know, now that the call stack is empty again, this microtask will be pushed and executed. .catch() behaves similarly, but it handles rejections or errors thrown earlier in the chain and queues its callback as a microtask too.
Promise Chaining
Promise chaining is very difficult to understand. That is, if you do not understand what .then and .catch return. So what do they return?
The answer is simple. Both .then() and .catch() always return a new promise. The format is simple. Either the callbacks in them return a value, or a promise or nothing. If the internal callbacks return a promise, the next promise in the chain adopts its eventual state and value. If the callbacks return a value, that value is wrapped in a fulfilled promise, and that promise is returned. Finally, if the callback doesn't return anything, then it returns undefined. These functions take this and return a fulfilled promise that settles with its value as undefined.
Now, since the value returned by .then and .catch is a promise, we can apply another .then or .catch to it. But applying both .then and .catch would get very unreadable. The .catch is executed when a promise in the chain is rejected or if an error is thrown by any of the handlers. So nowadays the use of .catch is usually as the only .catch in one chain, and any rejection in the chain is handled by a single .catch.
The chain looks like this:
washRice(rawRice)
.then(washedRice => drainRice(washedRice))
.then(drainedRice => boilRice(drainedRice))
.then(boiledRice => serveRice(boiledRice))
.catch(error => {
console.log("Error occurred. Throwing away spoiled rice.");
});
Whatever a .then() callback returns becomes the result of the next step. If it returns a promise, the chain waits for it. The fulfilment value is automatically passed into the next .then() and is available to be passed as an argument to the callback in it. Any error that occurs at any step of the chain will be handled directly by the .catch.
Conclusion
Promises do appear to be a slightly complex topic to understand, but once you get the internal architecture, it really is not. But even if one actually understands what is happening, true intuition is not built until one writes the code themselves. So at the end of this blog, I would suggest that you try the codes by yourself, make mistakes, read the documentation and maybe get to learn even more than what you learned by reading this blog. See you in the next article.


