#Callbacks → Promises → async/await
AddedJavaScript runs your code on one thread: one call stack, one thing at a time. If waiting for a network response blocked that thread, the whole page would freeze — no clicks, no scrolling, no painting — until the response came back. So slow work (network, timers, disk) is handed to the environment (the browser, or libuv in Node), which does the waiting in the background, and your code is told when the result is ready.
How you're told has gone through three generations, each built on the one before. Promises replaced "pass me a function to call later" with an object you get back immediately and can pass around. async/await is syntax on top of promises — under the hood it's still promises.
A useful mental model: a callback is "call me when it's done". A promise is the buzzer you get at a food counter — you get it straight away, before the food exists, and you can hand it to someone else or check it later. await is "stand here until the buzzer goes off" — except only this function stands still; the rest of the program keeps working.
// 1. Callbacks — nesting gets ugly fast ("callback hell")
getUser(id, (err, user) => {
if (err) return handle(err);
getOrders(user, (err, orders) => {
if (err) return handle(err);
render(orders);
});
});
// 2. Promises — flat chains, one error handler
getUser(id)
.then((user) => getOrders(user))
.then(render)
.catch(handle);
// 3. async/await — promises that read like synchronous code
try {
const user = await getUser(id);
render(await getOrders(user));
} catch (err) {
handle(err);
}- Callbacks.
getUser(id, cb)starts the lookup and returns immediately; later the environment callscb(err, user). Node's convention is error-first: the first argument is an error ornull. To fetch orders you must nest the next call inside the first callback, because that's the only placeuserexists. Every extra step adds a level of indentation, and every level must repeatif (err) return handle(err)— forget one and that error silently disappears. - Promises.
getUser(id)now returns a promise straight away. The first.thencallback returns the promise fromgetOrders(user), so the chain waits for it and hands the orders torender. The chain stays flat however many steps you add, and a rejection at any step skips the remaining.thens and lands in the single.catch(handle). - async/await.
await getUser(id)pauses this function until the promise settles, then gives you the fulfilled value — or, if it rejected, throws the reason as an exception. That's why one ordinarytry/catchcovers errors from both calls.render(await getOrders(user))waits for the orders, then callsrenderwith them. - All three do the same work in the same order, and none of them blocks the thread — only the way you write "and then" changes.
awaitis only allowed inside anasyncfunction or at the top level of an ES module, so the third snippet assumes one of those.
Where each style still belongs:
- Callbacks are still right for things that happen many times —
addEventListener,setInterval, streamdataevents. A promise represents a single future value and can settle only once. - Old callback APIs can be turned into promises with
util.promisifyin Node, or by wrapping them innew Promiseyourself. Most Node core modules already ship a promise version (node:fs/promises,node:timers/promises). - async/await is the default for sequential async logic in modern code; plain
.thenis still handy for a one-off "do this when it's ready" without making the surrounding functionasync.