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);
},
};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);
});
});
});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));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));Output from the full module:
callbacks: Ada o1.pdf
promises caught: no invoice for o2
async/await: Ada o1.pdfThe 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'); })();1 sync
2 async fn body runs synchronously
3 microtask
4 timeoutAn 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'); }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'; } }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);
}
}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.