Exploring the Depths of Promises: A Guide to Asynchronous JavaScript

Sanya gupta
Quinbay
Published in
8 min readApr 11, 2024

In the realm of modern web development, managing asynchronous operations efficiently is essential as JavaScript applications evolve in complexity. This is where promises comes in handy, providing a simplified way to handle asynchronous javascript. In this article, we will deep dive into the world of promises, exploring their benefits and real-world applications.

Understanding Synchronous & Asynchronous JavaScript:

Synchronous JavaScript:

JavaScript as a language operates on a single-threaded/synchronous model, meaning it can only execute one piece of code at a time. While synchronous code is straightforward and easy to understand, certain operations, like fetching data from an API or reading a file, may require significant time to complete. This delay can result in performance issues and unresponsive web page.

// Synchronous code
console.log("Start");

// Simulate a time-consuming operation
for (let i = 0; i < 1000000000; i++) {}
console.log("End");

Output:
Start
End

You can try running the same code on JSFiddle by visiting the following link: JSFiddle — Synchronous Code Execution.

In this case, the program executes the “Start” message, then executes the for loop (which takes a significant amount of time to complete) , and finally prints the “End” message after completing the loop.

During the loop execution, the webpage may appear unresponsive or “frozen” to the user, as it doesn’t react to clicks, scrolls, or any other user interactions, preventing the user interface from updating or the server from handling other tasks. To avoid blocking the main thread and ensure a smooth user experience, time-consuming operations should be handled asynchronously.

Asynchronous JavaScript:

Asynchronous operations allow multiple tasks to be initiated without waiting for each to finish before moving to the next one. This prevents the main execution thread from being blocked, ensuring a smoother and more responsive user experience.

Though Javascript is a synchronous language (runs on a single thread), this multithread behaviour is supported by the environment, our javascript code is running on (browser’s runtime environment or the Node runtime environment).
The detailed aspects of the event loop will be discussed in detail in our upcoming article.

Callbacks are a fundamental mechanism for handling asynchronous operations in JavaScript. A callback is simply a function that is passed as an argument to another function and is executed after the completion of an asynchronous task.

//Asynchronous code

console.log("Start");

// Simulate an asynchronous operation
setTimeout(() => {
console.log("Inside setTimeout callback");
}, 2000);

console.log("End");

Output:
Start
End
Inside setTimeout callback

You can try running the same code on JSFiddle by visiting the following link: JSFiddle — Asynchronous Code Execution.

When you use setTimeout() JavaScript initiates the operation (such as setting up a timer or making an HTTP request) and registers a callback function to be executed once the operation completes.
In JavaScript, once a callback is registered for an asynchronous operation, the execution moves on to the next line of synchronous code without waiting for its fulfilment, enhancing the responsiveness and efficiency of the program.

Enter Promises:

Here, we will deep dive into the concept of promises & understand why they have become the preferred method for managing asynchronous operations in JavaScript applications.

What are Promises?

Imagine you promise to do something for a friend. Your promise has two possible outcomes: either you fulfil it, or you don’t. In JavaScript, a promise represents a task that hasn’t finished yet but will eventually complete and return a result.

Promises provide more structured and flexible way to handle asynchronous operations compared to callbacks.

Creating a Promise

let promise = new Promise(function(resolve, reject) {
})

Promise is a built-in object in javascript and as we can see in the argument, it takes a function which is known as executor. This executor function is called immediately upon the creation of the Promise.

Executor Function:
The executor function receives two parameters: resolve and reject.
resolve is called when the asynchronous operation is successful, and reject is called when it fails. Let’s see an example.

const myPromise = new Promise((resolve, reject) => {
// Simulating an asynchronous operation
setTimeout(() => {
const success = 7>5;
if (success) {
resolve("Operation succeeded"); // If success is true, resolve with a success message
} else {
reject("Operation failed"); // If success is false, reject with an error message
}
}, 2000); // delay of 2 seconds
});
  • If success is true, we resolve the promise with a success message.
  • If success is false, we reject the promise with an error message.

Promises have three states:
— Pending: Initial state, neither fulfilled nor rejected.
Fulfilled: The operation completed successfully.
Rejected: The operation failed.

Every promise has a state and value, where state can be fulfilled, rejected, pending based on the result. Let’s see with examples.

Promise.resolve()
Promise.resolve() is a static method that returns a resolved promise. It can accept different types of values.
If you pass a promise to Promise.resolve(), it returns the same promise.

const promise = new Promise((resolve, reject) => {
// Asynchronous operation
resolve('Success');
});

const resolvedPromise = Promise.resolve(promise);
console.log(resolvedPromise)

Output:

In the provided example, the state is ‘Resolved’ because the promise has been resolved successfully with value ‘Success’.

