Callbacks in JavaScript

We all value our time. Don't we? When we are tasked with doing something, we try to think if we can do something in parallel to save time. Even peeling vegetables while watching television is an example of that. But now imagine that you are at a restaurant. You go in there, order some food and come back to the seat to do something else. But what if you were forced to wait until your order was complete? That would be the kind of situation we would have to deal with if it weren't for callbacks.
Now, surely callbacks do not cause order speedups in your favourite restaurant, but it surely does in applications. Today, we will not only dive into what callbacks are, but also why they are an important part of asynchronous programming in JavaScript.
What are Callbacks?
To understand callbacks, one must first understand functions. A function is a set of instructions that is well defined about what it does and is reserved to be used later. These functions can have an input and an output. An input to a function is called its argument. Arguments are usually data that we can operate on in the function.
In JavaScript, we have the ability to store functions as data in a variable. This is because a function, as its core, is just another object that can be called to execute some instructions. For example:
const myFunction = (a,b)=>{
return a+b;
}
Now, what is a callback? A callback is a function that is passed to another function as an argument. At first, this seems pretty counterintuitive and needless, but it is actually something that a huge chunk of JavaScript code in the world relies on to run. This methodology is the foundation of asynchronous programming in JavaScript.
Let us see a very basic example of a callback function to get a general feel of it:
function sumOfRandomNumbers(getSumCallback){
const a = Math.random()*100;
const b = Math.random()*200;
return getSumCallback(a,b);
}
function add(x,y){
return x+y;
}
console.log('Here is the sum of 2 completely random numbers');
console.log(sumOfRandomNumbers(add));
In the above example, we passed the function add as a parameter to the function sumOfRandomNumbers. This function can now use the add function inside our code.
However, this example doesn't represent the philosophy of callbacks. The main idea behind callbacks is to create a way to make a segment of code wait before some other task has completed. For example:
function processUser(name,callback){
let username = name + `${Math.floor(Math.random()*10000)}`;
callback(name,username);
}
function greetUser(name,username){
console.log(`Hello ${name}. Your name has been processed`);
console.log(`Your new username is : ${username}`);
}
processUser('Parikar',greetUser);
Now the idea behind this code is:
Process the user first and do any tasks that need to be completed first.
When your task is done, pass on the relevant data to the function I am giving so that it can do its task.
The idea is very simple - create a clear execution flow while the function itself never knows what logic is happening. So the callback can be anything.
For example, in the above code, we can also do:
function processUser(name,callback){
let username = name + `${Math.floor(Math.random()*10000)}`;
callback(name,username);
}
function setUserPassword(name,username){
console.log(`Hello ${name}. Your name has been processed`);
console.log('Please provide your password');
//get password somehow (password = usergivenPassword
console.log('Your password is set successfully');
}
processUser('Parikar',setUserPassword);
Now, instead of greeting the user, we can use the same logic to save the user's password.
Callbacks and Asynchronous Programming
We know that the execution of pure JavaScript code is sequential and synchronous. But real JavaScript programs run in a JavaScript environment where environment-specific APIs are called. Many of these APIs take some time to execute. However, as we know, with such time-consuming calls, JavaScript does not wait and simply moves on to continue execution. However, that would be a disaster. Because then, if we want some logic to run only after an asynchronous task has completed, we cannot do it. For example:
const data = fetch('https://example.com');
console.log(data);
Here fetch is an asynchronous function. JavaScript knows it takes time. So instead of waiting for the data to arrive, console.log() would get executed, which would not have the data reliably.
So instead of relying on luck, we would need a concrete mechanism. This mechanism came earlier in the form of callbacks.
The Reliability Provided by Callbacks
Since we need to reliably execute a set of instructions when an asynchronous task has been completed, callbacks come in very handy here. For example:
setTimeout(()=>{
console.log('The timer has finished. Detonate the bom....');
},3000);
console.log('Finished Preparations');
As we can see in the above code, we have a console.log statement. According to the asynchronous nature-based execution, since setTimeout will take 3 seconds to execute, we will see the message "Finished Preparations" first. Now, after the timer has expired, setTimeout itself will call our callback function. By this method, we ensure that only after the given asynchronous task is completed is our function called.
The New Problem
Despite being a genius solution, it had some problems. And that is usually, we don't just want one task to execute after the other. We want tasks to get executed in a sequence.
For example, when we want to cook rice, we want one task to be performed only after the previous one has completed. Trying to write out this program would look something like this:
setTimeout((rawRice)=>{
console.log('Washing Rice');
const washedRice = wash(rawRice);
setTimeout((washedRice)=>{
console.log('Boiling Rice');
const boiledRice = boil(washedRice);
},3000);
},3000);
As you can see, I have just done 2 simple tasks:
Wash Rice
Boil Rice
And even then, it took such a complex code. Maybe it is my fault. Let us try to make it clearer.
function washRice(rawRice,nextTask){
console.log('Washing Rice');
setTimeout(()=>{
const washedRice = wash(rawRice);
nextTask(washedRice);
},3000);
}
function drainRice(washedRice,nextTask){
console.log('Draining Rice');
setTimeout(()=>{
const drainedRice = drain(washedRice);
nextTask(drainedRice);
},3000);
}
function boilRice(drainedRice){
console.log('Boiling Rice');
setTimeout(()=>{
const boiledRice = boil(drainedRice);
console.log(boiledRice);
}3000);
}
function cookRice(rice){
washRice(rice,(washedRice)=>{
drainRice(washedRice,(drainedRice)=>{
boilRice(drainedRice);
}
});
}
I tried my best to make this code very readable. But I am still pretty sure that it is very complex to understand. This was recognised as a genuine problem called callback nesting, and a new solution was required to be able to solve this problem, as this functionality was needed very frequently by developers. And thus began the era of 'The Promise'.
Having understood the problem of callback hell is actually what gets a JavaScript developer ready for promises, the objectively most important concept to fully grasp asynchronous programming. The flow of the code and the logic behind the construction of nested code (it has a cool name too - 'Pyramid of Doom') becomes the key that unlocks the use case of promises, which we will dig into in the next article.

