These concepts are must-know kind of thing if you work with JavaScript/TypeScript or its libraries and frameworks and are often asked in technical interviews.
We are going to be discussing:
- Callback
- Callback Hell
- Promise (overview)
- Promise Chaining
- Async/Await
Callback
Before understanding about callback hell, we first need to know what the hell is callback in JavaScript?
JavaScript deals functions as first class citizens i.e., function can be passed as an argument to another function as well as a function can be returned from another function.
A callback in JavaScript is a function that is passed into another function as an argument.
Callback has nothing to do with the asynchronous behavior of JavaScript. A callback is simply a function that is passed as an argument to another function and is intended to be executed at a later time or after a specific event occurs. This event doesn't have to be an asynchronous operation; it could be a user action, a specific condition being met, or any other event you want to handle with a separate function.
Let's go on and see the examples of callback in context of synchronous and asynchronous behaviour.
Callback Synchronous Example
// A function that takes a callback as an argument and invokes it
function performOperation(callback) {
console.log("Performing the operation...");
callback();
}
// Define a callback function
function callbackFunction() {
console.log("Callback has been executed!");
}
// Calling the main function with the callback
performOperation(callbackFunction);
You will se an output like this:
Performing the operation...
Callback has been executed!
Callback Asynchronous Example
// A function that takes a callback as an argument and invokes it
function doSomethingAsync(callback) {
setTimeout(function() {
console.log("Async operation done!");
callback();
}, 1000);
}
// Define a callback function
function callbackFunction() {
console.log("Callback has been executed!");
}
// Calling the main function with the callback
doSomethingAsync(callbackFunction);
You will see output like this:
Async operation done!
Callback has been executed!
Callback Hell
Now we somewhat understood what the hell is callback, let's go on exploring Callback Hell.
Callback hell is introduced when we have nested functions. This is a requirement in almost all real world applications.
As more nested callbacks are added, the code becomes harder to read, maintain, and reason about. This can lead to bugs and difficulties in debugging.
See for yourself:
Declaring Functions:
function asyncFunction1(callback) {
setTimeout(() => {
console.log("Async Function 1 Done");
callback();
}, 1000);
}
function asyncFunction2(callback) {
setTimeout(() => {
console.log("Async Function 2 Done");
callback();
}, 1000);
}
function asyncFunction3(callback) {
setTimeout(() => {
console.log("Async Function 3 Done");
callback();
}, 1000);
}
Calling the code:
asyncFunction1(() => {
asyncFunction2(() => {
asyncFunction3(() => {
console.log("All Async Functions Completed");
});
});
});
As you can see pyramid of doom being formed
If there are more operations to be performed then the pyramid's height will keep increasing and so would be the impact of doom.
Real world example
For example, an authenticated user performed some action from the browser which hits an API that involves getting the data from database and sending notification E-mail back to the user, this could be performed in a series of steps one after another.
- Receives request from the user.
- Authenticate the user.
- Check authorization level of user to perform an action.
- Perform the action.
- Send Notification E-mail.
- Send response back to the user browser.
So, it could be something like...
// Step 1: Receives request from the user
app.post('/performAction', (req, res) => {
// Step 2: Authenticate the user
authenticateUser(req.body.username, req.body.password, (authError, isAuthenticated) => {
if (authError) {
res.status(401).send('Authentication failed');
} else if (!isAuthenticated) {
res.status(401).send('User not authenticated');
} else {
// Step 3: Check authorization level of user
checkAuthorization(req.body.username, (authLevelError, isAuthorized) => {
if (authLevelError) {
res.status(403).send('Authorization check failed');
} else if (!isAuthorized) {
res.status(403).send('User not authorized');
} else {
// Step 4: Perform the action
performAction(req.body.actionData, (actionError, result) => {
if (actionError) {
res.status(500).send('Action failed');
} else {
// Step 5: Send Notification E-mail
sendNotificationEmail(req.body.username, (emailError) => {
if (emailError) {
console.error('Failed to send notification email');
}
// Step 6: Send response back to the user browser
res.status(200).send('Action performed successfully');
});
}
});
}
});
}
});
});
You can see that the pyramid of doom has doomed us in this example. What happens if there are sockets involved in which a notification is sent in real-time? The data to be sent via. socket and stuff like that? All within one singular operation. Then the doom will keep on increasing and it will be a living hell for developers to maintain the code, change business logic and God forbid, to debug it if any error occurs.
Promises to the rescue
ECMAScript 2015, also known as ES6, introduced the JavaScript Promise object.
Promise in JavaScript is a detailed concept to be studied but for short terms, you can think of it as a really good way to deal with asynchronous behavior of JavaScript.
It has three states:
- Pending
- Resolved
- Rejected
When an action is being performed then the state is in pending, if the action is successful then the result is resolved, otherwise the result is rejected.
JavaScript promises us that it will return us something after a certain duration of time whenever the asynchronous task has been completed.
You can see a short example here:
function asyncFunction() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Async Function Done");
resolve();
}, 1000);
});
}
asyncFunction().then(() => {
console.log("Async operation performed.");
});
The result would be like this:
Async Function Done
Async operation performed.
Promise Chaining
Okay, that's great. Promises introduced.. But how does it deal with our problem of callback hell?
The answer is with Promise chaining.
Let's modify our simple example from before:
Function declaration
function asyncFunction1() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Async Function 1 Done");
resolve();
}, 1000);
});
}
function asyncFunction2() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Async Function 2 Done");
resolve();
}, 1000);
});
}
function asyncFunction3() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Async Function 3 Done");
resolve();
}, 1000);
});
}
Our functions are now returning promises.
Let's see how we execute them now:
asyncFunction1()
.then(() => asyncFunction2())
.then(() => asyncFunction3())
.then(() => {
console.log("All Async Functions Completed");
})
.catch((error) => {
console.error("An error occurred:", error);
});
We will see the result like this:
Async Function 1 Done
Async Function 2 Done
Async Function 3 Done
All Async Functions Completed
This increased readability, but for inclusion of async/await with the work of promises has given much better working environment to the codebase.
For the real world pseudo example we discussed earlier, that can also be re-written like this for promise chaining:
// Step 1: Receives request from the user
app.post('/performAction', (req, res) => {
authenticateUserPromise(req.body.username, req.body.password)
.then((isAuthenticated) => {
if (!isAuthenticated) {
res.status(401).send('Authentication failed');
return Promise.reject();
}
// Step 3: Check authorization level of user
return checkAuthorizationPromise(req.body.username);
})
.then((isAuthorized) => {
if (!isAuthorized) {
res.status(403).send('User not authorized');
return Promise.reject();
}
// Step 4: Perform the action
return performActionPromise(req.body.actionData);
})
.then(() => {
// Step 5: Send Notification E-mail
return sendNotificationEmailPromise(req.body.username);
})
.then(() => {
// Step 6: Send response back to the user browser
res.status(200).send('Action performed successfully');
})
.catch((error) => {
console.error('An error occurred:', error);
res.status(500).send('An error occurred');
});
});
Async-Await
ECMAScript 2017 introduced the JavaScript keywords async and await.
The purpose was to further simplify the use of promises in JavaScript world.
async keyword is written before the function and await is written before the function which executes asynchronous operation.
We can execute the same code like this:
async function runAsyncFunctions() {
try {
await asyncFunction1();
await asyncFunction2();
await asyncFunction3();
console.log("All Async Functions Completed");
} catch (error) {
console.error("An error occurred:", error);
}
}
runAsyncFunctions();
Or much shorter way with IIFE (Immediately Invoked Function Execution)
(async ()=>{
await asyncFunction1();
await asyncFunction2();
await asyncFunction3();
console.log("All Async Functions Completed");
})()
The real world example can be used with promises and async/await like this:
// Step 1: Receives request from the user
app.post('/performAction', async (req, res) => {
try {
// Step 2: Authenticate the user
const isAuthenticated = await authenticateUserPromise(req.body.username, req.body.password);
if (!isAuthenticated) {
res.status(401).send('Authentication failed');
return;
}
// Step 3: Check authorization level of user
const isAuthorized = await checkAuthorizationPromise(req.body.username);
if (!isAuthorized) {
res.status(403).send('User not authorized');
return;
}
// Step 4: Perform the action
const result = await performActionPromise(req.body.actionData);
// Step 5: Send Notification E-mail
await sendNotificationEmailPromise(req.body.username);
// Step 6: Send response back to the user browser
res.status(200).send('Action performed successfully');
} catch (error) {
console.error('An error occurred:', error);
res.status(500).send('An error occurred');
}
});
Conclusion
- Callback functions can be used to perform asynchronous operation.
- Callback hell is introduced with nested operations.
- Promise chaining solves callback hell.
- Async/Await further improves the solution.
I hope that helps. Let me know if there was any confusion anywhere in the article or the mistake that you think should be rectified.
Follow me here for more stuff like this:
LinkedIn: https://www.linkedin.com/in/shameeluddin/
Github: https://github.com/Shameel123