async/await and Error Handling
The Core Problem#
A promise chain works, but once you add conditions, loops or variables that must be shared between steps, the .then wiring gets hard to read. What we really want is to write "check the PIN, then read the balance, then dispense cash" as ordinary lines, and use an ordinary try/catch when something fails.
That is what async and await give you. In this post you will build an ATM withdrawal flow, see exactly which lines run and which are skipped when a step fails, and learn how to run independent calls in parallel to make your code faster.
By the end you will be able to:
- Explain what
asyncreturns and whatawaitactually pauses - Predict which lines are skipped when an awaited promise rejects
- Choose between running calls one by one and running them together with
Promise.all
Mental Model & Intuition#
async before a function means the function always returns a promise. If it returns a value, the promise is fulfilled with that value. If it throws, the promise is rejected with that error.
await inside an async function is like putting a bookmark in the function. JavaScript pauses that function only, not the whole program, and goes off to run other code. When the promise settles, JavaScript comes back to the bookmark.
- If the promise is fulfilled,
awaitgives you the value. - If the promise is rejected,
awaitthrows the error at that exact line.
That second point is the key idea. Because a rejection becomes a normal thrown error, a normal try/catch works. And like any thrown error, it skips the rest of the try block.
await only works inside an async function. The one exception is top level await in ES module files, which you will see when we cover modules.
Step-by-Step Walkthrough#
Here is what happens when someone enters the wrong PIN in the withdraw function from the code section.
| Step | State | Why |
|---|---|---|
| 1 | withdraw("9999", 2000) starts and enters the try block |
An async function runs normally until its first await |
| 2 | await verifyPin(pin) pauses withdraw |
The rest of the program is free to run |
| 3 | verifyPin throws "Wrong PIN", so its promise is rejected |
After 300 ms, the check fails |
| 4 | await throws the error at that line |
A rejected promise becomes a thrown error |
| 5 | Control jumps to catch, and getBalance and dispense never run |
Lines after a failing await are skipped |
| 6 | finally runs and prints "Card returned" |
finally runs whether or not there was an error |
Code Implementation#
1. The ATM flow#
The wait helper is a promise that fulfills after some milliseconds. It stands in for a real network or database call.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function verifyPin(pin) {
console.log("Checking PIN...");
await wait(300);
if (pin !== "1234") throw new Error("Wrong PIN");
}
async function getBalance() {
console.log("Reading balance...");
await wait(300);
return 5000;
}
async function dispense(amount) {
console.log("Counting cash...");
await wait(300);
return `Collect cash: ${amount}`;
}
Now the withdrawal itself. Compare the two styles. They behave the same.
Run it three ways from the terminal. The values after the file name come from process.argv, and Number() turns the amount from a string into a number.
node atm.js 1234 2000 (everything works):
Checking PIN...
Reading balance...
Counting cash...
Collect cash: 2000
Card returned
node atm.js 9999 2000 (wrong PIN, so the balance and cash steps are skipped):
Checking PIN...
Failed: Wrong PIN
Card returned
node atm.js 1234 9000 (balance too low, so only the cash step is skipped):
Checking PIN...
Reading balance...
Failed: Insufficient balance
Card returned
2. One by one vs together#
Loading a dashboard needs a profile, orders and alerts. None of them depends on the others, and each takes one second.
const getProfile = async () => { await wait(1000); return "profile"; };
const getOrders = async () => { await wait(1000); return "orders"; };
const getAlerts = async () => { await wait(1000); return "alerts"; };
async function oneByOne() {
console.time("one by one");
const profile = await getProfile();
const orders = await getOrders();
const alerts = await getAlerts();
console.timeEnd("one by one");
return [profile, orders, alerts];
}
async function together() {
console.time("together");
const result = await Promise.all([getProfile(), getOrders(), getAlerts()]);
console.timeEnd("together");
return result;
}
(async () => {
await oneByOne();
console.log(await together());
})();
Output:
one by one: 3.004s
together: 1.002s
[ 'profile', 'orders', 'alerts' ]
Promise.all starts all three calls at once, waits for all of them, and returns the results in the same order you listed them. Three seconds became one.
Edge Cases & Gotchas#
Forgetting await gives you a promise, not a value.
async function demo() {
const name = getName(); // forgot await
console.log(name);
console.log(await name);
}
Output:
Promise { <pending> }
Sai
If you ever see Promise { <pending> } where you expected data, a missing await is the first thing to check.
await inside forEach does not wait. forEach ignores the promises returned by its callback.
[1, 2, 3].forEach(async (n) => {
await wait(100);
console.log("inside forEach:", n);
});
console.log("forEach finished");
Output:
forEach finished
inside forEach: 1
inside forEach: 2
inside forEach: 3
To wait for each item in order, use a for...of loop. To run them all at once, use Promise.all with map.
return without await inside try skips your catch. The function hands back the promise before it settles, so the error escapes.
const failing = async () => { throw new Error("DB down"); };
async function noAwait() {
try {
return failing(); // no await
} catch (err) {
return "caught inside";
}
}
async function withAwait() {
try {
return await failing(); // with await
} catch (err) {
return "caught inside";
}
}
noAwait() rejects with "DB down", and the catch never runs. withAwait() resolves with "caught inside". Inside a try, write return await.
Promise.all fails fast. If any one promise rejects, Promise.all rejects right away and you lose the other results. When you want every result, successful or not, use Promise.allSettled:
Promise.allSettled([
wait(100).then(() => "ok"),
Promise.reject(new Error("failed")),
]).then((r) => console.log(r.map((x) => x.status)));
Output:
[ 'fulfilled', 'rejected' ]
Pro Tip / Performance Insight#
Ask one question before every await: does the next call need the result of this one?
- Yes: the PIN must be verified before the cash is dispensed, so use
awaitone by one. - No: profile, orders and alerts are independent, so start them together with
Promise.all.
Unnecessary one by one awaits are the most common reason a backend endpoint is slower than it needs to be. Measure with console.time and console.timeEnd as shown above, and you will see the difference immediately.
Next Steps & Practice#
Try these on your own before moving on:
- Rewrite your promise chain from the previous post using
async/awaitandtry/catch. Confirm it prints the same output for a correct OTP and a wrong OTP. - Add a fourth step to
withdraw: a daily limit check that throws if the amount is above 10000. Which lines get skipped when it fails? - Run three independent 1 second tasks one by one, then with
Promise.all, and compare the times. Then make one of them reject and see whatPromise.alldoes. - Remove an
awaiton purpose and see what gets printed.
Read next: APIs with fetch and axios, where these exact patterns are used to call a real server.