Promise.reject()
Similar to Promise.resolve(), Promise.reject() is a static method that returns a rejected promise. It is useful for explicitly marking a promise as rejected, typically when an error condition is encountered:

const errorPromise = Promise.reject(new Error('Error Reason'));
console.log(errorPromise)

Output:

Here, errorPromise will be a promise object that has been rejected with the error object containing the message ‘Error Reason’.

Consuming a Promise

Once a promise is created, you can consume its value using the then() method. This method takes two optional callback functions: one for the fulfillment (onFulfilled), and one for the rejection (onRejected).

myPromise.then(
(data) => {
console.log("Data fetched successfully:", data);
},
(error) => {
console.error("Error fetching data:", error);
}
).catch((error) => {
console.error("Error fetching data:", error); // Log error message if Promise is rejected
}).finally(() => {
console.log("Promise settled, regardless of success or failure.");
});
  1. then(): This method is used to specify two callback functions: one to be executed when the Promise is fulfilled, and the other when it is rejected.
  2. catch(): This method provides a cleaner way of handling errors when you primarily use then() for success handling. If an error occurs and is not handled by any error handlers in then(), it will be caught by the catch() method.
  3. finally(): The finally() block here will execute regardless of whether the promise returned by myPromise() resolves or rejects.

Why Use Promises: Simplifying Asynchronous JavaScript

In this section, we’ll explore the compelling reasons to use promises in JavaScript, supplemented by illustrative examples.

1. Callback Hell: The Problem with Nested Callbacks
Callback hell, also known as the pyramid of doom, occurs when asynchronous operations are nested within multiple layers of callbacks. This pattern makes code difficult to read, understand, and debug.

// Example of callback hell
asyncFunction1(function(result1) {
asyncFunction2(result1, function(result2) {
asyncFunction3(result2, function(result3) {
// Nested callback logic
});
});
});

Let’s see how Promise chaining resolves this issue

2. Promise Chaining: Simplifying Asynchronous Code
Promises enable a more elegant and sequential approach to handling asynchronous operations through promise chaining. With promise chaining, you can perform a series of asynchronous tasks in a clear and concise manner.

// Example of promise chaining
asyncFunction1()
.then(result1 => asyncFunction2(result1))
.then(result2 => asyncFunction3(result2))
.then(finalResult => {
// Handle final result
})
.catch(error => {
// Handle errors
});

3. Improved Readability and Maintainability
Promises improve the readability and maintainability of asynchronous code by allowing developers to express complex asynchronous workflows in a linear and understandable fashion.

// Example: Fetching data using promises
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
// Process data
})
.catch(error => {
// Handle errors
});

The catch() method is used to handle errors that occur in any part of the chain.

4. Error Handling Made Easy
Promises provide built-in error handling capabilities through the .catch() method, making it straightforward to handle errors for asynchronous operations.

// Example: Error handling with promises
asyncFunction()
.then(result => {
// Process result
})
.catch(error => {
// Handle errors
});

5. Parellel Execution
The Promise.all() method allows you to execute multiple asynchronous operations in parallel and wait for all of them to complete. This is useful when you need to fetch data from multiple sources simultaneously.

// Example: Fetching data from multiple APIs concurrently
const promises = [fetchData1(), fetchData2(), fetchData3()];

Promise.all(promises)
.then(results => console.log('All data fetched:', results))
.catch(error => console.error('Error:', error));

Promise.all() is indeed one of the most commonly used methods for handling multiple asynchronous operations concurrently in JavaScript.
1. Completion: The method returns a single promise that resolves when all the promises in the input array have resolved successfully, or it rejects as soon as one of the promises rejects.
2. Fulfillment or Rejection: If all promises fulfill successfully, Promise.all() resolves with an array of their respective results in the same order as the input array. However, if any one of the promises rejects (fails), the entire Promise.all() call immediately rejects with the reason (error) of the first rejected promise.

6. Race Conditions
Promise.race() is used when you want to respond as soon as the first asynchronous operation completes. This is useful in scenarios where you’re dealing with multiple APIs and want to use the response from the fastest one.

// Example: Using Promise.race() to handle race conditions
const promises = [fetchData1(), fetchData2(), fetchData3()];
Promise.race(promises)
.then(result => console.log('First response received:', result))
.catch(error => console.error('Error:', error));

Conclusion

As we have explored, we can see how promises have transformed asynchronous programming in JavaScript by providing a structured and efficient way to handle these async operations.

In our next article, we’ll explore how JavaScript executes promises in a queue with the event loop, providing insights into runtime behaviour. We’ll also discuss how async/await offers a more intuitive alternative, enhancing code readability and simplifying asynchronous programming.

Your questions are invaluable to us, so don’t hesitate to share them in the comments below.
Join us as we delve deeper into mastering asynchronous JavaScript. Thank you!

--

--