From Callbacks to a Promise

Assume you hired a worker and want him to perform work for you. Naturally, you would have a lot of tasks prepared for him to do. But how would he know what to do next? Surely you can't be sitting near him every time. That would waste your time. So you had a clever idea. You provided him with a document for every task, and at the end of the task, the document number for the next task was written. Now he would know what to do next.
Callbacks in JavaScript work in the same fashion in the context of asynchronous programming. But it is not that simple. Even though the world of asynchronous programming in JavaScript rested in the hands of callbacks for a long time, there was still something off. Callbacks helped a lot, undeniably so, but for large codebases, they were not very merciful. It would force developers to write very complex code with nested callbacks, which is very difficult to maintain. It was such a headache that an entire library, Async.js, was created just to help developers overcome it. It was clear that a more permanent solution was needed. And that came in the form of promises.
But before we get into promises and how they overcome callbacks, we need to understand the bigger picture. We first need to understand what Asynchronous code is, how callbacks actually work, and what the problem with callback nesting is.
Asynchronous JavaScript
The first thing to know is that JavaScript, at its core, is synchronous. Meaning- in core JavaScript, instructions can only be executed in a step-by-step manner. It is not an asynchronous language. However, that applies only to core JavaScript. The codes we see are usually a combination of core JavaScript and the environment it runs in. It is this combination that can behave asynchronously. But how does that actually happen?
Working of Asynchronous Code
Let us try to understand why we would need asynchronous behaviour first.
In JavaScript, code runs sequentially. The JavaScript engine decides what code to run based on defined tasks present in the call stack. This stack contains the 'functions' or blocks of code that the engine executes in one go. When one finishes, it reaches for the top of the stack again to get another task to do
But suppose you start a task that might take up some time. However, that would cause the code to block the flow of execution, creating a lot of delay. And users don't like delays. So the developers of the environment (like Browser or Node.js) designed those APIs differently, which were known to take some time to execute. This was to ensure that they do not become a bottleneck in execution and instead run in parallel on a different thread. These APIs that trigger an asynchronous nature are predefined.
Now, in the first pass, the engine passes through the code line by line. All synchronous code is executed sequentially, while any functions meant to be executed are pushed into the call stack. However, the asynchronous tasks are treated a little differently. They are meant to be processed by the environment. These asynchronous tasks are never something that JavaScript can execute directly. They are always functionalities provided by the environment. Even if an asynchronous task takes no time, it is still something that the engine cannot execute by itself.
Now, when that task is completed by the environment, it often either takes or creates a callback that has the logic of what to do next after this task. This function is pushed into what is called the task queue. Tasks from this task queue are pushed into the call stack only when the call stack is empty. The management of this process is the job of the event loop. A task from the call stack cannot be picked up until the current task at hand is complete.
For example:
console.log('Hello');
setTimeout(()=>console.log('Timer Completed'),0);
console.log('I was written after Timer');
Now, in this code, since the timer never takes any time, we think that it should print:
Hello
Timer Completed
I was written after Timer
But that never happens. Instead, we get:
Hello
I was written after Timer
Timer Completed
This is because console.logs were synchronous statements. They get executed sequentially. But setTimeout cannot be pushed into the call stack as it is not something that JavaScript can process directly. It needs to be managed by the environment. What is pushed in the call stack is the callback that is given in setTimeout after the timer expires, and the call stack gets empty. Once the environment processes it, it then pushes it into the task queue. Even if environment processing takes true 0 time, it can never push the callback in the middle of the logs, as it is a separate execution context.
Need for callback Nesting.
The need for callback nesting arrived when we sometimes wanted tasks not to actually run in parallel. This could sound surprising, but it is perfectly logical. Because what if we had to wait for the completion of an asynchronous task, so that based on its consequence, or the data it returns, we can perform some task. For example:
const data = fetch('www.example.com');
console.log(data);
Here, fetch is an asynchronous operation, and we want the data to be printed only when the fetch is completed. Hence comes the genius idea of using a callback. For such asynchronous functions, we pass them a callback to execute once they are done.
For example:
fs.readFile("data.txt","utf8",(err,data)=>{
console.log(data);
});
Now, readFile is an asynchronous code that takes a callback and executes it after the file reading operation is completed. However, if we wanted asynchronous tasks to be continued sequentially, we would need to repeatedly nest callbacks to achieve that.
For example:
fs.readFile("data1.txt","utf8",(err,data1)=>{
fs.readFile("data2.txt","utf8",(err,data2)=>{
fs.writeFile("data3.txt",`\({data1}+\){data2}`,(err)=>{
if(err){
console.log(err);
}else{
console.log('File Saved');
}
})
});
});
This problem was what came to be known by names like "callback hell" or "Pyramid of doom", referring to the deep, complex nested structure which was difficult to follow.
Promises
Now promises were not a patch on the language. It was an infrastructure change within the language ecosystem to support asynchronous programming. A promise is well visualised for the word that is used to represent the concept. A promise, as we know, can either be fulfilled or broken (rejected). Until then, it remains pending. But when the time comes, it will settle into either one of these states.
The concept in JavaScript looks a lot the same. An asynchronous function, on being encountered for execution, will immediately return a promise. A promise is a special kind of object that holds a state and a value. The state, called the status of a promise, can be one of three things:
pending
fulfilled
rejected
The process of how promises work is quite similar to callbacks. First, all synchronous code gets executed. Then the environment does the asynchronous task. Once it is done with the task, it marks the promise state as either fulfilled or rejected (if some error occurs) and sets its value to the data it got. Based on this, a developer can use a feature called ".then". The ".then" feature takes a callback as an argument and schedules it for execution after a promise is resolved. Similarly, a ".catch" feature schedules a callback to be executed in case the promise is rejected. On the change of state of a promise, these features schedule a function in the 'microtask queue'. A queue that is higher in priority than the task queue. If the call stack is empty, tasks are picked up one by one from here until it is empty. Only when the microtask queue has been emptied can the task queue be reached out for by the event loop.
For example:
console.log('Fetch Start');
// Fetch is an API that returns a promise
const fetchPromise = fetch('www.example.com');
fetchPromise.then((data)=>{
console.log(data);
})
.catch((err)=>{
console.error(err);
});
console.log('Written After Fetch');
The output for this code would be something like:
Fetch Start
Written After Fetch
Example or Error based on what fetch returned
This simply can be felt as: The fetch function has given a promise. On fulfilment, print the data that it promised to bring (the callback function having a data parameter in .then represents the fact that it was expecting data). But if the promise is broken (rejected), there must have been some error. Find out what the error was and print that error so that we can examine the problem.
The main advantage of this usage is that we can execute another asynchronous function in .then and have it return a promise. Then this promise is returned by .then, on which we can apply another .then. This makes the flow linear instead of nested.
For example:
const washRicePromise = washRice(rawRice);
washRicePromise
.then((washedRice)=>{
return drainRice(washedRice);
})
.then((drainedRice)=>{
return boilRice(drainedRice);
})
.then((boiledRice)=>{
return boiledRice;
})
.catch((error)=>{
console.log("An Error Occurred",error);
})
Here, we assume that each function returns a promise and a value on fulfilment. We pass on that value to the next function. Any value that is directly returned by .then is, in itself, returned wrapped in a fulfilled promise. Another advantage is that, at any step, the error is caught by a single handler, making the logic clearer and more structured.
Promises have a lot more to them, which we will discuss in the upcoming article.

