On this page

Callbacks → Promises → async/await

Async code in JavaScript has been written in three styles over the years: callbacks, then promises, then async/await. Each one was created to fix the problems of the one before it.

Definition

  • Callback: a function passed into another function, to be called later when the async work finishes.
  • Promise: an object that represents a value that will be available in the future: either a result or an error.
  • async/await: a cleaner syntax on top of promises that lets async code read like normal, top-to-bottom code.

Analogy: think of ordering food.

  • Callback: you give the restaurant your phone number and trust them to call you. You have no control over when, or whether, they call.
  • Promise: you get an order receipt. It will be marked either ready or cancelled, and you decide what to do in each case.
  • async/await: you wait at your table with the receipt, and the food comes to you. It reads simply, but the kitchen keeps serving others meanwhile.
CallbacksPromisesasync/await
IntroducedSince the startES6 (2015)ES2017
Result throughA function argument.then()await
Errors througherr argument.catch()try / catch
ReadabilityNests deeper with each stepFlat chainLooks like sync code
Built on——Promises

The Example We'll Rewrite

We need to do 3 steps in order: get a user, then their orders, then the details of the first order.

1. Callbacks

function getUser(id, callback) {
setTimeout(() => callback(null, { id, name: "Asha" }), 500);
}
function getOrders(user, callback) {
setTimeout(() => callback(null, [101, 102]), 500);
}
function getOrderDetails(orderId, callback) {
setTimeout(() => callback(null, { orderId, total: 499 }), 500);
}
getUser(1, (err, user) => {
if (err) return console.error(err);
getOrders(user, (err, orders) => {
if (err) return console.error(err);
getOrderDetails(orders[0], (err, details) => {
if (err) return console.error(err);
console.log(details); // { orderId: 101, total: 499 }
});
});
});

Node.js callbacks follow the error-first convention: the first argument is the error (null if there is none), and the result comes after it.

Problems With Callbacks

ProblemWhat it means
Callback hell (pyramid of doom)Each step nests deeper, so the code drifts to the right and gets hard to read
Repeated error handlingYou must check err at every level
Inversion of controlYou hand your function to someone else's code and trust it to call it once, not twice, not never

2. Promises

A promise is always in one of three states:

StateMeaning
PendingThe work is still running
FulfilledFinished successfully, with a value
RejectedFailed, with an error

Once a promise is fulfilled or rejected, it is settled and never changes again. This fixes inversion of control: a promise can't succeed twice, or succeed and then fail.

function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve({ id, name: "Asha" }), 500);
});
}
function getOrders(user) {
return new Promise((resolve) => {
setTimeout(() => resolve([101, 102]), 500);
});
}
function getOrderDetails(orderId) {
return new Promise((resolve) => {
setTimeout(() => resolve({ orderId, total: 499 }), 500);
});
}
getUser(1)
.then((user) => getOrders(user))
.then((orders) => getOrderDetails(orders[0]))
.then((details) => console.log(details))
.catch((err) => console.error(err)); // one catch for every step

What improved:

  • Flat chain instead of nesting, because each .then returns a new promise.
  • One .catch handles an error from any step.
  • A promise settles only once.

Converting a Callback Function to a Promise

const fs = require("fs");
const util = require("util");
// Option 1: util.promisify (works with error-first callbacks)
const readFileAsync = util.promisify(fs.readFile);
// Option 2: wrap it yourself
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Option 3: Node's built-in promise version
const fsp = require("fs/promises");

Running Promises Together

MethodResolves whenRejects when
Promise.allAll succeed (gives all results)Any one fails
Promise.allSettledAll finish, success or failure (gives every outcome)Never
Promise.raceThe first one settles, success or failureThe first one to settle fails
Promise.anyThe first one succeedsAll fail

3. async/await

An async function always returns a promise. Inside it, await pauses that function until a promise settles, then gives you its value.

async function showOrderDetails() {
try {
const user = await getUser(1);
const orders = await getOrders(user);
const details = await getOrderDetails(orders[0]);
console.log(details); // { orderId: 101, total: 499 }
} catch (err) {
console.error(err);
}
}
showOrderDetails();

What improved:

  • Reads top to bottom, like synchronous code.
  • Errors use normal try / catch.
  • Easy to use loops and if conditions with async steps.

Note: await pauses only the async function. The main thread keeps running other code, and the rest of the function resumes as a microtask.

Mistake: Running Independent Tasks One by One

// Slow: about 1000 ms (each await waits for the previous one)
async function slow() {
const user = await getUser(1);
const orders = await getOrders({ id: 2 }); // doesn't need user
}
// Fast: about 500 ms (both start at the same time)
async function fast() {
const [user, orders] = await Promise.all([getUser(1), getOrders({ id: 2 })]);
}

Use await one after another only when a step needs the result of the previous one. Otherwise, start them together with Promise.all.

Summary

  • Callbacks pass a function to be called later. They lead to callback hell, repeated error checks, and inversion of control.
  • Promises are objects with three states: pending, fulfilled, rejected. They settle only once, chain flat with .then, and handle errors with one .catch.
  • util.promisify converts error-first callback functions into promise-based ones.
  • Promise.all, allSettled, race, and any run several promises together.
  • async/await is syntax on top of promises. An async function always returns a promise, and errors use try / catch.
  • Don't await independent tasks one by one. Run them in parallel with Promise.all.

Interview Answer: How Did Async Code Evolve From Callbacks to async/await?

At first, async code used callbacks: functions passed in to run when the work finishes. With many dependent steps, callbacks nest inside each other, which causes callback hell. Errors must be checked at every level, and there is inversion of control, because we trust another function to call our callback exactly once.

Promises fixed this. A promise is an object that is pending, then either fulfilled or rejected, and it settles only once. Promises chain flat with .then(), and one .catch() handles errors from any step. Methods like Promise.all let us run tasks in parallel.

async/await, added in ES2017, is syntax on top of promises. An async function always returns a promise, and await pauses that function until a promise settles, so async code reads like synchronous code and uses normal try / catch. I still use Promise.all when tasks don't depend on each other, so they run in parallel instead of one by one.

Next TopicPromise.all, allSettled, race, any