Ch. 1 · JavaScript

Callbacks vs Promises vs Async/Await in JavaScript Explained

One task written three ways (callbacks, promise chains, async/await) with error handling, parallel work and the return-await trap, run in Node.

~6 min readintermediateupdated Oct 20, 2026

The direct answer: these are three generations of the same idea, which is “run this code when the slow thing finishes”. A callback is a function you hand to an API so it can call you back. A promise is an object representing a result that will exist later, which you can pass around, chain and combine. async/await is syntax built on promises that lets you write the same chain as if it were ordinary sequential code. An async function always returns a promise, and await pauses that function (not the thread) until a promise settles.

So “promises vs async/await” is a bit of a trick question. They aren’t competitors. You use await on promises, and you still need Promise.all and friends when you want things to happen at the same time.

Comparison at a glance

Callbacks Promise chains async/await
What you get back Nothing; the result arrives in your function A promise object A promise (from the async function)
Sequencing several steps Nesting, one level per step .then() chain One await per line
Error handling Check err at every level (Node convention) One .catch() at the end try/catch, like sync code
Parallel work Manual counters Promise.all, allSettled, race, any Same combinators, awaited
Guaranteed to be async Depends on the API Yes, .then callbacks always run as microtasks Yes
Where you still meet it Event listeners, Node streams, older libraries Library code, combinators Most new application code

The same job written three ways

Imagine three async operations: load a user, load their orders, load the invoice for an order. Below, a tiny fake database uses Node-style callbacks with a 10 ms delay, and util.promisify turns each function into a promise-returning version. Everything ran in Node 22 as one ES module.

import { promisify } from 'node:util';
const db = {
  getUser(id, cb) { setTimeout(() => cb(null, { id, name: 'Ada' }), 10); },
  getOrders(user, cb) { setTimeout(() => cb(null, [{ id: 'o1' }, { id: 'o2' }]), 10); },
  getInvoice(order, cb) {
    setTimeout(() => order.id === 'o1'
      ? cb(null, { pdf: 'o1.pdf' })
      : cb(new Error(`no invoice for ${order.id}`)), 10);
  },
};
JavaScript

Callbacks

db.getUser(1, (err, user) => {
  if (err) return console.error(err.message);
  db.getOrders(user, (err, orders) => {
    if (err) return console.error(err.message);
    db.getInvoice(orders[0], (err, invoice) => {
      if (err) return console.error(err.message);
      console.log('callbacks:', user.name, invoice.pdf);
    });
  });
});
JavaScript

Every step adds a level of indentation and a repeated if (err) check. That’s the shape people call callback hell. The nesting itself isn’t the worst part: forget one if (err) return and the next step runs with undefined. There’s also no value to hold on to. You can’t pass “the eventual invoice” to another function; you can only pass more callbacks.

Promise chain

const getUser = promisify(db.getUser);
const getOrders = promisify(db.getOrders);
const getInvoice = promisify(db.getInvoice);

getUser(1)
  .then((user) => getOrders(user))
  .then((orders) => getInvoice(orders[1]))
  .then((invoice) => console.log('promises:', invoice.pdf))
  .catch((err) => console.log('promises caught:', err.message));
JavaScript

The chain is flat because each .then that returns a promise waits for it before calling the next one. This version deliberately asks for the invoice of o2, which fails, and one .catch at the end handled it. A rejection skips every .then below it until it reaches a handler, much like an exception unwinding a call stack. The weak spot is sharing values: notice the final step can’t see user without nesting or carrying it along manually.

async/await

async function firstInvoice(id) {
  const user = await getUser(id);
  const orders = await getOrders(user);
  const invoice = await getInvoice(orders[0]);
  return `${user.name} ${invoice.pdf}`;
}
console.log('async/await:', await firstInvoice(1));
JavaScript

Output from the full module:

callbacks: Ada o1.pdf
promises caught: no invoice for o2
async/await: Ada o1.pdf
Text

