Callbacks, Promises and async/await in JavaScript

How asynchronous JavaScript moved from callbacks to promises to async/await, with error handling, Promise combinators and the mistakes that cause most async bugs.

Dibin Jose

7 min read

On this page 14

JavaScript runs your code on a single thread. Anything slow, such as a network request, a timer or a file read, is handed off to the browser or Node.js, and your code is told when it finishes. How you get told has changed over the years: first callbacks, then promises, then async/await.

All three are still in use. Older Node.js APIs and event listeners take callbacks, most libraries return promises, and most new application code uses async/await. This guide walks through each one in order, because each step only makes sense once you have seen the problem with the previous one.

Callbacks

A callback is a function you pass to another function so it can call you back later. Callbacks are not only for async work. Array.prototype.map takes one and calls it straight away:

const prices = [10, 25, 40]
const withTax = prices.map(price => price * 1.2)

For asynchronous work, the callback runs after the operation finishes, while the rest of your code keeps running:

setTimeout(() => {
  console.log('Two seconds later')
}, 2000)

console.log('This prints first')

Error-first callbacks in Node.js

Node.js core APIs follow a convention: the callback’s first argument is an error (or null), and the result comes after it.

const fs = require('node:fs')

fs.readFile('config.json', 'utf8', (err, text) => {
  if (err) {
    console.error('Could not read config:', err.message)
    return
  }
  console.log(JSON.parse(text))
})

You cannot use try/catch around fs.readFile to catch a missing file, because the error arrives later, after the surrounding code has finished. Every callback has to check err itself.

Where callbacks break down

Callbacks get hard to manage when one async step depends on another:

getUser(id, (err, user) => {
  if (err) return handle(err)
  getOrders(user.id, (err, orders) => {
    if (err) return handle(err)
    getInvoice(orders[0].id, (err, invoice) => {
      if (err) return handle(err)
      send(invoice)
    })
  })
})

The nesting is only part of the problem. Error handling is repeated at every level, running steps in parallel needs manual bookkeeping, and nothing stops a buggy library from calling your callback twice or never. Promises were designed to fix these issues.

Promises

A promise is an object that stands for a value that is not ready yet. It is in one of three states:

  • pending: the work has not finished
  • fulfilled: it finished with a value
  • rejected: it failed with a reason, usually an Error

Once a promise is fulfilled or rejected (together called settled), it never changes again, and its handlers run at most once.

Creating a promise

Most of the time you consume promises returned by fetch, database clients or fs/promises. When you need to wrap a callback-based API yourself, use the Promise constructor:

function wait(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

function readJson(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, text) => {
      if (err) reject(err)
      else resolve(JSON.parse(text))
    })
  })
}

The function passed to new Promise (the executor) runs immediately. Call resolve with the result or reject with an error. For Node.js-style callbacks, util.promisify does this wrapping for you, and most core modules already have promise versions, such as node:fs/promises.

then, catch and finally

.then() registers what to do with the value, .catch() handles a rejection, and .finally() runs either way, which suits cleanup such as hiding a spinner.

showSpinner()

fetch('/api/orders')
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return res.json()
  })
  .then(orders => renderOrders(orders))
  .catch(err => showError(err.message))
  .finally(() => hideSpinner())

Two details make chaining work:

  • Each .then() returns a new promise. Whatever you return from the handler becomes that promise’s value. If you return another promise, the chain waits for it.
  • A thrown error, or a rejected promise, skips the following .then() handlers and goes to the next .catch(). One .catch() at the end covers every step above it.

Note that fetch only rejects on network failure. A 404 or 500 response still fulfills, so check res.ok yourself, as above.

The callback example from earlier becomes a flat chain:

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getInvoice(orders[0].id))
  .then(send)
  .catch(handle)

async and await

async/await (ES2017) is syntax on top of promises. An async function always returns a promise, and await pauses that function until a promise settles, without blocking the thread.

async function sendFirstInvoice(id) {
  const user = await getUser(id)
  const orders = await getOrders(user.id)
  const invoice = await getInvoice(orders[0].id)
  return send(invoice)
}

The code reads top to bottom, and ordinary try/catch/finally works again because a rejected promise makes await throw:

