There are two fundamental methods for handling asynchronous programs in JavaScript: callbacks and promises. Callbacks have been there since the very beginning of JavaScript and are still widely used in many older libraries and APIs. Nonetheless, in the last few years, promises have gained popularity due to their legibility and ease of use. In this article we will explore these two approaches — the differences between them, their pros and cons and when to use each one — with explanatory examples.
Callbacks
Callback in JavaScript is a function that is passed as an argument to another function and is called by it at a certain point in the operation, e.g. after fetching data from the database. That way the calling code can extend the function called, by providing additional behavior, and the function called can be written in a more general fashion, not knowing the context it is called in.
const welcome = (name, callback) ⇒ {
console.log(`Hello, ${name}!`);
callback(name);
}
const goodbye = (name) ⇒ {
console.log(`Goodbye, ${name}!`);
}
welcome('Chris', goodbye);
// Hello, Chris!
// Goodbye, Chris!
Example of a callback function
In this example, the welcome function takes two arguments: the string name and the callback
function callback. Inside the function, we log Hello, (name)! to the console and call our
callback function goodbye with one argument name, which prints out
Goodbye, (name)! When we call the welcome function with Chris as the first
argument, the console outputs Hello, Chris! and then Goodbye, Chris!
const welcome = (name, callback) ⇒ {
console.log(`Hello, ${name}!`);
callback(name);
}
welcome('Chris', (name) ⇒ {
console.log(`Goodbye, ${name}!`);
});
// Hello, Chris!
// Goodbye, Chris!
The same example but using an anonymous callback function
Callbacks are commonly used in asynchronous JavaScript, mainly in older code or libraries that have
not been updated to use newer async patterns, such as Promises or
async/await. Callback functions can also be helpful in cases where we require finer-grained control
over operation ordering. For instance, many Node.js built-in functions rely on callbacks, like the
fs
module for operations on files or the
http
module for HTTP requests. In the browser, callback functions are used in certain APIs, such as
setTimeout
and
setInterval
methods, the
addEventListener
method on DOM elements (user events) or the
XMLHttpRequest
object. However, in modern JavaScript, Promises have become the standard approach of handling asynchronous operations,
and newer libraries and APIs are increasingly using Promises and async/await.
const fs = require('fs');
fs.writeFile('file.txt', 'Hello World!', (error) ⇒ {
if (error) {
throw error;
} else {
console.log('File saved!');
}
});
Example of using callback with fs module
In the example above, writeFile function takes three arguments: the file name, the data to be written to
the file, and a callback function that will be called after the file has been written. If the file writing fails, an
error will be thrown. Otherwise, a message File saved! will be logged to the console.
const button = document.getElementById('myButton');
button.addEventListener('click', (event) => {
console.log('Button clicked!');
});
An example of using callback in user events
In this example, we select a button from the DOM elements by its id and add a click event listener to
it. Each time a user clicks on the button, our callback function is called and prints Button clicked! to
the console.
Advantages and disadvantages of callbacks
- Allow to execute code after some asynchronous operation has finished
- Useful for handling asynchronous operations such as user events or
XMLHttpRequestobject - Used to create higher-order functions
- Simplicity of use
Even though callbacks are still commonly used in JavaScript in many libraries and APIs, they have their drawbacks, too. The main disadvantage of callbacks is known to every developer as callback hell and looks always more or less as illustrated below.
call0((val0) ⇒ {
call1((val1) ⇒ {
call2((val2) ⇒ {
call3((val3) ⇒ {
call4((val4) ⇒ {
call5((val5) ⇒ {
call6((val6) ⇒ {
alert('You reached CALLBACK HELL')
})
})
})
})
})
})
})
A callback hell
In the image, there is a block of code with nested callback functions where each of them calls the next callback and depends on the previous one. Working on asynchronous operations in this way can cause many issues with code, such as:
- Low readability and problem in maintaining code
- Difficulty in error handling, debugging and testing
- Increased code complexity
- Problem in managing variable scope
- Difficulties in managing flow control
Promises
A Promise in JavaScript is an object containing information about the current state and result of an
asynchronous operation. There are three different states: pending, fulfilled and
rejected. The pending state is the initial state for every Promise and
means that the operation has not yet been completed. When it is fulfilled, it means that the operation is
completed successfully, and its value is available. If the operation fails, the Promise will be in
the rejected state and as a result we will receive an error.
The image above illustrates a Promise chain. At the beginning we receive a pending Promise with
undefined, at this point, result. Then, the Promise can be either fulfilled if successfully resolved and
we get a resulting value or rejected with an error. To access a Promise object we use the
then method or the catch method to handle errors. The then method returns a new
Promise and it can be chained with another then. Let’s take a look at an example:
const iPromise = new Promise((resolve, reject) ⇒ {
if (/*success*/) {
resolve('Success!');
} else {
reject('Failure!');
}
});
iPromise
.then(onResolve1, onReject1)
.then(onResolve2, onReject2)
.then(onResolve3, onReject3)
then methods can be chained with each other
The then method takes up to two arguments, which are callbacks, for two possible scenarios: resolve or
reject. In the case of resolve, we receive a result, such as data from the database. In the case of reject, we get an
error. As we can see, then can both handle successful async operations and those that fail. But handling
both cases on each Promise from the promise chain can be a little bit tiring and a solution for that is the
catch method. A catch placed at the end of a promise chain will handle any rejection that
occurs.
iPromise
.then(onResolve1)
.then(onResolve2)
.then(onResolve3)
.catch(onReject)
Promise's catch method
To perform some final procedures after a Promise is settled, for example a clean-up, we use the finally
method. In that method we have no access to the state and result values of the Promise
object.
iPromise
.then((result) ⇒ console.log(result))
.catch((error) ⇒ console.error(error))
.finally(() ⇒ console.log('Action completed!'))
Promise's finally method
To handle multiple Promises at once we can use the
Promise.all
method, as follows:
Promise.all([promise1, promise2, promise3])
.then((results) ⇒ console.log(results))
// [...] output after all promises have resolved
then resolves only when all promises resolve
Promise.all takes an array of Promises as an argument and returns an array of results from all the input
Promises after they have all been resolved.
Promises were first introduced in ES6 as a better solution to deal with asynchronous operations than callbacks.
Promises can be created using a constructor like our iPromise from the previous examples, but most often
we consume Promises returned from asynchronous functions like fetch or
async/await. We also receive Promises from commonly used libraries, such as Axios and
Stripe.
fetch('https://my_api_data.com')
.then(response ⇒ response.json())
.then(data ⇒ {
// do something with the data
})
.catch(error ⇒ {
// handle error
});
API access with fetch function
The image above illustrates fetching data from a server using the asynchronous fetch
function.
Another great strength of Promises is that we can create a new Promise in the then
function, like in the example below:
const email = 'chriswater@mail.com';
fetch("https://my_api_data.com/users?email=${email}`)
.then(response ⇒ response.json())
.then(userData ⇒ {
const userId = userData.id;
return fetch('https://my_api_data.com/posts?userId=${userId}`);
})
.then(response ⇒ response.json())
.then(userPosts ⇒ {
console.log("User's posts:", userPosts);
})
.catch(error ⇒ {
console.error("Error:", error);
});
A chain of fetch requests, happening one after another
As shown in the image, we fetch the user's data by email address. In the next step, we extract the id from the data
received from the API and send another request to fetch the user's posts by id number. Then we process the received
result and log posts or an error to the console. Here we use the new Promise that is returned by the
second fetch query.
Advantages of Promises
- Clean and elegant syntax
- Can be chained together in order to perform complex operations
- Have standardized interface for managing errors
- Can be used with the async/await code
- Simplify parallelizing many asynchronous operations
Disadvantages of Promises
- Can be difficult to learn for beginners
- Can cause memory leaks when not properly cleaned up if they are no longer needed
- Hard to debug in some cases
While the Promise-based approach is very efficient for handling asynchronous code in JavaScript, it requires careful and thorough implementation for effective use and is not the only and always right choice.
Summary. Callbacks vs Promises?
Promises have a better built-in error handling mechanism and provide more efficient flow control management than callbacks. They have an elegant and readable structure for handling multiple async functions at once, while callbacks in more elaborate operations tend to be difficult to read due to many nested functions in one block of code, causing a problem called callback hell. On the other hand, Promises can cause memory leaks if not used correctly and, because of their asynchronous nature, they can be harder to debug. Despite the many advantages of Promises over the callback approach, callbacks are still occasionally used in JavaScript, such as when working with legacy code, but overall, Promises are a better choice if they can be used.