Why?
To get some idea how JavaScript Promises run callbacks asynchronously under the hood.
Let's create our own Promise in JavaScript! We'll follow the Promise/A+ specification, which outlines how promises handle async operations, resolve, reject, and ensure predictable chaining and error handling.
To keep things simple, we'll focus on the key rules marked by ✅ in the Promises/A+ specification. This won't be a full implementation, but a simplified version. Here's what we'll build:
1. Terminology
1.1 'promise' is an object or function with a then
method whose behavior conforms to this specification.
1.2 thenable' is an object or function that defines a then
method.
1.3 'value' is any legal JavaScript value (including undefined
, a thenable, or a promise).
1.4 'exception' is a value that is thrown using the throw
statement.
1.5 'reason' is a value that indicates why a promise was rejected.
2. Requirements
2.1 Promise States
A promise must be in one of three states: pending, fulfilled, or rejected.
2.1.1. When pending, a promise: ✅
⟶ may transition to either the fulfilled or rejected state.
2.1.2. When fulfilled, a promise: ✅
⟶ must not transition to any other state.
⟶ must have a value, which must not change.
2.1.3. When rejected, a promise: ✅
⟶ must not transition to any other state.
⟶ must have a reason, which must not change.
2.2 The then Method
A promise must provide a then
method to access its current or eventual value or reason.
A promise's then
method accepts two arguments:
promise.then(onFulfilled, onRejected);
2.2.1. Both onFulfilled
and onRejected
are optional arguments: ✅
⟶ If onFulfilled
is not a function, it must be ignored.
⟶ If onRejected
is not a function, it must be ignored.
2.2.2. If onFulfilled
is a function: ✅
⟶ it must be called after promise
is fulfilled, with promise
's value as its first argument.
⟶ it must not be called before promise
is fulfilled.
⟶ it must not be called more than once.
2.2.3. If onRejected
is a function, ✅
⟶ it must be called after promise
is rejected, with promise
's reason as its first argument.
⟶ it must not be called before promise
is rejected.
⟶ it must not be called more than once.
2.2.4. onFulfilled
or onRejected
must not be called until the execution context stack contains only platform code. ✅
2.2.5. onFulfilled
and onRejected
must be called as functions (i.e. with no this
value). ✅
2.2.6. then
may be called multiple times on the same promise. ✅
⟶ If/when promise
is fulfilled, all respective onFulfilled
callbacks must execute in the order of their originating calls to then
.
⟶ If/when promise
is rejected, all respective onRejected
callbacks must execute in the order of their originating calls to then
.
2.2.7. then
must return a promise. ✅
promise2 = promise1.then(onFulfilled, onRejected);
⟶ If either onFulfilled
or onRejected
returns a value x
, run the Promise Resolution Procedure [[Resolve]](promise2, x)
. ❌
⟶ If either onFulfilled
or onRejected
throws an exception e
, promise2
must be rejected with e
as the reason. ❌
⟶ If onFulfilled
is not a function and promise1
is fulfilled, promise2
must be fulfilled with the same value as promise1
. ❌
⟶ If onRejected
is not a function and promise1
is rejected, promise2
must be rejected with the same reason as promise1
. ❌
Implementation
A JavaScript Promise takes an executor function as an argument, which is called immediately when the Promise is created:
new Promise(excecutor);
const promise = new Promise((resolve, reject) => {
// Runs some async or sync tasks
});
The core Promises/A+ specification does not deal with how to create, fulfill, or reject promises. It's up to you. But the implementation you provide for the promise's construction has to be compatible with asynchronous APIs in JavaScript. Here is the first draft of our Promise class:
class YourPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
const resolve = value => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
}
};
const reject = reason => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
}
};
try {
executor(resolve, reject); // The executor function being called immediately
} catch (error) {
reject(error);
}
}
}
Rule 2.1 (Promise States) states that a promise must be in one of three states: pending, fulfilled, or rejected. It also explains what happens in each of these states.
When fulfilled or rejected, a promise must not transition to any other state. Therefore, we need to ensure the promise is in the pending state before making any transition:
const resolve = value => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
}
};
const reject = reason => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
}
};
We already know that a promise's initial state is pending, and we ensure it remains so until explicitly fulfilled or rejected:
this.state = 'pending';
Since the executor function is called immediately upon the promise's instantiation, we invoke it within the constructor method:
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
Our first draft of the YourPromise
class is done here.
The Promise/A+ specification mostly focuses on defining an interoperable then()
method. This method lets us access the promise's current or eventual value or reason. Let's dive into it.
Rule 2.2 (The then Method) states that a promise must have a then()
method, which accepts two arguments:
class YourPromise {
constructor(executor) {
// Implementation
}
then(onFulfilled, onRejected) {
// Implementation
}
}
Both onFulfilled
and onRejected
must be called after the promise is fulfilled or rejected, passing the promise's value or reason as their first argument if they are functions:
if (this.state === 'fulfilled') { // Assumes promise is already fulfilled
try {
const result = onFulfilled ? onFulfilled(this.value) : this.value;
resolve(result);
} catch (error) {
reject(error);
}
}
if (this.state === 'rejected') { // Assumes promise is already rejected
try {
const result = onRejected ? onRejected(this.reason) : this.reason;
reject(result);
} catch (error) {
reject(error);
}
}
Additionally, they must not be called before the promise is fulfilled or rejected, nor more than once. Both onFulfilled
and onRejected
are optional and should be ignored if they are not functions.
If you look at Rules 2.2, 2.2.6, and 2.2.7, you'll see that a promise must have a then()
method, the then()
method can be called multiple times, and it must return a promise:
then(onFulfilled, onRejected) {
return new YourPromise(executor);
}
promise2 = promise1
.then(...)
.then(...)
.then(...);
To keep things simple, we won't deal with separate classes or functions. We'll return a promise object, passing an executor function:
then(onFulfilled, onRejected) {
return new YourPromise((resolve, reject) => {
if (this.state === 'fulfilled') {
try {
const result = onFulfilled ? onFulfilled(this.value) : this.value;
resolve(result);
} catch (error) {
reject(error);
}
}
if (this.state === 'rejected') {
try {
const result = onRejected ? onRejected(this.reason) : this.reason;
reject(result);
} catch (error) {
reject(error);
}
}
});
}
Within the executor function, if the promise is fulfilled, we call the onFulfilled
callback and resolve it with the promise's value. Similarly, if the promise is rejected, we call the onRejected
callback and reject it with the promise's reason.
The next question is what to do with onFulfilled
and onRejected
callbacks if the promise is still in the pending state? We queue them to be called later, as follows:
if (this.state === 'pending') {
this.onFulfilledCallbacks.push(value => {
try {
const result = onFulfilled ? onFulfilled(value) : value;
resolve(result);
} catch (error) {
reject(error);
}
});
this.onRejectedCallbacks.push(reason => {
try {
const result = onRejected ? onRejected(reason) : reason;
reject(result);
} catch (error) {
reject(error);
}
});
}
We're done. Here's the second draft of our Promise class, including the then()
method:
class YourPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = value => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(callback => callback(value));
}
};
const reject = reason => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(callback=> callback(reason));
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new YourPromise((resolve, reject) => {
if (this.state === 'pending') {
this.onFulfilledCallbacks.push(value => {
try {
const result = onFulfilled ? onFulfilled(value) : value;
resolve(result);
} catch (error) {
reject(error);
}
});
this.onRejectedCallbacks.push(reason => {
try {
const result = onRejected ? onRejected(reason) : undefined;
reject(result);
} catch (error) {
reject(error);
}
});
}
if (this.state === 'fulfilled') {
try {
const result = onFulfilled ? onFulfilled(this.value) : this.value;
resolve(result);
} catch (error) {
reject(error);
}
}
if (this.state === 'rejected') {
try {
const result = onRejected ? onRejected(this.reason) : undefined;
reject(result);
} catch (error) {
reject(error);
}
}
});
}
}
Here, we introduce two fields: onFulfilledCallbacks
and onRejectedCallbacks
as queues to hold callbacks. These queues are populated with callbacks via then()
calls while the promise is pending, and they are called when the promise is fulfilled or rejected.
Go ahead test your Promise class:
const promise = new YourPromise((resolve, reject) => {
setTimeout(() => resolve('Success!'), 1000);
});
promise
.then(value => {
console.log('Fulfilled with:', value);
return 'Next step';
})
.then(value => {
console.log('Chained with:', value);
});
It should output:
Fulfilled with: Success!
Chained with: Next step
On the other hand, if you run the following test:
const promise = new YourPromise(resolve => {
resolve('Immediately resolved!');
});
console.log('Before then()');
promise.then(value => {
console.log('Inside then():', value);
});
console.log('After then()');
You would get:
Before then()
Inside then(): Immediately resolved!
After then()
Instead of:
Before then()
After then()
Inside then(): Immediately resolved!
Why? The issue lies in how the then()
method processes callbacks when the YourPromise
instance is already resolved or rejected at the time then()
is called. Specifically, when the promise state is not pending, the then()
method does not properly defer execution of the callback to the next micro-task queue. And that causes synchronous execution. In our example test:
⟶ The promise is immediately resolved with the value 'Immediately resolved'.
⟶ When promise.then()
is called the state is already fulfilled, so the onFulfilled
callback is executed directly without being deferred to the next micro-task queue.
Here the Rule 2.2.4 comes into play. This rule ensures that the then()
callbacks (onFulfilled
or onRejected
) are executed asynchronously, even if the promise is already resolved or rejected. This means that the callbacks must not run until the current execution stack is completely clear and only platform code (like the event loop or micro-task queue) is running.
Why is this rule important?
This rule is one of the most important rules in the Promise/A+ specification. Because it ensures that:
⟶ Even if a promise is immediately resolved, its then()
callback won't execute until the next tick of the event loop.
⟶ This behavior aligns with the behavior of the other asynchronous APIs in JavaScript such as setTimeout
or process.nextTick
.
How can we achieve this?
This can be achieved with either a macro-task mechanism such as setTimeout
or setImmediate
, or with a micro-task mechanism such as queueMicrotask
or process.nextTick
. Because the callbacks in a micro-task or macro-task or similar mechanism will be executed after the current JavaScript execution context finishes.
To fix the issue above, we need to ensure that even if the state is already fulfilled or rejected, the corresponding callbacks (onFulfilled
or onRejected
) are executed asynchronously using queueMicrotask
. Here's the corrected implementation:
class YourPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = value => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(callback => callback(value));
}
};
const reject = reason => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(callback => callback(reason));
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new YourPromise((resolve, reject) => {
if (this.state === 'pending') {
this.onFulfilledCallbacks.push(value => {
queueMicrotask(() => {
try {
const result = onFulfilled ? onFulfilled(value) : value;
resolve(result);
} catch (error) {
reject(error);
}
});
});
this.onRejectedCallbacks.push(reason => {
queueMicrotask(() => {
try {
const result = onRejected ? onRejected(reason) : reason;
reject(result);
} catch (error) {
reject(error);
}
});
});
}
if (this.state === 'fulfilled') {
queueMicrotask(() => {
try {
const result = onFulfilled ? onFulfilled(this.value) : this.value;
resolve(result);
} catch (error) {
reject(error);
}
});
}
if (this.state === 'rejected') {
queueMicrotask(() => {
try {
const result = onRejected ? onRejected(this.reason) : this.reason;
reject(result);
} catch (error) {
reject(error);
}
});
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
}
Run the previous example test code again. You should get the following output:
Before then()
After then()
Inside then(): Immediately resolved!
That's it.
By now, you should have a clear understanding of how callbacks from then()
are deferred and executed in the next micro-task queue, enabling asynchronous behavior. A solid grasp of this concept is essential for writing effective asynchronous code in JavaScript.
What's next? Since this article didn't cover the full Promises/A+ specification, you can try implementing the rest to gain a deeper understanding.
Since you've made it this far, hopefully you enjoyed the reading! Please share the article.
Top comments (0)