async function loadOrders() {
  showSpinner()
  try {
    const res = await fetch('/api/orders')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return await res.json()
  } catch (err) {
    showError(err.message)
    return []
  } finally {
    hideSpinner()
  }
}

The return await inside try is deliberate: without await, a rejection from res.json() would escape the catch block.

In ES modules you can also use await at the top level of a file, outside any function.

Running work in parallel

await on each line runs the steps one after another. That is right when each step needs the previous result, and wasteful when they are independent. The Promise combinators run several promises at once and combine the results.

Promise.all

Waits for every promise and returns their values in the same order. It rejects as soon as any one of them rejects.

const [user, plans, usage] = await Promise.all([
  getUser(id),
  getPlans(),
  getUsage(id)
])

Use it when you need every result and one failure means the whole operation failed.

Promise.allSettled

Waits for every promise, whatever happens, and reports each outcome. It never rejects.

const results = await Promise.allSettled(urls.map(url => fetch(url)))

const failed = results.filter(r => r.status === 'rejected')
console.log(`${failed.length} of ${urls.length} requests failed`)

Use it for batch jobs where partial success is acceptable, such as sending notifications or warming caches.

Promise.race

Settles with the first promise to settle, whether it fulfilled or rejected. The classic use is a timeout:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
  )
  return Promise.race([promise, timeout])
}

race does not cancel the slower promise; it only stops waiting for it. To actually abort a request, pass an AbortSignal to fetch. AbortSignal.timeout(ms) creates one that aborts after a delay.

Promise.any

Fulfills with the first promise that fulfills, ignoring rejections. It rejects only if all of them reject, with an AggregateError listing every reason. It suits redundant sources, such as trying several mirrors and keeping the first that answers.

Common mistakes

Forgetting to return inside .then(). The chain moves on with undefined and does not wait for the inner work:

// Wrong: save() runs, but nothing waits for it or catches its errors
getDraft().then(draft => {
  save(draft)
})

// Right
getDraft().then(draft => save(draft))

Using await inside forEach. forEach ignores the promises its callback returns, so the code after it runs before the work is done. Use for...of for sequential steps, or Promise.all with map for parallel ones:

for (const file of files) {
  await upload(file) // one at a time
}

await Promise.all(files.map(file => upload(file))) // all at once

Accidental sequential awaits. Two independent await lines take the sum of both durations. Start both, then await them together with Promise.all.

Unhandled rejections. A rejected promise with no handler is easy to miss. Browsers log it and fire an unhandledrejection event; current Node.js versions crash the process by default. Make sure every chain ends in a .catch() or sits inside try/catch, and log unhandled rejections in production.

Wrapping a promise in new Promise. If a function already returns a promise, return it or await it. Wrapping it in a new constructor adds code and often loses errors when the reject path is forgotten.

Throwing inside a callback within the executor. An error thrown inside a setTimeout or Node.js callback does not reject the surrounding promise. Call reject(err) there instead.

Mixing styles in one function. A function that sometimes calls a callback and sometimes returns a promise is hard to use correctly. Pick one interface, preferably promises.

Which style to use

  • async/await for application code. It is the easiest to read and to handle errors in.
  • Promise combinators whenever independent work can run at the same time.
  • Callbacks for events and streams that fire many times (addEventListener, stream.on('data')), and when you work with an older API you have not wrapped yet.

Promises came with ES2015, alongside the other features in this ES6 guide. For applying these patterns on the server, see Node.js best practices.

This article originally appeared on lightrains.com

Leave a comment

To make a comment, please send an e-mail using the button below. Your e-mail address won't be shared and will be deleted from our records after the comment is published. If you don't want your real name to be credited alongside your comment, please specify the name you would like to use. If you would like your name to link to a specific URL, please share that as well. Thank you.

Comment via email

Share this article

Topic

More on Engineering Insights

Tools, languages and practices from the Lightrains team, and how offshore engineering teams work.

Written by

Dibin Jose

Software Engineer - Blockchain

Software Engineer at Lightrains Tech

Keep reading

Ready to build your next AI product?

Get a free consultation and project quote for AI, software or product development, tailored to your goals.

  • No-obligation consultation
  • Clear scope and timeline
  • Transparent pricing
Get your free project quote