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
XMLHttpRequest
object - 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.
If you are looking for expertise in JavaScript frameworks such as Vue or React…
Let’s talk!Aneta Narwojsz is a dedicated Frontend Developer at Makimo, harnessing her profound expertise in JavaScript and other frontend technologies to deliver remarkable web solutions. Known for her knack for translating complex JavaScript fundamentals into easily digestible content, she regularly publishes enlightening articles and engaging video materials. When she's not immersed in code, Aneta unwinds with her favorite pastimes: visiting the cinema, immersing herself in books, experimenting in the kitchen, and exploring fashion trends.