How JavaScript Promises Work Internally? PyPixel
Promises are one of the most important features in modern JavaScript. JavaScript Promises allow us to write asynchronous code in a cleaner way by avoiding callback hell. But you might wonder sometimes how promises actually work under the hood. No more worries in this post, I’ll take you through the internal implementation of promises in JavaScript and understand what happens when we create, resolve, or reject a promise.
JavaScript Promises Basics
For a quick recap – a promise represents an asynchronous operation that may complete at some point and produce a value. It can be in one of three states:
- Pending – Initial state when the promise has been created but the asynchronous operation hasn’t been completed yet.
- Fulfilled – The promise’s asynchronous operation has been completed successfully and the promise now has a resolved value.
- Rejected – The asynchronous operation failed and the promise has a rejection reason.
We create a promise using the Promise constructor:
const promise = new Promise((resolve, reject) => {
// async operation
});
The executor function passed to the Promise constructor takes two arguments – resolve and reject. These functions are pre-defined by the Promise API:
- resolve – Call this when you want to resolve the promise with some value
- reject – Call this if any errors happen
When either of these are called, the promise state changes from pending to fulfilled or rejected. The promise result is also set to the resolve value or rejection reason.
Promise Object Internals
Under the hood, the Promise object maintains state and links to callbacks using the following internal properties:
- [[PromiseState]] – A string that is either “pending”, “fulfilled” or “rejected”.
- [[PromiseResult]] – Set to either the resolve value or rejection reason.
- [[ResolveCallbacks]] – An array of callback functions to execute when/if the promise resolves.
- [[RejectCallbacks]] – An array of callback functions to execute when/if the promise rejects.
When we create a new promise, [[PromiseState]] is set to “pending”, [[PromiseResult]] is undefined, and the callbacks arrays start out empty.
Here is roughly what the internal slots of a fresh promise would look like:
[[PromiseState]]: "pending"
[[PromiseResult]]: undefined
[[ResolveCallbacks]]: []
[[RejectCallbacks]]: []
Resolving a Promise
The resolve function that’s passed to the executor serves to resolve the promise. Here is simplified code for what happens internally when resolve is called:
function resolve(value) {
[[PromiseState]] = "fulfilled";
[[PromiseResult]] = value;
// execute all callbacks in [[ResolveCallbacks]]
}
We set the state to “fulfilled”, save the value, and execute any “on resolved” callbacks that were added to the promise.
Similarly, the reject function handles setting the state to rejected and the reason value:
function reject(reason) {
[[PromiseState]] = "rejected";
[[PromiseResult]] = reason;
// execute all callbacks in [[RejectCallbacks]]
}
Chaining Promises
An important part of promises is chaining – being able to write additional logic that executes after a promise settles. This chainability comes from callbacks in the resolve and reject functions:
const promise = doSomethingAsync()
.then((value) => {
// handle success
})
.catch((err) => {
// handle failure
});
Here’s how the chaining works internally:
When a promise resolves successfully:
- Saved resolve callbacks are executed one by one
- Each callback can return another promise or a direct value
- If a callback returns a promise, add more callbacks to it
- Once all callbacks are done, resolve the final promise chain with the last value
And similarly when handling rejections:
- Saved reject callbacks execute one by one
- Can return promises or values
- Reject the final chain if the last rejection reason
By linking callbacks and promises in this chain, we can write asynchronous logic piece by piece, instead of nested callback syntax.
Promises vs. Callbacks
To truly appreciate promises, let’s briefly compare them to the traditional callback approach. Callbacks can lead to callback hell, where nested callbacks become hard to read and maintain.
doAsyncTask1((result1, error1) => {
if (error1) {
console.error('Error in Task 1:', error1);
} else {
doAsyncTask2((result2, error2) => {
if (error2) {
console.error('Error in Task 2:', error2);
} else {
doAsyncTask3((result3, error3) => {
if (error3) {
console.error('Error in Task 3:', error3);
} else {
// Handle the final result
}
});
}
});
}
});
Compare this to the promise approach:
doAsyncTask1()
.then(result1 => doAsyncTask2(result1))
.then(result2 => doAsyncTask3(result2))
.catch(error => console.error('An error occurred:', error));
The promise-based code is more linear, making it easier to follow and maintain.
Async/Await Syntax
Promises enabled a big improvement in asynchronous JavaScript code. The async/await syntax builds on top of promises and makes the code even cleaner:
async function getUsers() {
const response = await fetch('/api/users');
const users = await response.json();
return users;
}
Under the surface, async/await uses the same promise capabilities. The await keyword halts execution until a promise settles, then gets its resolved value.
So promises are still critical even as async/await adoption increases – the elegant syntax depends on the solid foundation that promises provide.
Conclusion
To sum up, Promises in Javascript are a way to handle asynchronous operations more gracefully. They provide a cleaner alternative to callbacks which makes the code more readable and maintainable. A promise is an object representing the eventual completion or failure of an asynchronous operation. It promises to return a value or throw an error once the operation is completed.
Read More:
FAQs
What are the internal slots used inside a JavaScript promise?
Promises have [[PromiseState]]
to track if it is pending, fulfilled, or rejected. [[PromiseResult]]
holds the resolve/reject value. And [[ResolveCallbacks]]
+ [[RejectCallbacks]]
array hold callbacks to execute for each scenario.
What happens internally when a promise is resolved?
The [[PromiseState]]
is set to “fulfilled”, [[PromiseResult]]
saves the resolve value, and all callbacks in [[ResolveCallbacks]]
execute one by one.
What happens internally when a promise is rejected?
Similar to resolve, the [[PromiseState]]
sets to “rejected”, [[PromiseResult]]
saves reject reason, and [[RejectCallbacks]]
callbacks execute.
How does the then() method work with promises internally?
The then()
method adds a callback to [[ResolveCallbacks]]
. When promise resolves, it executes and can return a value/promise for next then() in chain.
How does the catch() method work internally?
Same as then()
but catch()
adds the callback to [[RejectCallbacks]]
array to handle errors in the promise chain.
What allows the chaining of then() and catch() methods on promises?
By recursively running callbacks added from then()/catch()
, and returning their values/promises for next link in the chain internally.
How does the await keyword work with promises internally?
Await stops execution until the awaited promise resolves, then returns the [[PromiseResult]] resolve value from internal slots.
Are async and await promises different internally?
No – async/await builds on regular promises. Await halts execution so async waits on promises to resolve/reject.
Where are the resolve and reject functions defined internally?
Passed by the JS engine when Promise constructor runs so developer can control the fate of promise.
Can other objects be made Promises compatible internally?
Yes – any object exposing then, catch, finally methods & [Symbol.toStringTag]
set to “Promise” can integrate.