Mastering JavaScript Promises: From Basics to Advanced

Murat Cakmak
Insider Engineering
8 min readOct 9, 2023

JavaScript’s single-threaded nature often poses challenges, especially when dealing with asynchronous operations. Enter Promises, a solution to these challenges. When JavaScript starts reading code, it moves to the next line without waiting for the current line’s output and arranges them in a certain order.

That’s why Promises come to the rescue when we want to perform synchronous operations. We can use them to convert asynchronous tasks into synchronous ones.

I will explain why and how to use them.

The goal here is to provide a comprehensive guide on JavaScript Promises.

Understanding Asynchronous JavaScript

Asynchronous programming in JavaScript means that the code doesn’t wait for one task to finish before moving to the next. This can lead to faster execution but also complexity.

In fact, what we do with asynchronous programming can also be achieved by opening multiple threads, but JavaScript, which handles everything with a single thread, works more efficiently.

Before the ES6 update, asynchronous operations could be done with Callback Hell. Callback Hell refers to the complex structure that arises when we nest multiple callbacks inside a function. The resulting code structure resembles a pyramid, which is why Callback Hell is also known as the “pyramid of doom.” It makes the code less readable and harder to maintain.

The following code block is an example of this.

getArticles(20, (user) => {
console.log("Fetch articles", user);
getUserData(user.username, (name) => {
console.log(name);
getAddress(name, (item) => {
console.log(item);
// this goes on and on...
});
});
});

Promise Fundamentals

Promises were introduced in JavaScript with ES6 in 2015.

A Promise is a JavaScript object used for managing asynchronous operations.

Promises allow you to write code that continues after a specific event occurs without blocking the execution of other code; JavaScript continues to read the code asynchronously.

Promises enable the handling of data that is not currently available but will be available in the future.

A Promise has three states in its structure, and when a Promise is initiated, it is in one of these three states:

  • Pending: This is the initial state when the Promise is neither fulfilled nor rejected. It represents the state of the Promise while the asynchronous operation is still ongoing.
  • Fulfilled: This state signifies that the asynchronous operation associated with the Promise has been successfully completed.
  • Rejected: This state indicates that the asynchronous operation has failed or been rejected for some reason.

Promises transition from the “Pending” state to either “Fulfilled” or “Rejected” once the asynchronous operation is completed. These states help manage the flow of asynchronous code and handle its outcomes.

When a Promise is either fulfilled or rejected, it enters the “settled” state, and in this step, there are two important methods:

  • then: When a Promise successfully transitions to the “fulfilled” state, the then method allows you to specify a callback function or code block that will work with the completed data. This is used to define what should happen when a successful result is obtained.
  • catch: When a Promise transitions to the “rejected” state, the catch method lets you specify a callback function or code block that will work with the rejected error. This is used to handle situations where the operation fails.

These methods are very useful for making Promise-based asynchronous code more readable and error-resistant.

const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
const randomNumber = Math.random();
if (randomNumber < 0.5) {
resolve("Data has been successfully retrieved.");
} else {
reject("An error occurred while fetching data.");
}
}, 1000);
});

myPromise
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error(error);
});
  1. We’ve added logic to randomly either resolve the Promise with a success message or reject it with an error message.
  2. We use the then method to handle the successful resolution and print the success message.
  3. We use the catch method to handle any errors and print the error message if the Promise is rejected.

Promise Chaining

To combine multiple Promises and perform Promise chaining, you can follow these steps. Promise chaining allows for the sequential execution of asynchronous operations that depend on each other.

The Promise structure is inherently asynchronous, which means it doesn’t block the execution of other code. Therefore, to perform sequential asynchronous tasks that depend on one another, you need to create a Promise chain.

fetchData()
.then(data => {
return otherFetchData(data);
})
.then(chainData => {
return chainData.response;
})
.catch(error => {
console.log(error);
});

Let’s examine the Promise chains in the code example above:

  1. A Promise chain is created by adding multiple “.then()” methods.
  2. Since the Promise that initiates the chain is at the beginning, the functions within each “.then()” become Promises, and their return values are passed to the next link in the chain.
  3. Only one “.catch()” is sufficient within the Promise chain. If an unexpected error occurs within the chain, the subsequent “.then()” links are skipped, and the function inside “.catch()” is executed.

Consuming Promises

After creating our Promise object, we complete it with “.then()”. For example, when we send a request to an API using fetch, we expect the API to return a response. When this response is received, the Promise is resolved, and we can use .then() to handle it.

If we return the data received from fetch with “.then()” and add another .then() after it, the previous “.then()” becomes a Promise itself. When it resolves, we can process the data returned in the previous “.then()”.

