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

Are you currently struggling to wrap your head around the concepts of callbacks, promises, and async/await?

Have you read countless tutorials and combed through documentation on websites like w3schools or MDN, but still can't quite wrap your head around these concepts?

Take a deep breath and don't despair! I'm here to help you make sense of these. So, let's roll up our sleeves and get going!

Note: Don’t worry if you don’t understand everything yet. This is just a quick peek to understand the difference.

Callbacks

A callback function is simply a function that you write and pass on to another function.

That other function then invokes (“calls back”) your function when some condition is met or some (asynchronous) event occurs.

The invocation of the callback function you provide notifies you of the condition or event.

Let's break this down with an example.

Consider a function called divide() that needs a server to compute its result. You don't want the main() to wait synchronously until the result is ready. Instead, you want it to be notified asynchronously when the result is ready.

One way to deliver the result asynchronously is to give divide() a callback function that it uses to notify the main function.

function main() {
  divide(12, 3, (err, result) => {
    if (err) {
      console.log(err);
    } else {
      console.log(result);
    }
  });
}

Then the following steps happen:

  • divide() sends a request to a server.
  • Then the current task main() is finished and other tasks can be executed.
  • When a response from the server arrives it invokes the callback.

Promises

Promises are a game-changer when it comes to asynchronous programming. They are a new, core language feature that was introduced in ES6 to make our lives easier.

A Promise is an object that represents the result of an asynchronous computation. That result may or may not be ready yet. The Promise API is intentionally vague about this: there is no way to synchronously get the value of a Promise; you can only ask the Promise to call a callback function when the value is ready.

So, at the simplest level, Promises are just a different way of working with callbacks.

function main() {
  divide(12, 3)
    .then((result) => console.log(result))
    .catch((err) => console.log(err));
}

Practical benefits to using them

One real problem with callback-based asynchronous programming is that it is common to end up with callbacks inside callbacks inside callbacks, with lines of code so highly indented that it is difficult to read. Promises allow this kind of nested callback to be re-expressed as a more linear Promise chain that tends to be easier to read and easier to reason about.

Another problem with callbacks is that they can make handling errors difficult. If an asynchronous function (or an asynchronously invoked callback) throws an exception, there is no way for that exception to propagate back to the initiator of the asynchronous operation.

Promises help here by standardizing a way to handle errors and providing a way for errors to propagate correctly through a chain of promises.

Async/Await

ES2017 introduces two new keywords— async and await that represent a paradigm shift in asynchronous JavaScript programming.

These new keywords dramatically simplify the use of Promises and allow us to write Promise-based, asynchronous code that looks like synchronous code that blocks while waiting for network responses or other asynchronous events.

When using async and await with Promises, much of the complexity of Promises (and sometimes even their very presence!) disappears.

async function main() {
  try {
    const result = await divide(12, 3);
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

The divide() function being called is the same Promise-based function as in the previous section. However, with the use of await, we now have synchronous-looking syntax for handling the call.

await can only be used inside a special kind of function, an async function (note the keyword async in front of the function keyword). When await is encountered, the current async function is paused and returns from it. Once the awaited result is ready, the execution of the function continues where it left off.

To sum it up, callbacks are the foundation of asynchronous programming, but they can lead to highly nested code and difficult error handling. Promises were created to simplify these issues and allow for more linear code. And with the introduction of async/await, we can write Promise-based asynchronous code that looks like synchronous code.

It's important to note that these concepts aren't mutually exclusive, but rather built on top of one another. To truly master asynchronous JavaScript, you need a solid understanding of all three.

I've developed a guide called Master JavaScript Promises. It's a beginner-friendly guide with one goal: to help you build your intuition of how Promises works, so that you can work with async functions confidently.

I have created a set of resources for learning asynchronous JavaScript. These guides—Callbacks, Promises, and Async/Await —cover everything I’ve learned from years of real-world JavaScript experience.

If you found this article helpful, you’ll get so much out of these guides. Each one is optimized for those “lightbulb moments,” building a strong mental model for how asynchronous JavaScript works and how you can use it to create fast, dynamic applications.