The async version reads top to bottom, every earlier variable stays in scope, and if any await rejects, the function’s promise rejects with that error. Wrap the awaits in try/catch to handle it locally. Top-level await (used on the last line) works only in ES modules.

What actually runs when

All three are non-blocking. While the timers are pending, the event loop is free to handle other work. Here’s the ordering detail that trips people up:

console.log('1 sync');
setTimeout(() => console.log('4 timeout'), 0);
Promise.resolve().then(() => console.log('3 microtask'));
(async () => { console.log('2 async fn body runs synchronously'); })();
JavaScript
1 sync
2 async fn body runs synchronously
3 microtask
4 timeout
Text

An async function runs synchronously until its first await. Promise callbacks are microtasks, which run as soon as the current script finishes, before the timer task. Callback APIs don’t make that promise: some call you back synchronously, some later, and mixing both in one API causes ordering bugs. The event loop guide covers the queues in depth.

Mistakes interviewers listen for

Accidentally sequential awaits. Two independent requests awaited one after another take the sum of their times. In a test with two 100 ms waits, back-to-back awaits took about 200 ms, while await Promise.all([wait(100), wait(100)]) took about 100 ms. Start independent work together, then await the combined promise. Promise combinators explains which one to pick when some calls might fail.

Expecting try/catch to catch a callback’s throw. This doesn’t work:

try {
  setTimeout(() => { throw new Error('thrown in callback'); }, 0);
} catch (e) { console.log('never printed'); }
JavaScript

By the time the callback throws, the try block finished long ago. In Node the error surfaced as an uncaughtException, which would crash the process without a handler. This is the main reason the err-first convention exists, and the reason promises were such an improvement: a throw inside a .then callback becomes a rejection you can catch.

return vs return await inside try. Look at these two:

const fail = async () => { throw new Error('boom'); };
async function withoutAwait() { try { return fail(); } catch { return 'caught'; } }
async function withAwait() { try { return await fail(); } catch { return 'caught'; } }
JavaScript

withAwait() resolved to 'caught'. withoutAwait() rejected with boom, because returning the promise hands it straight to the caller and the function leaves the try block before the promise rejects. Outside a try, return await is redundant; inside one, it changes behaviour.

Forgetting that await in forEach doesn’t wait. The callback is async, but forEach ignores the promises it returns. See async/await in loops for the patterns that do work.

Unhandled rejections. A promise that rejects with no handler doesn’t stay silent forever. Since Node 15, the default is to crash the process. Every chain needs a .catch or an awaiting try somewhere.

How the choice plays out in real code

New application code almost always uses async/await with promise-based APIs: fetch, fs/promises, database drivers. You’ll still write callbacks for things that happen many times rather than once, like addEventListener, emitter.on and stream data events, because a promise settles only once. When you need to adapt an old callback API, util.promisify handles the err-first shape in Node, or you wrap it in new Promise((resolve, reject) => ...) yourself. Promise chains still show up in library code and in short expressions such as fetch(url).then((r) => r.json()).

Practice question: convert a callback API

You’re given legacyRead(path, cb), which calls cb(err, data). Write readAsync(path) that returns a promise, then use it to read two files in parallel and log both results.

Answer with reasoning

function readAsync(path) {
  return new Promise((resolve, reject) => {
    legacyRead(path, (err, data) => (err ? reject(err) : resolve(data)));
  });
}

async function main() {
  try {
    const [a, b] = await Promise.all([readAsync('a.txt'), readAsync('b.txt')]);
    console.log(a, b);
  } catch (err) {
    console.error('read failed:', err.message);
  }
}
JavaScript

The wrapper maps the two callback outcomes onto the promise’s two outcomes. A promise settles once, so if a buggy legacy API calls back twice, the second call is ignored, which is a quiet safety net worth mentioning. Both reads start before anything is awaited, so they overlap. Promise.all rejects as soon as either read fails; if you needed both outcomes regardless, Promise.allSettled would be the better choice. In Node specifically, promisify(legacyRead) does the same as the hand-written wrapper.

Where to go next

More in JavaScript

esc