async and await
async and await are not a replacement for promises. They are a way of
writing promises that reads like ordinary code — and that is the whole benefit,
which is larger than it sounds.
The same chain, twice
With .then:
function showDelivery() {
return getCustomer(1)
.then((customer) => getOrder(customer.id))
.then((order) => getSlot(order.id))
.then((slot) => console.log(slot.time))
.catch((error) => console.log('Failed:', error.message));
}
With async/await:
async function showDelivery() {
try {
const customer = await getCustomer(1);
const order = await getOrder(customer.id);
const slot = await getSlot(order.id);
console.log(slot.time);
} catch (error) {
console.log('Failed:', error.message);
}
}
Named variables instead of chained arrow arguments, and module 5's
try/catch works — the thing callbacks could not do at all.
The two rules
await pauses until a promise settles and gives you the value.
await only works inside an async function — or at the top level of a
module, which is why a <script type="module"> can await and a plain script
cannot. That is the odd SyntaxError module 1 mentioned.
And one consequence people miss:
An async function always returns a promise, whatever you return inside it.
async function getName() {
return 'Priya';
}
console.log(getName());
console.log(await getName());
Promise { 'Priya' }
Priya
So async functions are contagious in a useful way: calling one gives you a
promise, which you await, which requires you to be async too. The chain ends
at an event handler or the top level of a module.
A throw inside an async function becomes a rejection of that promise:
async function thrower() {
throw new Error('inside async');
}
thrower().catch((e) => console.log(e.message));
inside async
Which is why try/catch around an await works — it is catching a rejection
converted back into a thrown error.
The bug you will definitely write
Forgetting await:
async function totalPlates() {
return 8;
}
const total = totalPlates();
console.log(total);
console.log(total * 80);
Promise { 8 }
NaN
NaN, not 640. A promise is an object; multiplying it gives NaN — module
2's coercion, arriving through a door you did not expect.
The symptoms to recognise instantly:
| Symptom | Cause |
|---|---|
Promise { <pending> } in a log |
Missing await |
NaN from arithmetic that looks right |
Missing await |
undefined from a property that exists |
Missing await |
.map is not a function on an array |
Missing await |
Seeing Promise { anywhere you expected data means a missing await.
The other bug: try/catch that catches nothing
try {
risky();
} catch (error) {
console.log('caught');
}
If risky is async and you did not await it, the catch never runs. The
function returned a promise immediately and succeeded at doing so; the rejection
happens later, with nothing watching.
In Node that crashes the process with an unhandled rejection. In a browser you
get Uncaught (in promise) Error in the console and the rest of your handler
carries on as though nothing failed.
try {
await risky();
} catch (error) {
console.log('caught');
}
await is what connects the rejection to your catch.
The performance bug: awaiting in a loop
This is correct and slow:
const orders = [];
for (const id of [1, 2, 3]) {
orders.push(await getOrder(id));
}
Three requests, each waiting for the last. At 200ms on mobile data that is 600ms for work that could take 200.
await in a loop is right only when each step needs the one before. When
they are independent, start them all and wait once:
const orders = await Promise.all([1, 2, 3].map((id) => getOrder(id)));
Measured on two 100ms operations: sequential awaits take about 200ms,
Promise.all about 100ms.
Note the .map callback is not async and has no await — it just calls
getOrder, which returns a promise. Promise.all does the waiting.
map with async, and forEach
const results = [1, 2, 3].map(async (id) => id * 2);
console.log(results[0]);
Promise { 2 }
An async callback returns a promise, so map gives you an array of
promises, not an array of values. That is fine — await Promise.all(results)
gives [2, 4, 6] — but it catches everybody once.
forEach is worse, because it cannot help you at all:
const collected = [];
[1, 2, 3].forEach(async (id) => {
collected.push(await getOrder(id));
});
console.log(collected);
[]
The array is empty. forEach ignores return values, so it does not wait for
anything; it started three operations and moved straight on. The pushes happen
later, after your console.log.
Never use forEach with an async callback. Use for...of with await for
sequential work, or map plus Promise.all for parallel. This is the single
most common async bug in real code.
Mixing the two styles
They are the same promises, so you can mix freely:
const order = await getOrder(1).catch(() => null);
That is often the neatest way to say "try this, and I do not mind if it fails"
without a four-line try/catch.
And at the edge of your code — an event handler — .catch is how you stop a
rejection escaping:
button.addEventListener('click', () => {
loadOrders().catch(showError);
});
An async event handler that throws has nowhere to report to; addEventListener
does not look at the returned promise. Either make the handler async with its
own try/catch, or call .catch on the promise. Never neither.
When to use which
| Situation | Reach for |
|---|---|
| A sequence of dependent steps | async/await |
| One promise, one thing to do with it | .then is fine |
| Several in parallel | Promise.all with await |
| "Do not care if it fails" | await ... .catch(() => fallback) |
| Inside an event handler | async handler, or .catch on the call |
Default to async/await. Reach for .then when a single call reads better
as one line.
Check your work
await pauses until a promise settles, and only works inside an async
function or at the top level of a module.
An async function always returns a promise, and a throw inside it becomes
a rejection.
Forgetting await gives you the promise object. Arithmetic on it is NaN,
properties are undefined, and array methods are missing. Promise { in a log
means a missing await.
try/catch around a non-awaited async call catches nothing — the rejection
arrives later, unhandled. In Node that crashes the process; in a browser it is
Uncaught (in promise).
Sequential awaits in a loop are as slow as the sum of the parts — about
200ms for two 100ms operations against about 100ms for Promise.all. Use the
loop only when each step depends on the one before.
map with an async callback gives an array of promises. Wrap it in
Promise.all.
forEach with an async callback does not wait for anything and leaves your
array empty. Use for...of or map + Promise.all.
An async event handler needs its own try/catch, or a .catch on the
call.
Practice
- Rewrite a three-step
.thenchain withasync/awaitandtry/catch. - Call an
asyncfunction withoutawaitand log the result. Then multiply it by a number and account for theNaN. - Log an
asyncfunction's return value and confirm it is a promise even though you returned a string. - Throw inside an
asyncfunction and catch it with.catchon the outside. - Write the silent-failure bug. Wrap a non-awaited async call in
try/catch, make it reject, and confirm nothing is caught. Read what your environment says about the unhandled rejection. Then addawait. - Measure the loop bug. Await three 200ms waits in a
for...ofloop withconsole.timearound it. Then do the same withPromise.alland compare. - Write a case where the loop is genuinely correct — each step needing the
previous result — and explain why
Promise.allcannot be used. mapwith anasynccallback and log the array. Then fix it withPromise.all.- Run the
forEachbug. Push awaited values inside aforEachcallback, log the array immediately, and confirm it is empty. Then log it again after a delay and confirm the work did happen — it just could not be waited for. - Write an async click handler that throws, and confirm nothing reports it. Fix it two ways.
- Harder. Write
loadDashboard()that fetches a customer, then in parallel their orders and their delivery slots, and returns one combined object. Exactly oneawaitshould be sequential and one should be aPromise.all. Then make the slots request fail and adjust it so the dashboard still renders with the orders — deciding for yourself whether that calls forallSettledor a.catchwith a fallback.
Next: fetch — where all of this stops being timers and starts being real data
over a real network.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship