What is the difference between callback functions, promises, and async/await in Javascript?

You've probably worked with each three of them yet as they are all very basic, yet reliable methods for asynchronous programming. This article aims to take a closer look into how they differ from one another, what they have in common and where async/await comes into play

The TL: DR - version:

  1. Callbacks are functions passed as arguments into other functions to make sure mandatory variables are available within the callback function's scope
  2. Promises are placeholder objects for data that's available in the future. As soon as their state changes from pending to resolved, .then() the method can be called to make the data available for subsequent operations.
  3. Async / await is syntactic sugar over promises. Instead of using .then(), you can assign data that's resolved by a promise and assign it to a variable that is available within an async function() scope.

A big part of what Javascript does best has to do with sending and processing data over the internet. There's a good chance that you'll stumble across AJAX, specifically in terms of making API requests quite early in your web dev journey.

You'll have to consider asynchronous code execution whenever you are:

  1. Calling data from remote resources
  2. Waiting for other processes to deliver computed information back into your application's scope

While you might encounter the first more often in your frontend and the second in your node.js backend, in both cases callback functions and promises are bound to come into play.

In a (really tiny) nutshell, each of these variants makes sure that remotely called resources - or computed variables are ready for usage when they're needed.

Asynchronous programming

To understand asynchronous code execution, it helps to put the concept into words and compare it with its synchronous counterpart.

If you're aware of the basics, feel free to skip to Callback functions.

Assume you have three lines of code that do the following:

  1. A variable declaration that assigns the number 5 to a variable named number.
  2. A function that takes in a variable number and sends it to a server (URL) via ajax.
  3. An alert function that passes back the result from (2) to a user.

Then, consider the following JS pseudocode:

/*(1)*/ const number = 5; 
/*(2)*/ const serverResponse = getServerData(url, number)
/*(3)*/ alert(serverResponse)

If you were using a synchronously executed language, this construct may work just fine. Take a number, evaluate it serverside and alert the result.

A Javascript engine, however, will not wait for (2) to evaluate; if serverResponse is not available right away, it will call the alert and you will see something like this:

What else could happen is your console throwing an error, stating that serverResponse is not defined. Both exceptions are symptoms of the same cause.

The reason is the order in which the two functions get called. Javascript's event loop creates a queue of tasks being worked on in a given order. However, it doesn't wait for either of those tasks to finish but executes the next one in line right away without waiting for an evaluation.

To prevent this from happening, we have to make sure that variables are available for consumption before they're assigned or used for other functions. At that point, callbacks and promises come into play.


Callback functions

