From callbacks to async/await
What a promise actually is, why await only pauses the function it is in, and the try/catch that never catches.
After this lesson you can
- Explain what state a promise is in and how it gets there
- Say why an unhandled rejection is different from a thrown error
- Spot a promise that was never awaited
A promise is an object representing a value that is not ready yet. It is always in exactly one of three states: pending, fulfilled with a value, or rejected with a reason — and once it leaves pending, it never changes state again.
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
wait(100).then(() => console.log("done"));
async/await is syntax over the same promises — it does not replace
them, it makes them readable.
async function load() {
const res = await fetch("/api/users/1");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
An async function always returns a promise, whatever you return
inside it. await pauses that function until the promise settles; it
does not pause the rest of the program, which keeps running.
Try it
function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms));} export async function run() { const sequentialStart = Date.now(); await wait(60); await wait(60); await wait(60); const sequential = Date.now() - sequentialStart; const parallelStart = Date.now(); await Promise.all([wait(60), wait(60), wait(60)]); const parallel = Date.now() - parallelStart; return { sequentialMs: Math.round(sequential / 10) * 10, parallelMs: Math.round(parallel / 10) * 10, };}The try/catch that never catches
try {
sendWelcomeEmail(user.email); // no await
} catch (err) {
logger.warn(err); // never runs
}
try only guards the synchronous part of its block. The call
returns a promise immediately, the block finishes, and the rejection
arrives later with nothing on the stack to catch it — an unhandled
rejection, which under Node's default policy can crash the process.
The fix is await:
try {
await sendWelcomeEmail(user.email);
} catch (err) {
logger.warn(err); // now it actually runs
}
The habit worth building: every promise is awaited, returned, or explicitly discarded with a comment saying why it is fire-and-forget. A bare promise-returning call sitting on its own line as a statement always deserves a second look.
Sequential awaits waste the wait
const a = await loadUser(id); // waits
const b = await loadOrders(id); // then waits again, even though
// loadOrders never needed `a`
If two awaited calls do not depend on each other, awaiting them one
after another adds their times together for no reason. Promise.all
starts both immediately and waits once:
const [a, b] = await Promise.all([loadUser(id), loadOrders(id)]);
Promise.all rejects as soon as any one promise rejects, and cancels
nothing — the others keep running in the background. Promise.allSettled
is for when you want every outcome, failures included, rather than the
first failure stopping everything.
Try it yourself
2 visible tests · 2 hidden testsattempt(id) is given: it resolves to { id, ok: true } for an even
id, and rejects with `failed: ${id}` for an odd one. Implement
runAll(ids), which calls attempt for every id in parallel and
returns an array of results in the same order — { id, ok: true } for
a success, { id, ok: false } for a failure. One failure must not stop
the others from being reported.
runAll([1,2,3,4])runAll([2,4,6])
Sign up to check the hidden tests and save your progress. Sign up