Understanding Promises, Async, and Await and Callback Hell in Node.js: A Comprehensive Guide
NODEJS
6/8/20245 min read
Introduction to Promises in Node.js
Node.js is a powerful framework for building server-side applications. One of the essential aspects of effective Node.js programming is managing asynchronous operations. Traditionally, this was done using callbacks, but this approach often led to callback hell—a situation where callbacks are nested within callbacks, creating difficult-to-read and maintain code. Promises offer a better way to handle asynchronous operations.
What are Promises?
Promises are objects representing the eventual completion or failure of an asynchronous operation. They provide a way to attach callbacks for handling success or failure, making the code more readable and easier to manage. A Promise object can be in one of three states: pending, fulfilled, or rejected.
Here’s a basic example of a promise:
const myPromise = new Promise((resolve, reject) => {
// Asynchronous operation
setTimeout(() => {
const authSuccess= true;
if (authSuccess) {
resolve('Authentication issuccessful!'); }
else {
reject('Authentication failed.'); } }, 1000);});
Let's see how we can call this promise from our code
const func1 = () => {
return myPromise;
}
func1 .then((message) => {
console.log(message);}).catch((error) => {
console.error(error);});
Promise Chaining
Let's understand one more aspect of promises which is called promise chaining
A Promise chain in Node.js refers to the technique of chaining multiple asynchronous operations together using Promises. This allows you to perform a series of operations where each subsequent operation depends on the result of the previous one. By chaining Promises, you can avoid deeply nested callback structures (callback hell) and make your asynchronous code more readable and maintainable.
Basic Structure of a Promise Chain
Each then() method in a Promise chain returns a new Promise, allowing further chaining. If a then() method returns a value, the next then() in the chain will receive that value as its argument. If a then() method returns a Promise, the next then() in the chain will wait for that Promise to resolve before proceeding.
Example of a simple asynchronous operation returning a promise
function asyncOperation(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
// Chaining multiple promises together
asyncOperation('First Operation', 1000)
.then(result => {
console.log(result); // Output after 1 second: Success: First Operation
return asyncOperation('Second Operation', 2000);
})
.then(result => {
console.log(result); // Output after 3 seconds: Success: Second Operation
return asyncOperation('Third Operation', 1000);
})
.then(result => {
console.log(result); // Output after 4 seconds: Success: Third Operation
})
.catch(error => {
console.error(error);
});
Explanation
First Operation: The asyncOperation function is called with 'First Operation' and a delay of 1 second. This returns a Promise.
Second Operation: When the first Promise resolves, its result is passed to the first then(). Inside this then(), asyncOperation is called again with 'Second Operation' and a delay of 2 seconds, returning a new Promise.
Third Operation: When the second Promise resolves, its result is passed to the next then(). asyncOperation is called again with 'Third Operation' and a delay of 1 second.
Error Handling: If any Promise in the chain is rejected, the catch() method will handle the error.
Benefits of Promise Chains
Improved Readability: Promises and chaining make asynchronous code more readable than nested callbacks.
Sequential Execution: Promises allow sequential execution of asynchronous operations, where each operation waits for the previous one to complete.
Error Handling: Errors in any part of the chain can be caught in a single catch() block, making error handling more consistent and centralized.
Using Async and Await
Though promises improve the readability of asynchronous code, async and await keywords introduced in ES2017 make it even more straightforward. The async keyword is used to declare an asynchronous function, while the await keyword is used to wait for a promise to resolve or reject within an async function. This way, the code looks synchronous, making it easier to understand and maintain.
Lets use the above example to integrate async and await and simplify the code
function async asyncOperation(value, delay) {
setTimeout(() => {
if (value) {
return `Success: ${value}`;
} else {
throw Error('No value provided');
}
}, delay);
}
// Chaining multiple promises together
try {
const result1 = await asyncOperation('First Operation', 1000)
console.log(result1);
const result2 = await asyncOperation('Second Operation', 1000)
console.log(result2);
const result3 = await asyncOperation('Third Operation', 1000)
console.log(result3);
}catch(error){
console.error(error);
};
After using async await using the same implementation, the code looks much more readable and easier to understand. Points to note while using async await
Any method attached with async keyword will always return a promise by itself. We as developers need to only return the actual response and async will automatically wrap it up in a promise object, thereby reducing the amount of code needed to create new Promise objects
.then feature has been replaced with await now. await keyword will wait for the method call to complete and resolve the promise returned from the method and provides the actual data
Callback Hell
Let's understand how promises/async-await provides advantage over callback hells
Callback Hell occurs when multiple nested callbacks make the code difficult to read and maintain. Promises and async/await help to flatten the structure and make the code more manageable.
Lets see an example
function asyncOperation1(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success async1: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
function asyncOperation2(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success async2: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
function asyncOperation3(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success async3: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
asyncFunction1(('First op', 1000) => {
asyncFunction2(('Second op', 1000) => {
asyncFunction3(('Third op', 1000) => {
console.log("All Async Functions Completed");
});
});
});
This is called callback hell, where we are creating a pyramid of dependent asynchronous functions, where one function is invoked based on the result of the previous one, and it will increase more and more based on use cases
Lets try to see how we can simplify the above code using promises and async/await
Using Promises
function asyncOperation1(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success async1: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
function asyncOperation2(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success async2: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
function asyncOperation3(value, delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (value) {
resolve(`Success async3: ${value}`);
} else {
reject('Error: No value provided');
}
}, delay);
});
}
asyncFunction1('First op', 1000)
.then(() => asyncFunction2('Second op', 1000))
.then(() => asyncFunction3('Third op', 1000))
.then(() => {
console.log("All Async Functions Completed");
})
.catch((error) => {
console.error("An error occurred:", error);
});
Using async/await
function async asyncOperation1(value, delay) {
setTimeout(() => {
if (value) {
return `Success async1: ${value}`;
} else {
throw Error('No value provided');
}
}, delay);
}
function async asyncOperation2(value, delay) {
setTimeout(() => {
if (value) {
return `Success async2: ${value}`;
} else {
throw Error('No value provided');
}
}, delay);
}
function async asyncOperation3(value, delay) {
setTimeout(() => {
if (value) {
return `Success async3: ${value}`;
} else {
throw Error('No value provided');
}
}, delay);
}
try {
const result1 = await asyncOperation1('First Operation', 1000)
console.log(result1);
const result2 = await asyncOperation2('Second Operation', 1000)
console.log(result2);
const result3 = await asyncOperation3('Third Operation', 1000)
console.log(result3);
}catch(error){
console.error(error);
};
We can see promises drastically improves the code readability and makes it much easier to understand the workflow also
Advantages of Promises and Async/Await
Readability: Code is easier to read and understand.
Error Handling: Errors are caught in a more straightforward manner using .catch() for Promises and try...catch for async/await.
Avoidance of Nested Callbacks: Promises and async/await help to flatten the structure, avoiding deeply nested callbacks.
Better Flow Control: Promises chain operations more cleanly, and async/await makes the code look synchronous, simplifying the flow control.
In summary, Promises and async/await provide a more powerful and flexible way to handle asynchronous operations in Node.js, significantly improving code readability and maintainability compared to traditional callbacks.