A callback is a function (let's call ours bar) that is called right before another function finishes (function foo).

It's Call[ed in the] back [of another] function

For that to happen, bar must be passed into foo as an argument, so that the variables that have been evaluated in foo are available in the function scope of bar.

So far so good? Let's take a look at the following example:

// First, define bar, our callback function.
const bar = (fooNumber) => {
  return fooNumber + 5;
};

// Then, define the function that delivers variables for bar
const foo = (callback) => {
  const myNumber = 10;

  // 'callback' is the placeholder for the actual function
  callback(myNumber);
};

// Finally, execute foo and pass fooNumber into bar
foo((fooNumber) => console.log(bar(fooNumber))); // expected result: 15

It might look quite alien at first sight, so we'll replicate this behavior with a synchronous example that works just the same:

const bar = (fooNumber) => {
  return fooNumber + 5;
}

const foo = () => {
  const myNumber = 10;
  return myNumber;
}

console.log(bar(foo())) // expected result: 15

Both of the above functions return the exact same result but differ in how they get there.

  • The first function evaluates foo and passes its result on the next function, making sure it is available for bar
  • The second function evaluates inside-out. It executes, followed right away by bar, using foo's result as an argument.

And here comes the trick - What would happen if, in scenario 2, foo was not yet evaluated into 10, but takes a moment (half a second) to get that number from elsewhere?

const bar = (fooNumber) => {
  return fooNumber + 5;
}

const foo = () => {
  setTimeout(() => {
    const myNumber = 10;
    return myNumber;
  }, 500)
}

console.log(bar(foo())) // expected result: ?

The result will be NaN, as foo, at the moment its value is read within bar, is undefined.

Let's now put this timeout into the callback example:

const bar = (fooNumber) => {
  return fooNumber + 5;
};

// Then, pass it into foo as an argument
const foo = (callback) => {
  setTimeout(() => {
  const myNumber = 10;
  callback(myNumber);
  }, 500)
};

foo((fooNumber) => console.log(bar(fooNumber))); // expected result: ?

That looks much better, we're back to 15.

As a callback is executed as a part of another function, these two are considered to be the same task (message) in the event loop's sequence flow. This is not the case in the second, non-callback example, therefor foo's evaluation is undefined and the result is NaN.


Promises

You might have noticed the type of the object that was alerted in the first example above. It was not the expected variable from serverResponse, but neither was it undefined.

What you have seen was a placeholder for a variable that will be there at some point in the future. Imagine it like the small buzzer you're handed in a restaurant while you're waiting for your food to be delivered. When handed to you, the exact moment of when your dish arrives is unknown, but it will do at some point. You will be notified by the state of the buzzer (changing from inactive to buzzing) as soon as the moment comes.

A buzzer is a literal promise for your food to arrive at some point#

As soon as the buzzer goes off, the promise made to you is, and you can go and claim your food. Only then, it is available to you for eating.

window.fetch and Javascript service workers are two innovative browser technologies that heavily rely on promises. Especially service workers run in a purely asynchronous context, making data availability the most crucial prerequisite for proper results.

Let's try and replicate this example in code features:

  • When the order is placed, the exact moment of food availability is unknown, but always takes between 5 and 20 minutes (seconds in the code).
  • A placeholder (Promise - object) is handed out to the calling function.
  • It resolves into the amount of time that has passed since the order placement, but only once the food is ready.

And now to the related code that simulates the waiting time:

// Imagine to be at a restaurant and place an order
const orderFood = () => {

 // A buzzer will be handled to you
 return new Promise((resolve, reject) => {
 
  // Cooking time could be anything between 5 and 20 seconds
  const cookingTime = 5000 + Math.random() * 15000;

  // The food will be prepared in the given time
  setTimeout(() => {
   const foodReady = true;

   // If the food is ready after the cooking time,
   // pass the information on to the buzzer. Also,
   // pass on the cooking time in seconds
   if (foodReady) {
    const time = (cookingTime / 1000).toFixed();
    resolve(time);

    // If it is not ready for some reason, throw an exception which
    // you can later catch when calling the function
   } else {
    const reason = 'Your food could not be prepared ...';
    reject(reason);
   }
  }, cookingTime);
 });
};

// Call the initial function. Wait for it to resolve
orderFood()

 // The variable in the .then method is what you have passed
 // into the resolve function within the promise
 .then((time) => {
  console.log(`BZZZZZ BZZZZZ - Your food is ready.`);
  console.log(`Your waiting time was ${time} seconds`);
 })

 // Catch the reason for the promise rejection
 .catch((reason) => {
  console.log(reason);
 })

 // Perform an operation after any type of outcome
 .finally(() => {
  return 'Handing buzzer back to restaurant staff'
 });

Note that there's more to promises, such as the Promise.all() and Promise.any() methods, which give you even better control of asynchronous code processing. They're out of scope for this article, but worth mentioning at this point.


Async / await

... is actually syntactic sugar over promises and not a separate technique. Instead of returning a single placeholder per function, you can declare the same with the help of anasync function and use the keyword await inside that function's scope whenever trying to assign a variable with a value that's not available yet. While the functionality is the same, asynchronous functions look more like that type of coding you're already familiar with.

Let's try and rephrase the above function call of orderFood() in async style.

// Promise style
orderFood()
 .then((time) => {
  console.log(`BZZZZZ BZZZZZ - Your food is ready.`);
  console.log(`Your waiting time was ${time} seconds`);
 })

// async/await style
(async () => {
 // Instead of chaining .then() methods, you can use the await keyword
 const time = await orderFood();
 console.log(`BZZZZZ BZZZZZ - Your food is ready.`);
 console.log(`Your waiting time was ${time} seconds`);
})();

When dealing with multiple promises, instead of chaining .then() - methods over several promises, you could keep assigning variables as you did before, within a single function's scope. Writing async/await functions might also make longer code files more readable and prevents you from ending up in a .then() - type of callback hell.

A note: Try and avoid using promises together with async / await in the same file. Choose the one you prefer and stick with it - your team will thank you.

Bonus: A peek into advanced promises

Okay, before I finish, let me give you an idea of the previous teaser.

Since their introduction, promises became a core part of asynchronous Javascript programming. With this transition came many useful features - including concurrent resolving of several promises at once.

The method in question is Promise.all(). It makes sure that all promises you pass into it are resolved before moving ahead in the code chain. This comes in especially handy if you use two or more remote resources that have dependencies on one another.

I will not go into detail here - perhaps in a later article - but the commented code below should give you an idea of how Promise.all() works. If you have not encountered fetch() yet, you can find a brief introduction over at MDN.

For the example below, I am using JSONPlaceholder, a fake API that delivers mock data in JSON format.

# Install npm package for serverside fetch
$ npm i node-fetch
// Import the fetch module for serverside fetch execution
const fetch = require('node-fetch');

(async () => {
  // Assign one promise (fetch) to each variable
  const users = fetch('https://jsonplaceholder.typicode.com/users');
  const posts = fetch('https://jsonplaceholder.typicode.com/posts');
  const albums = fetch('https://jsonplaceholder.typicode.com/albums');

  // Wait for all three promises to resolve
  const responses = await Promise.all([users, posts, albums]);

  // Transform the promise body into json
  const data = await Promise.all(responses.map((el) => el.json()));
  console.log(data);

  // To each user, assign the corresponding post and albums
  const userData = data[0].map((user) => {
    user.posts = data[1].filter((post) => post.userId === user.id);
    user.albums = data[2].filter((album) => album.userId === user.id);
    return user;
  });

  // Voilรก - the users received their matching albums and posts
  console.log(userData);
})();