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.  

Promise state diagram

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.