How Promises Work in JavaScript – A Comprehensive Beginner‘s Guide
Promises are an important part of asynchronous JavaScript programming. They provide a mechanism to deal with asynchronous operations in a more readable and organized way compared to callbacks.
In this comprehensive guide, we will cover everything you need to know as a beginner about promises in JavaScript, including:
- What problems promises solve
- What exactly a promise is
- The lifecycle and states of a promise
- How to create promises
- Attaching callbacks to promises with .then() and .catch()
- Running promises in parallel with Promise.all() and Promise.race()
- The async/await syntax for working with promises
- Error handling with promises
- The job queue and event loop
By the end, you‘ll have a solid understanding of promises in JavaScript and how to use them properly in your code.
Prerequisites
To best follow along, you should have:
- Basic JavaScript syntax knowledge
- Understanding of asynchronous code and callbacks
If you need to brush up on these, check out MDN‘s JavaScript Guide and this Async Operations in JavaScript article first.
Why Promises Matter
Before promises were introduced in ES6, asynchronous JavaScript code relied on callbacks. However, using callbacks can quickly become messy.
Consider this code fetching data from multiple sources:
function fetchPage1(callback) {
// async request
if(success) {
callback(page1Data)
} else {
callback(error)
}
}
function fetchPage2(callback) {
// async request
if(success) {
callback(page2Data)
} else {
callback(error)
}
}
function fetchPage3(callback) {
// async request
if(success) {
callback(page3Data);
} else {
callback(error)
}
}
fetchPage1(function(page1Data) {
console.log(page1Data);
fetchPage2(function(page2Data) {
console.log(page2Data);
fetchPage3(function(page3Data) {
console.log(page3Data);
});
});
});
This "callback hell" pyramid shape makes the code difficult to read, reason about, and maintain – especially for longer asynchronous sequences.
Promises provide a simpler and flatter structure for writing async code by letting you chain and compose promises instead of nesting callbacks inside callbacks.
Overall, promises make asynchronous code in JavaScript more efficient, performant, and readable. They enable you to write complex async code that is still elegant and clean.
What is a Promise?
A promise in JavaScript is an object that represents an asynchronous operation.
Essentially, a promise is a returned object to which you attach callbacks, instead of passing callbacks into a function.
A promise can have three possible states:
- Pending: Initial state, neither fulfilled nor rejected.
- Fulfilled: Operation completed successfully.
- Rejected: Operation failed.
A promise starts off pending and then may transition to fulfilled or rejected depending on the outcome of the async operation.
For example, fetching data from an API:
const dataPromise = fetch(apiURL);
// 1. dataPromise is now pending
// 2. async operation completes:
if(response.ok) {
dataPromise.fulfilled(response)
} else {
dataPromise.rejected(Error)
}
// 3. dataPromise is settled - it is either fulfilled or rejected
Consumers of the promise attach callbacks to handle the fulfilled and rejected cases.
Promises avoid callback hell because instead of nesting callbacks inside functions, you chain promises using .then() and .catch().
Creating a Promise
To create a promise in JavaScript, use the Promise constructor:
const promise = new Promise((resolve, reject) => {
// Async operation
const isSuccessful = true;
if(isSuccessful) {
resolve(‘Resolution value‘);
} else {
reject(‘Rejection reason‘);
}
});
The Promise constructor takes one argument – a callback function with resolve and reject parameters.
Inside the callback function, kick off any async operation – like making an API request.
Based on the outcome, call resolve() or reject() to change the promise‘s state and pass along any relevant data.
resolve(value): Fulfills the promise withvaluereject(reason): Rejects the promise withreason
Let‘s create a promise that wraps a timeout to simulate an async request:
function asyncFunc() {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(‘Some data‘);
}, 2000);
});
return promise;
}
asyncFunc();
We create and return the promise, which will resolve with string data after 2 seconds.
To consume the value of a promise, we need callbacks…
Attaching Callbacks with .then() and .catch()
We attach callbacks to promises using .then() and .catch() to handle the resolved and rejected cases:
const promise = asyncFunc();
promise.then((data) => {
console.log(data); // ‘Some data‘
}).catch((err) => {
console.log(err);
});
The first callback passed to .then() handles the fulfilled case.
The second callback passed to .then() and .catch() handle the rejected case.
.then() vs .catch()
You might wonder when to use .then() vs .catch().
Use .then() when:
- Chain promises sequentially
- Handle errors in each step
Use .catch() when:
- Handle all errors in one place instead of each
.then() - Code readability – keep main logic in
.then()
Chaining promises with .then() allows us to avoid the callback pyramid. For example:
fetchData(url)
.then(data => {
// Transform data
return sendData(transformedData)
})
.then(response => {
// Do something else
return fetchMoreData()
})
.catch(err => {
// Handle all errors here
});
Much easier to reason about!
Promise Methods for Parallel Execution
Often you need to run async promises concurrently and wait for all or some to settle.
Let‘s explore the main methods that allow parallel promise flows:
Promise.all()
Takes an array of promises and returns a single promise.
The single promise resolves when all promises in the array resolve.
Gets an array of resolved values.
const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve(2);
const promise3 = Promise.resolve(3);
Promise.all([promise1, promise2, promise3]).then(values => {
console.log(values); // [1, 2, 3]
});
Rejects immediately if any promise rejects.
Promise.race()
Takes array of promises and returns the first to settle – either fulfill or reject state.
const promise1 = new Promise((resolve, reject) =>
setTimeout(resolve, 1000)
);
const promise2 = new Promise((resolve, reject) =>
setTimeout(resolve, 500)
);
Promise.race([promise1, promise2]).then(value => {
// promise2 resolves first
});
Good for timeout implementation – first settled promise wins the race.
Promise.any()
Similar to race, but waits only for the first fulfilled promise to settle while ignoring rejected ones.
const promises = [
fetchWithTimeout(url1),
fetchWithTimeout(url2),
fetchWithTimeout(url3),
];
try {
let first = await Promise.any(promises);
console.log(first);
} catch (error) {
console.log(error);
// All promises rejected
}
Promise.allSettled()
Returns a promise that is fulfilled when all input promises are settled (doesn‘t matter if rejected or not).
Get array of settlement states – useful when you need status of all promises.
const promise1 = Promise.resolve(1);
const promise2 = Promise.reject(‘Oops‘);
Promise.allSettled([promise1, promise2]).then(results => {
results.forEach(res => console.log(res));
/*
{ status: "fulfilled", value: 1 }
{ status: "rejected", reason: "Oops" }
*/
});
These methods enable concurrent async flows with coordinated outcomes.
Now let‘s look at async/await that takes promises to the next level…
Async/Await for Clean Promise Code
Async/await was introduced in ES2017 and is built on top of promises.
It allows you to write asynchronous code that looks synchronous by using async and await.
Async Functions
An async function is a special function that implicitly returns a promise and allows usage of await inside it.
Functions are made async by putting the async keyword before function:
async function getUser() {
// Async code here
}
const promise = getUser(); // Implicitly returns promise
Any value returned from an async function gets implicitly wrapped in a resolved promise.
If the async function throws, it returns a rejected promise.
Await Expression
The await keyword can be put in front of any async promise-based function to pause execution until promise settles, then returns the fulfilled value.
async function getUser() {
const response = await fetch(‘/api/user‘); // Execution pauses here
const user = response.json(); // Execution resumes here
return user;
}
getUser().then(user => console.log(user));
- Makes code look synchronous but run asynchronously
- Must be used inside async function
Await expressions also bubble up promise rejections if there is an error:
async function getUser() {
try {
const user = await getUserFromDB(); // Execution stops on rejection
console.log(user);
} catch (err) {
// Catches error
}
}
So with async/await you get implicit promise invocation, async execution, and unified error handling!
Error Handling With Promises
Robust error handling is important with async promise-based code to handle faults and unexpected errors.
We‘ve already seen .catch() used to handle errors in promises.
We can also use try...catch inside async functions:
function getUser() {
try {
const user = await fetchUser();
} catch (error) {
// Catches errors in getUser()
}
}
getUser();
If a promise inside try rejects, execution jumps to the catch block.
We can also handle errors with .finally():
getUser()
.then(user => {
// Handle user
})
.catch(err => {
// Handle error
})
.finally(() => {
// Always runs after promise settles
});
Good use cases for .finally() are cleanup operations or stopping loading indicators.
These various techniques give fine-grained control for handling errors from async promise-based code.
The Job Queue
The job queue allows promises to execute in a predictable manner.
It‘s a queue that executes special types of async tasks before other async tasks like setTimeout.
For example:
Promise.resolve(‘promise done‘)
.then(console.log);
setTimeout(() => console.log(‘timeout done‘));
console.log(‘statement done‘);
// Logs:
// statement done
// promise done
// timeout done
Although the setTimeout was called first, the promise callback got pushed to the job queue, which runs before the callback queue that handles setTimeout.
So the job queue handles promise callbacks in order after the call stack clears.
This ensures expected promise behavior compared to other types of async behavior like timers and network requests.
Conclusion
Congratulations 🎉 – You now have a comprehensive understanding of promises in JavaScript, including how they improve async code over callbacks.
We covered:
- The states and lifecycle of promises
- How to create and consume promises with
.then(),.catch() - Running promise concurrently with
Promise.all()etc - Using async/await for cleaner async code
- Proper error handling techniques with promises
- The job queue and how it enables consistent promise scheduling
You now have the knowledge to harness the power of promises in your code to write professional asynchronous JavaScript.
Happy coding!