If any error occurs during this process, the operation will be rejected, and we can catch these errors using “.catch()”.

If we want to perform certain actions after the operation is completed, whether it is resolved or rejected, we can use the “.finally()” method to do so.

Promise.all() and Promise.race()

Promise.all()

  1. You can use the “Promise.all()” method to check if all parallel asynchronous operations have been completed.
  2. Chaining asynchronous operations that don’t need to wait for each other can extend the overall execution time of the process.
  3. When all Promise operations in the array resolve, all the data in the arrays are successfully returned.
  4. If any Promise in the array is rejected, the result of “Promise.all()” will also be rejected.
const promise1 = Promise.resolve(123);
const promise2 = 456;
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 1000, 'foo');
});

Promise.all([promise1, promise2, promise3]).then((values) => {
console.log(values);
});

// output: [123, 456, 'foo']

Promise.race()

  1. The “Promise.race()” method, as specified, is the process of racing the promises within an array.
  2. The value of the first completed promise becomes valid.
  3. Whether the result is resolved or rejected doesn’t matter; the result of the first completed promise is returned.
const promise1 = new Promise((resolve, reject) => {
setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'two');
});

Promise.race([promise1, promise2]).then((value) => {
console.log(value);
});

// output: two

In the example above, two Promise objects are defined.

  • When both of these promises are invoked simultaneously with “Promise.race()”, the promises start executing.
  • The data from the first Promise that resolves is returned within “Promise.race()”, so we receive the “two” output defined within promise2.

Real World usage scenarios for both of these methods

In the following “Promise.all()” method, initially, values are returned using different methods. The Promise process continues until these values are completed.

After the expected operations are completed, the “Promise.all()” method runs and provides the output of all Promise values.

const promise1 = Promise.resolve(123);
const promise2 = 456;
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 1000, 'foo');
});

Promise.all([promise1, promise2, promise3]).then((values) => {
console.log(values);
});

// output: [123, 456, 'foo']

Async/Await

Async is a library introduced with ES6 that simplifies asynchronous programming in JavaScript.

The async keyword indicates that the result of a function is a Promise, while await waits for a Promise object to complete its operation.

By using the async and await keywords, you can wait for the response of a request sent with fetch, and once the response is received, you can continue with the processing.

async function fetchData(url) {
const response = await fetch(url);
const data = await response.json();

return data;
}

fetchData('https://api.example.com/data');

As shown in the example below, if the response does not return as expected, the throw statement will execute, and the catch segment will handle the error. This allows us to easily catch and manage errors.

async function fetchData(url) {
try {
const response = await fetch(url);

if (response.ok) {
const data = await response.json();

return data;
} else {
throw new Error('Network response was not ok.');
}
} catch (error) {
console.error(error);
}
}

const data = fetchData('https://api.example.com/data');

console.log(data);

Real World Examples

When developing a web application, using Promises instead of callbacks is necessary to enhance its readability and maintainability.

As shown below, when we make developments like this, with each addition or modification, the code will take on the ‘pyramid of doom’ shape. And with each development, the readability and maintainability of the code will diminish further.

getArticles(20, (user) => {
console.log("Fetch articles", user);
getUserData(user.username, (name) => {
console.log(name);
getAddress(name, (item) => {
console.log(item);
// this goes on and on...
});
});
});

In the example below, a more understandable structure emerges.

  • We send a request to the API with Fetch, and based on the response, we either resolve or reject it.
  • As a result, we can print the outcome to the screen using “.then()” or “.catch()”. After this step, we can transform it into a Promise chain using .then(), making it simpler to update the code.
function fetchData(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error('Network response was not ok.');
}
})
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
}

fetchData('https://api.example.com/data')
.then(data => {
console.log(data);
})
.catch(error => {
console.error(error);
});

Conclusion

Promises allow us to write asynchronous operations in JavaScript in a more organized and maintainable way. This makes our code more readable. In modern JavaScript development, avoiding structures like callbacks in favor of Promises enhances the readability and maintainability of the code in team collaborations.

If you’re interested in more detailed information and resources, you can explore the additional sources below.

Feel free to add any questions or suggestions in the comments section.

Thank you for reading!

Resources

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#promise_concurrency
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises#chaining

https://levelup.gitconnected.com/vimp-javascript-promise-implementation-challenges-5a4f120d8606

https://javascript.info/promise-basics

If you are curious about Micro-Frontends in JavaScript, you can check this other post. Follow us on the Insider Engineering Blog to read more about our Agile Best Practices.

--

--