All About JavaScript Promises
Problems are the root of invention and progress. One of the most annoying issues that JavaScript developers faced several years ago was Callback Hell. The insane syntax and complexity of code frustrated many developers and made JavaScript appear unattractive to others.
Now, the language is much more developer-friendly thanks to some abstractions that have been created. One of them is the Promise
. Today, we'll learn everything about this API and master it.
Problems With Callbacks
Take a look at Callback Hell - the main reason for Promise
involvement:
// Simulating asynchronous operations with callbacks.
function asyncOperation1(callback) {
setTimeout(() => {
console.log("Operation 1 complete");
callback(null, "Result of operation 1");
}, 1000);
}
function asyncOperation2(result1, callback) {
setTimeout(() => {
console.log("Operation 2 complete with input:", result1);
callback(null, "Result of operation 2");
}, 1000);
}
// Callback hell example
asyncOperation1((err, result1) => {
if (err) {
console.error("Error in operation 1:", err);
return;
}
asyncOperation2(result1, (err, result2) => {
if (err) {
console.error("Error in operation 2:", err);
return;
}
console.log("Final result:", result2);
});
});
The JavaScript community has created memes highlighting how insanely difficult it is to understand this syntax.
In addition, callbacks themselves are stateless and lack a unified structure. Different libraries may implement callbacks in various ways to pass an error, response, or other data, leading to inconsistencies.
// Inconsistency...
lib1((err, data) => {
if (err) {
return;
}
lib2(({ err, data }) => {
if (err) {
return;
}
// Do other stuff...
});
});
Next, you may notice the duplication of error handling logic. Every time, you need to add an if statement at every nested level of the code.
lib1((err, data) => {
// The repeated part.
if (err) {
return;
}
It's not the end yet :D. With the callback approach, we can clearly see that the behavior of each callback often depends on the previous one. As a result, the code we need to write to handle such behavior becomes very complex. Imagine needing to change the order of callbacks - good luck with that...
asyncOperation1((err, result1) => {
if (err) {
console.error("Error in operation 1:", err);
return;
}
asyncOperation2(result1, (err, result2) => {
if (err) {
console.error("Error in operation 2:", err);
return;
}
console.log("Final result:", result2);
});
});
Lastly, there is a lack of easy control over code behavior. Imagine React without a built-in useEffect
hook, where you would need to write convoluted code to listen for state changes or component updates - it would be a nightmare. To address this, React implemented a pattern called Inversion of Control. React provides an API to execute certain functions, and as developers, we only need to provide a function without worrying about when or how it will be called. React handles the invocation for us; we just specify the logic.
const ExampleComponent = () => {
const [count, setCount] = useState(0);
// useEffect runs after every render.
useEffect(() => {
// This is the effect logic.
console.log(`Component rendered with count: ${count}`);
// Optionally return a cleanup function.
return () => {
console.log(`Cleaning up after count: ${count}`);
};
}, [count]); // Dependency array, effect runs when `count` changes.
}
With callbacks, we need to specify the way and moment imperatively. That's why the Promise
was added to the language - to solve these problems and make the syntax much easier to work with. Here is the same version of async code management, but now implemented with promises instead of callbacks.
asyncOperation1()
.then((result1) => {
return asyncOperation2(result1);
})
.then((result2) => {
return asyncOperation3(result2);
})
.then((result3) => {
console.log("Final result:", result3);
})
.catch((err) => {
console.error("Error:", err);
});
Promises In Theory
Here is a documentation definition:
A
Promise
in JavaScript is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. It provides a cleaner and more manageable way to deal with asynchronous code compared to traditional callback-based approaches.
The Promise
may have three states:
- Pending: the initial state, neither fulfilled nor rejected.
- Fulfilled: the operation completed successfully.
- Rejected: the operation failed.
The best way to understand promises is to compare them with a real-life situation. Imagine a letter that you're sending to a family member. The letter is hidden inside an envelope. Without the envelope, the letter carrier can see what you've sent, which is risky. Additionally, to provide information about where the letter should be delivered, you would need to destroy or change the form of the letter by including this information at the top or bottom. A much better way is to hide the letter inside an envelope.
Comparing this to promises, the delivery is an action that can have three possible states, just like a Promise
(pending, fulfilled, or rejected). The envelope is a Promise
, and the information attached to the envelope, like the destination address, is Promise
metadata.
Promises are not only implemented in JavaScript. Although a Promise is not officially a design pattern, it is widely used to handle asynchronous operations. Promises are also implemented in languages such as
C#
,Python
,Java
,Rust
, andSwift
.
In summary, it's a way to describe an asynchronous operation that takes time to complete, attach metadata to it, and react to changes in the operation's state. Here is the diagram from MDN Web Docs:
Basic Promise Syntax
Let's examine the theory. Imagine we're writing an app that allows users to send virtual letters. We have the following process:
- Writing a letter.
- Delivering the letter.
- Receiving a payment.
All of these are asynchronous operations may take time. It makes them perfect candidates for using a Promise
. Additionally, we want to handle all errors that may occur during this process.
interface Letter {
id: string;
content: string;
}
const writeLetter = (content: string): Promise<Letter> => {
return fetch(`/api/letter/create`, { method: 'post', body: content }).then(
(res) => res.json()
);
};
const deliverLetter = (letter: Letter): Promise<Letter['id']> => {
return fetch(`/api/letter/deliver`, {
method: 'post',
body: JSON.stringify(letter),
}).then((res) => res.json());
};
const receivePayment = (): Promise<number> => {
return fetch(`/api/payment/receive`).then((res) => res.json());
};
const deliveryProcess = () => {
writeLetter('My letter content')
.then((letter) => deliverLetter(letter))
.then(() => receivePayment())
.catch((e: unknown) => {
console.log(e);
})
.finally(() => {
// It's "fulfilled" or "rejected" here.
// Or we may call it "settled".
});
};
// It runs the whole process.
deliveryProcess();
As you saw, we've chained several asynchronous operations into one big process. Each .then
continues the next step in the process - an API call that moves the process forward. After each step is fulfilled, the next Promise
is created, and the next steps are involved. If an error occurs (rejected), we handle it once inside the .catch
block.
On the other hand, whether the Promise
is fulfilled or rejected, we can handle logic inside .finally()
- such as clean-ups, logging, state resets, and many other aspects.
It's really important to understand that a
Promise
can be chained as many times as needed. It is simply a wrapper for an asynchronous operation. Thanks to thethen
,catch
, andfinally
methods, it allows us to handle any process at a high level without unnecessary nesting.
The Async and Await Keywords
This makes promises even simpler. Instead of directly calling then
, catch
, or finally
, we can use async
and await
to wrap code with built-in JavaScript exception handling.
interface Letter {
id: string;
content: string;
}
const writeLetter = async (content: string): Promise<Letter> => {
return (
await fetch(`/api/letter/create`, { method: 'post', body: content })
).json();
};
const deliverLetter = async (letter: Letter): Promise<Letter['id']> => {
return (
await fetch(`/api/letter/deliver`, {
method: 'post',
body: JSON.stringify(letter),
})
).json();
};
const receivePayment = async (): Promise<number> => {
return (await fetch(`/api/payment/receive`)).json();
};
const deliveryProcess = async () => {
try {
const letter = await writeLetter('My letter content');
await deliverLetter(letter);
await receivePayment();
} catch (e: unknown) {
console.log(e);
} finally {
// It's "fulfilled" or "rejected" here.
}
};
// It runs the whole process.
deliveryProcess();
The async
keyword allows us to use await
. Without the async
keyword, we'll get a syntax error when trying to use await
. Additionally, after declaring a function as async
, we get the following behavior:
- If the function returns a value, the
Promise
is resolved with that value. - If the function throws an error, the
Promise
is rejected with that error.
Essentially, it automatically calls the then
and catch
methods for us, allowing us to write more linear code with less boilerplate - especially when transforming blocks like .then(() => somePromiseFn())
to await somePromiseFn()
.
Now about await
:
- When the
await
keyword is used, theasync
function is paused until thePromise
is settled (fulfilled or rejected). - If the
Promise
is fulfilled, theawait
expression returns the resolved value. - If the
Promise
is rejected, theawait
expression throws the rejected value (similar to thethrow
statement).
In summary, it's just syntactic sugar, similar to how class
is to the object prototype.
Converting Async Operations To Promises
Sometimes you're using legacy APIs, or you don't like the way they handle asynchronous operations. The Promise
offers a way to handle such scenarios easily by converting async operations into Promise
objects. Let's say you have a legacy library in the NodeJS for reading files:
import { read } from "file-reader";
read(`/path-to-file`, (err, file) => {
if (err) {
console.log(err);
return;
}
console.log(file);
});
You can easily convert it to a promise-based version with the following code:
const readPromise = (onSuccess) => {
return new Promise((resolve, reject) => {
read(`/path-to-file`, (err, file) => {
if (err) {
// The "return" ensures that "resolve" code is not called when an error occurs.
return reject(err);
}
// In this case "return" is optional, as there is nothing more after this line.
onSuccess();
resolve(file);
});
});
};
We've used a Promise
constructor that takes resolve
and reject
functions, allowing us to craft our custom Promise
mechanism. Notice the important return
statement in the error handling block. Without it, onSuccess
could be called even when an error occurs, which is invalid behavior.
In the example code, we did not add return
to resolve
. In this case, it's fine, but if you have more complicated logic with multiple if
statements, it's necessary to avoid bugs. To ensure consistency and avoid forgetting, I always add return
to both resolve
and reject
.
if (something) return reject(err);
if (somethingElse) return resolve();
return resolve();
There is an ESLint plugin called Consistent Return to help ensure that return
statements are consistent.
Adding a
return
beforeresolve
is generally a good practice, especially in functions with more complex logic. It ensures that the function exits immediately after callingresolve
orreject
, preventing any accidental execution of subsequent code. Choose this approach and be consistent.
Promises Chaining
To understand how it works, let's consider that we have several functions that return promises. Some of them will reject.
const randomPromiseFactory = (reject: boolean) => () =>
reject ? Promise.reject(new Error('Error')) : Promise.resolve();
const p1 = randomPromiseFactory(false);
const p2 = randomPromiseFactory(true);
const p3 = randomPromiseFactory(false);
const p4 = randomPromiseFactory(false);
const p5 = randomPromiseFactory(true);
In this code, the p2
and p5
functions will reject a value - the reject
function will be called. All others will fulfill, meaning the resolve
function will be called. Now, we want to achieve the following scenario:
- Call
p1
. - Call
p2
. - If any of the above fail, call
p3
. - Call
p4
regardless of whetherp3
fails or succeeds.
We can use the following syntax:
// Version 1
const chain = () => {
p1()
.then(() => p2())
.catch(() => p3())
.then(() => p4());
};
// Version 2
const chain = async () => {
try {
await p1();
await p2();
} catch {
await p3();
}
await p4();
};
As you saw, we've transformed each function invocation that returns a Promise
into other promises to achieve the desired algorithm. What's really cool is that the nesting doesn't matter - you can have a deeply nested promise chain and catch errors at the top, or map the outcomes and handle them in the next .then
blocks. Examine the following scenario:
const step1 = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Step 1 complete');
}, 1000);
});
};
const step2 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('Error in step 2');
}, 1000);
});
};
const step3 = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Step 3 complete');
}, 1000);
});
};
step1()
.then(result => {
console.log(result);
return step2().then(step2Result => {
console.log(step2Result);
return step3();
});
})
.then(result => {
console.log(result);
})
.catch(error => {
console.error('Caught error:', error);
});
// Output:
// Step 1 complete
// Caught error: Error in step 2
In this example:
- Step 1 completes successfully and logs "Step 1 complete".
- Step 2 fails, and the error is caught by the
.catch()
block, logging "Caught error: Error in step 2". - Step 3 is not called because the chain was interrupted by the rejection in Step 2.
Static Methods To Handle Promises
Promise.resolve(value)
Returns a Promise
that is resolved with the given value.
Promise.resolve('Success').then(value => {
console.log(value); // "Success"
});
Promise.reject(reason)
Returns a Promise
that is rejected with the given reason.
Promise.reject('Error').catch(reason => {
console.log(reason); // "Error"
});
Promise.all(iterable)
Returns a Promise
that resolves when all of the promises in the iterable have resolved. It rejects when any Promise
in the iterable rejects.
let promise1 = Promise.resolve(1);
let promise2 = Promise.resolve(2);
Promise.all([promise1, promise2]).then(values => {
console.log(values); // [1, 2]
});
Promise.allSettled(iterable)
Returns a Promise
that resolves when all of the promises in the iterable have settled (either fulfilled or rejected). It never rejects. Instead, it passes an array to the fulfilled callback.
let promise1 = Promise.resolve(1);
let promise2 = Promise.reject('Error');
Promise.allSettled([promise1, promise2]).then(results => {
results.forEach(result => console.log(result.status));
// "fulfilled"
// "rejected"
});
Promise.any(iterable)
Returns a Promise
that resolves as soon as one of the promises in the iterable resolves, with the value from that promise. If no promises resolve or all rejects, it rejects with an AggregateError
.
let promise1 = Promise.reject("Error");
let promise2 = new Promise((resolve) => setTimeout(resolve, 100, "two"));
let promise3 = new Promise((resolve) => setTimeout(resolve, 200, "one"));
Promise.any([promise1, promise2, promise3])
.then((value) => {
console.log(value); // It will prompt "two".
})
.catch((error) => {
if (error instanceof AggregateError) {
console.error(
"All promises were rejected. AggregateError:",
error.errors
);
} else {
console.error("An unexpected error occurred:", error);
}
});
Use case: You're developing an email sending service built on top of three different email providers. You want to send an email to a user following a system action. Using Promise.any
, the Promise
will resolve as soon as the fastest service sends the email. If all providers fail, you can handle the error using AggregateError
.
Promise.race(iterable)
Promise.race
resolves or rejects as soon as one of the promises in the iterable it receives settles, regardless of whether the outcome is a resolution or rejection. Essentially, it returns the result of the fastest promise.
let promise1 = new Promise((resolve) => setTimeout(resolve, 500, 'one'));
let promise2 = new Promise((resolve) => setTimeout(resolve, 100, 'two'));
let promise3 = Promise.reject("Error");
Promise.race([promise1, promise2]).then(value => {
console.log(value); // Prompts "two".
});
Promise.race([promise1, promise2, promise3])
.then((value) => {
// It will be not called due to one rejection in "promise3".
console.log(value);
})
.catch((error) => {
// It will go to error "catch" block.
console.error("An unexpected error occurred:", error);
});
Use Case: You have several instances of a backend API managed by a custom load balancer, located in various parts of the world. You aim to route a request to the instance closest to the user to retrieve their data. Therefore, you want to return the result to the user immediately after receiving a response or rejection from the first instance that processes the request.
Promise.withResolvers()
This method is useful when you need to create and control a Promise
instance that will be resolved or rejected later, often within a callback or asynchronous mechanism.
// Example usage: Waiting for a button click.
const { promise, resolve, reject } = Promise.withResolvers();
const button = document.createElement('button');
button.textContent = 'Click me';
document.body.appendChild(button);
button.addEventListener('click', () => {
resolve('Button was clicked');
button.remove(); // Clean up the button after it is clicked.
});
// Handling the promise.
promise
.then(message => {
console.log(message);
})
.catch(error => {
console.error(error);
});
// The promise will be resolved when the button is clicked.
The key point here is that the Promise
is not resolved immediately. It resolves only when the resolve
function is called via a click event, which then triggers the promise.then
method. This is possible because the resolve
and reject
functions are assigned to variables in a higher scope, allowing us to call them from anywhere without unnecessary nesting.
FAQs
If I call resolve
or reject
multiple times, will the .then
or .catch
handlers be called the same number of times?
No, calling resolve
or reject
multiple times on a single promise will not cause the .then
or .catch
handlers to be called multiple times. Once a promise is settled (either fulfilled or rejected), its state is final and cannot be changed. Subsequent calls to resolve
or reject
will have no effect.
Here’s an example to illustrate this behavior:
const promise = new Promise((resolve, reject) => {
resolve('First value');
resolve('Second value');
reject('Error');
});
promise
.then(value => {
console.log('Resolved with:', value);
})
.catch(error => {
console.error('Rejected with:', error);
});
// Logs: "Resolved with: First value"
Should I always use return
when using Promise.reject
or Promise.resolve
?
Yes, it is generally a good practice to use return
when calling Promise.resolve
or Promise.reject
inside a function, especially within complex logic or asynchronous callbacks. This ensures that the promise chain is properly maintained and can help avoid unexpected behaviors in future code changes.
If you have any logic under reject
or resolve
, it will still be executed. Remember, both resolve
and reject
may be called multiple times, but only the first call will affect the state of the promise. Subsequent calls will have no effect on the promise's state, but they may affect the logic within your function. Consider the following example:
const readPromise = () => {
return new Promise((resolve, reject) => {
read(`/path-to-file`, (err, file) => {
if (err) {
return reject(err);
}
// Ensure this function is not called if an error occurs.
myOtherComplexFunction();
resolve(file);
});
});
};
In this example, using return
with reject
ensures that myOtherComplexFunction()
is not called if an error occurs. Without the return
statement, myOtherComplexFunction()
would still be executed even if there was an error, which is undesirable behavior.
So, always adding a return
statement for reject
and resolve
can help prevent such issues and keep your logic clear and predictable. This practice is already explained in the article to ensure you maintain proper promise behavior.
How do Promises work under the hood?
In JavaScript, the execution of promises is managed by the event loop and the microtask queue. When a Promise
is resolved or rejected, the corresponding .then
, .catch
, or .finally
handlers are placed in the microtask queue.
Here's the relevant API:
queueMicrotask(callback);
It's important to understand that the callbacks passed to .then
, .catch
, and .finally
are added to the microtask queue, not the internal logic of the Promise itself. The promise's internal logic is handled immediately in a synchronous manner.
What is the difference between Promise.race
and Promise.any
?
The name Promise.any
might seem misleading. While both functions aim to find the fastest Promise
, they handle resolve and reject differently. Here are the differences:
Promise.race
takes multiple promises and returns the result of the first promise that settles, regardless of whether it resolves or rejects. It's like a race where the first promise to finish, for better or worse, ends the race.
Promise.any
takes multiple promises and returns the result of the first promise that resolves successfully. It ignores any promises that reject unless all of them fail. If all promises reject, it throws an AggregateError
. Think of it as a contest where the first success wins, but all failures are ignored unless everyone fails.
Summary
Wow, that was a huge article, but I hope everything is now clear about the Promise
concept. I found it useful to write this article because it refreshed my knowledge and clarified some gaps in my understanding. The most important concepts you should remember after reading this article are:
- A
Promise
is an object representing the eventual completion or failure of an async operation, with three possible states: pending, fulfilled, and rejected. - The fulfilled or rejected states are collectively known as settled.
- A
Promise
is in the pending state immediately after thePromise
instance is created. - The fulfilled state occurs when the
resolve
function is called. - The rejected state occurs when the
reject
function is called. - Promises can be chained with
.then()
and.catch()
methods, allowing for complex asynchronous workflows. - The
async
andawait
provide syntactic sugar to work with Promises more easily. - The
Promise
API has many static methods, such asPromise.all
,Promise.race
,Promise.allSettled
, andPromise.any
, to simplify logic. - You can wrap current non-promise-based APIs with the
Promise
constructor to make them return promises.
Remember to practice using this API to fully understand it. It can be particularly challenging on the backend with Node.js, and any gaps or misunderstandings can lead to many bugs later on.