JavaScript is characterized as an asynchronous (non-blocking) and single-threaded language, indicating that it can execute only one process at a time.
In the realm of programming languages, callback hell typically denotes an inefficient method of coding that involves asynchronous function calls. This phenomenon is also commonly referred to as the Pyramid of Doom.
In JavaScript, the term "callback hell" describes a scenario where there is an overabundance of nested callback functions being executed. This leads to reduced readability and complicates code maintenance. The occurrence of callback hell is most common when managing asynchronous operations, such as executing multiple API requests or dealing with events that have intricate dependencies.
To gain a clearer insight into the concept of callback hell within JavaScript, it is essential to first grasp the functions of callbacks and the mechanics of event loops in JavaScript.
Callbacks in JavaScript
In JavaScript, every entity is treated as an object, encompassing strings, arrays, and functions. This foundational principle leads to the concept of callbacks, which enables the passing of a function as an argument to another function. The callback function is executed first, followed by the execution of the parent function thereafter.
Callback functions operate asynchronously, enabling the program to keep executing without pausing for the completion of the asynchronous operations. When various asynchronous tasks are interconnected, with each task relying on the preceding one, the organization of the code can become quite intricate.
Let’s explore the significance and application of callbacks. For instance, consider a scenario where we have a function that accepts three arguments: one string and two numerical values. Our goal is to produce output that is influenced by the string content, depending on various conditions.
Consider the below example:
function expectedResult(action, x, y){
if(action === "add"){
return x+y
}else if(action === "subtract"){
return x-y
}
}
console.log(expectedResult("add",20,10))
console.log(expectedResult("subtract",30,10))
Output:
30
20
The code provided will function correctly; however, it is essential to incorporate additional tasks to enhance the scalability of the code. As the number of conditional statements continues to rise, it will result in a cluttered codebase that requires optimization for improved readability and maintainability.
Therefore, we can enhance the code by restructuring it in the following manner:
function add(x,y){
return x+y
}
function subtract(x,y){
return x-y
}
function expectedResult(callBack, x, y){
return callBack(x,y)
}
console.log(expectedResult(add, 20, 10))
console.log(expectedResult(subtract, 30, 10))
Output:
30
20
Nonetheless, the output will remain unchanged. In the previous example, we established its own function body and provided the function as a callback to the expectedResult function. Therefore, if we aim to enhance the capabilities of the expected results, we can develop another function body that performs a different operation and utilize it as the callback function. This approach will facilitate comprehension and enhance the readability of the code.
There are various additional instances of callbacks that can be utilized within supported JavaScript features. Some frequently encountered examples include event listeners, as well as array methods like map, reduce, filter, and others.
To gain a clearer insight, it's essential to comprehend the concepts of pass-by-value and pass-by-reference in JavaScript.
JavaScript categorizes data types into two main groups: primitive and non-primitive. The primitive data types include undefined, null, string, and boolean. These types are considered immutable, meaning they cannot be altered. In contrast, non-primitive data types comprise arrays, functions, and objects, which are mutable and can be modified.
Passing by reference involves providing the reference address of an entity, which allows a function to accept it as an argument. Consequently, if the value is modified within that function, the original value, accessible outside the function, will also be affected.
In contrast, the concept of pass-by-value retains the original value, which remains accessible beyond the function's scope. Rather, it duplicates the value into two distinct memory locations. JavaScript recognizes all objects by their reference.
In JavaScript, the addEventListener method is utilized to monitor events like click, mouseover, and mouseout. The second parameter of this method is a function that gets executed when the specified event occurs. This function exemplifies the concept of passing by reference and is provided without parentheses.
Take a look at the following example; here, we have provided a greet function as an argument to the addEventListener method, designating it as the callback function. This function will be executed when the click event occurs:
Test.html:
<!DOCTYPE html>
<html>
<head>
<title>
Javascript Callback Example
</title>
</head>
<body>
<h3>Javascript Callback</h3>
<button id='btn'>Click Here to Console</button>
<script>
const button = document.getElementById('btn');
const greet=()=>{
console.log("Hello, How are you?")
}
button.addEventListener('click', greet)
</script>
</body>
</html>
Output:
In the preceding example, we have supplied a greet function as an argument to the addEventListener method, designating it as the callback function. This function will be executed when the click event occurs.
In a similar vein, the filter method serves as another illustration of a callback function. When applying a filter to traverse an array, it requires an additional callback function as a parameter to handle the array's elements. Take a look at the example below; in this case, we utilize the greater function to display numbers that exceed 5 within the array. The isGreater function is employed as the callback function within the filter method.
const arr = [3,10,6,7]
const isGreater = num => num > 5
console.log(arr.filter(isGreater))
Output:
[ 10, 6, 7 ]
The preceding example demonstrates that the greater function serves as a callback function within the filter method.
To gain a deeper insight into Callbacks and Event loops in JavaScript, it is essential to examine the concepts of synchronous and asynchronous JavaScript:
Synchronous JavaScript
Let’s explore the characteristics of a synchronous programming language. Synchronous programming is characterized by the following features:
Blocking Execution: The synchronous programming language implements the technique of blocking execution, which indicates that it halts the progression of subsequent statements until the current statements have been fully executed. This approach ensures a predictable and deterministic order of execution for the statements involved.
Sequential Flow: In synchronous programming, the execution follows a sequential order, indicating that each statement is processed one after another. The program in the language pauses until the current statement finishes execution before proceeding to the subsequent statement.
Simplicity: Frequently, Synchronous programming is viewed as straightforward to comprehend due to its predictable execution sequence. Typically, the flow of execution is linear and easy to forecast. Smaller applications are well-suited for development in these languages since they effectively manage the crucial order of operations.
Direct Error Management: In a synchronous programming language, managing errors is quite straightforward. When an error occurs during the execution of a statement, it will raise an error, allowing the program to intercept it.
In summary, synchronous programming is characterized by two fundamental attributes: it allows for the execution of one task at a time, and subsequent tasks are only processed after the completion of the current task. As a result, it adheres to a linear code execution flow.
This conduct of the programming language during the execution of a statement generates a condition known as block code, where each task must wait for the completion of the preceding task before it can commence.
However, when discussions arise regarding JavaScript, there has consistently been ambiguity surrounding whether it operates in a synchronous or asynchronous manner.
In the previously mentioned examples, the use of a function as a callback within the filter function resulted in synchronous execution. This is why it is referred to as synchronous execution. The filter function must pause and await the completion of the greater function before it can continue its own processing.
Therefore, the callback function is often referred to as a blocking callback, since it halts the execution of the parent function from which it was called.
At its core, JavaScript is recognized as a single-threaded, synchronous, and blocking language. However, by employing various techniques, we can enable it to operate asynchronously depending on the specific circumstances.
Now, let us explore the concept of asynchronous JavaScript.
Asynchronous JavaScript
The asynchronous programming paradigm is aimed at improving the performance of applications. In such cases, callbacks can be employed effectively. We can examine the asynchronous characteristics of JavaScript through the following example:
function greet(){
console.log("greet after 1 second")
}
setTimeout(greet, 1000)
In the preceding example, the setTimeout function accepts two parameters: a callback function and a duration specified in milliseconds. The callback function is executed following the elapsed time (in this instance, 1 second). Essentially, this means that the function will pause for 1 second before it proceeds with its execution. Now, let’s examine the code provided below:
function greet(){
console.log("greet after 1 second")
}
setTimeout(greet, 1000)
console.log("first")
console.log("Second")
Output:
first
Second
greet after 1 second
According to the code provided, the log statements following the setTimeout function will be executed immediately, while the timer runs in the background. Consequently, this leads to a one-second delay, after which the greeting message is displayed following the one-second interval.
In JavaScript, the setTimeout function operates asynchronously. When we invoke setTimeout, it schedules a callback function (in this instance, greet) to run following a designated delay. Nonetheless, it does not impede the execution of the code that follows.
In the example provided, the log messages represent synchronous operations that run instantly. They do not rely on the setTimeout function. As a result, these messages are executed and printed to the console right away, without any pause for the delay defined in setTimeout.
At the same time, the event loop in JavaScript manages asynchronous operations. In this scenario, it pauses until the defined delay of 1 second has transpired. Once that duration has concluded, it retrieves the callback function (greet) and proceeds to execute it.
Consequently, the subsequent code following the setTimeout function was executing concurrently in the background. This characteristic enables JavaScript to handle additional tasks while awaiting the completion of the asynchronous operation.
It is essential to comprehend the call stack and the callback queue in order to effectively manage asynchronous events in JavaScript.
Consider the below image:
As illustrated in the image above, a standard JavaScript engine is comprised of a heap memory and a call stack. The call stack processes all code immediately upon being added to the stack, without any delays.
Heap memory is tasked with the allocation of memory for objects and functions during runtime, as they become necessary.
At present, our web browser engines incorporate a variety of web APIs, including but not limited to DOM, setTimeout, console, fetch, and others. The engine interacts with these APIs through the global window object. Moving forward, specific event loops serve as gatekeepers, selecting function requests from the callback queue and transferring them to the stack. Functions like setTimeout necessitate a designated waiting period before they can be executed.
Let us revisit our illustration involving the setTimeout function. When this function is encountered in the code, a timer is initiated and registered within the callback queue. Subsequently, the remaining code is placed onto the call stack and executed. Once the timer reaches its designated limit, it expires, causing the callback function—previously defined and associated with the timeout—to be added to the callback queue. As a result, this function will be executed following the specified duration.
Callback Hell Scenarios
At this point, we have explored callbacks, as well as synchronous and asynchronous processes, along with other pertinent subjects related to the concept of callback hell. Let us delve into what callback hell signifies in the context of JavaScript.
The scenario in which numerous callbacks are layered upon one another is referred to as callback hell, due to its resemblance to a pyramid shape in the code. This phenomenon is also commonly known as the "pyramid of doom."
Callback hell complicates the comprehension and maintenance of code. This phenomenon is frequently encountered when developing in Node.js. To illustrate this, let’s examine the following example:
getArticlesData(20, (articles) => {
console.log("article lists", articles);
getUserData(article.username, (name) => {
console.log(name);
getAddress(name, (item) => {
console.log(item);
//This goes on and on...
}
})
In the preceding example, the function getUserData requires a username that relies on the list of articles or must be retrieved from the getArticles response contained within the article. Similarly, the getAddress function has a related dependency, as it is contingent upon the response from getUserData. This scenario is commonly referred to as callback hell.
The underlying mechanism of callback hell can be illustrated with the following example:
Let’s clarify that we are required to carry out task A. In order to execute this task, it is essential to obtain certain data from task B. In a similar fashion, we have various tasks that rely on one another and operate asynchronously. This scenario results in the formation of a sequence of callback functions.
Let’s delve into the concept of Promises in JavaScript and explore how they facilitate asynchronous operations, enabling us to circumvent the need for nested callbacks.
JavaScript promises
In JavaScript, promises were introduced with ES6. They serve as an object that encapsulates a value that may be available now, or in the future, or never. Thanks to their asynchronous characteristics, promises provide an alternative approach to circumvent the need for callbacks when dealing with asynchronous tasks. Presently, many Web APIs, such as fetch, utilize promises, offering a streamlined method for retrieving data from a server. This not only enhances code clarity but also helps prevent the complications associated with nested callbacks.
In everyday life, promises signify a bond of trust among individuals, providing a guarantee that a specific event will indeed occur. Within the realm of JavaScript, a Promise is defined as an object that guarantees to deliver a single value at some point in the future (when it is needed). In JavaScript, Promises are utilized to facilitate the handling and management of asynchronous operations.
The Promise object is designed to signify and encapsulate the outcome—whether successful or unsuccessful—of asynchronous activities along with their results. It acts as an intermediary for a value, even when the specific output is not yet available. This feature proves advantageous for asynchronous functions as it allows them to eventually yield either a success value or a failure explanation. Consequently, methods that operate asynchronously can return results in a manner similar to that of synchronous methods.
Generally, the promises have the following three states:
- Fulfilled: The fulfilled state is when an applied action has been resolved or completed successfully.
- Pending: the Pending state is when the request is in process, and the applied action has neither been resolved nor rejected and is still in its initial state.
- Rejected: The rejected state is when the applied action has been rejected, causing the desired operation to fail. The cause of rejection can be anything, including the server being down.
The syntax for the promises:
let newPromise = new Promise(function(resolve, reject) {
// asynchronous call is made
//Resolve or reject the data
});
Below is an example of writing the promises:
This is an example of writing a promise.
function getArticleData(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Fetching data....");
resolve({ id: id, name: "derik" });
}, 5000);
});
}
getArticleData("10").then(res=> console.log(res))
In the example provided, we can observe how promises can be effectively utilized to send a request to the server. It is evident that the readability of the code has improved compared to using callbacks. Promises offer functions such as .then and .catch, enabling us to manage the outcome of the operation, whether it succeeds or fails. We have the ability to define scenarios for various states of the promises.
Async/Await in JavaScript
An alternative approach to prevent the necessity of nested callbacks is through the use of Async/Await. This feature enables us to work with promises in a more streamlined manner. By utilizing Async/Await, we can eliminate the need for chaining methods such as .then or .catch, which are also reliant on callback functions.
The Async/Await syntax can be effectively utilized alongside Promises to enhance the performance of an application. It internally resolves the promises and delivers the resulting value. Furthermore, it offers greater readability compared to the traditional .then or .catch methods.
Utilizing Async/Await alongside traditional callback functions is not feasible. To implement it, one must designate a function as asynchronous by placing the async keyword prior to the function declaration. Nevertheless, it is important to note that it also operates on the principle of chaining under the hood.
Below is an example of the Async/Await:
async function displayData() {
try {
const articleData = await getArticle(10);
const placeData = await getPlaces(article.name);
const cityData = await getCity(place)
console.log(city);
} catch (err) {
console.log("Error: ", err.message);
}
}
displayData();
In order to utilize Async/Await, the function needs to be declared with the async keyword, while the await keyword should be positioned within that function. The async function will pause its execution until the associated Promise is either resolved or rejected. Once the Promise has been addressed, the execution will continue. Upon resolution, the result of the await expression will be assigned to the variable that holds it.
Summary:
In summary, we can eliminate the need for nested callbacks by utilizing promises along with async/await syntax. Besides these methods, other strategies can be beneficial, including documenting the code with comments and partitioning the code into distinct components. However, currently, many developers favor the implementation of async/await for its clarity and effectiveness.
Conclusion:
In JavaScript, the term "callback hell" describes a scenario where there are numerous nested callback functions being executed, leading to decreased readability and maintainability of the code. This problematic situation often arises when managing asynchronous operations, such as executing multiple API requests or processing events that have intricate dependencies.
To gain a clearer insight into the phenomenon known as callback hell in JavaScript.
In JavaScript, all entities are treated as objects, including strings, arrays, and functions. Consequently, the concept of callbacks enables us to provide a function as an argument to another function. The callback function is executed first, followed by the execution of the parent function thereafter.
Callback functions are executed in an asynchronous manner, enabling the code to proceed without pausing for the completion of the asynchronous operation.