Asynchronous JavaScript · Lesson 53 of 77 · concept
Async mistakes that bite
About 55 minutes.
By the end of this lesson you can
- Spot a missing `await` from its symptom — a Promise where a value was expected.
- Explain why `forEach` with an async callback does not wait, and name two shapes that do.
- Write asynchronous code whose failures are loud rather than silent.
- Explain why a `try`/`catch` around a call you did not await catches nothing.
- Decide whether an `await` inside a loop is a bug or the point.
Read, not run. The sandbox on this site has no promise callbacks (nothing after a .then runs), timers such as setTimeout, so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.
Every one of these has the same root
Explanation
Six mistakes follow, and they look unrelated until you notice they are all the same error: treating a promise as though it were the value inside it, or assuming something waited when nothing did.
None of them is a syntax error. That is what makes them expensive. The program runs, the console stays quiet, and the wrong thing happens somewhere far from the line that caused it.
Read this lesson as a checklist you can run over your own code, not as trivia. Each section names the symptom first, because the symptom is what you will actually meet.
1. The missing await
A common mistake
Symptom: a variable holds `[object Promise]`, or a property of it is `undefined`, or arithmetic on it produces `NaN`, or `if (thing)` is true when it should be false.
Cause: you called an async function without awaiting it, so you are holding the promise rather than its value. Because a promise is an object, every one of those operations quietly does something instead of failing loudly. Every object is truthy, so `if (user)` is true even when the user does not exist. Adding a number to it concatenates onto "[object Promise]". Comparing it with `>` gives `false`, because the comparison ends up with `NaN`.
Fix: `await` it, or return it and let the caller await. The habit that prevents it is to know which of your own functions are async — a naming convention helps, and a linter rule for floating promises helps more.
2. forEach does not wait
A common mistake
Symptom: a loop that is supposed to process items one at a time finishes instantly, and the totals afterwards are empty or wrong.
Cause: `array.forEach(async (item) => { await work(item); })` looks like a loop with waiting in it, but the async callback returns a promise and `forEach` throws that return value away. MDN states it directly: `forEach` expects a synchronous function and does not wait for promises. Every iteration starts, none is waited for, and `forEach` returns `undefined` before any of them finish.
Fix, when order matters or you want one at a time: `for (const item of items) { await work(item); }`. A `for...of` loop sits inside your async function, so `await` genuinely pauses it.
Fix, when they can all run at once: `await Promise.all(items.map(work))`. `map` keeps the returned promises instead of discarding them, and `Promise.all` waits for the lot.
This one is worth checking for by eye whenever you see `async` immediately after `forEach(`. The same trap applies to `map` without a `Promise.all` around it — you get an array of promises, which is at least a visible symptom.
3. try/catch that catches nothing
A common mistake
Symptom: an unhandled rejection appears in the console even though the call is plainly inside a `try` block.
Cause: `try { work(); } catch (error) { ... }` where `work` is async. The `try` block finishes the instant `work` returns its promise, long before that promise rejects. By the time the failure exists, this `try` is over — and a rejection is not a thrown exception, so it was never a candidate for that `catch` anyway.
Fix: `try { await work(); } catch (error) { ... }`. The `await` is what turns the rejection into a real exception at a moment when the `try` block is still active.
The subtler cousin is `return work();` inside a `try`. The promise leaves the function before it settles, so this function's `catch` never sees the failure — it surfaces in the caller instead. `return await work();` keeps the waiting inside the `try`.
4. The rejection nobody is listening to
A common mistake
Symptom: "UnhandledPromiseRejection" in the console, or in Node a process that exits with a non-zero code for no obvious reason.
Cause: a promise was created and nothing ever attached a handler or awaited it. MDN calls this a floating promise, and the classic version is a `.then` handler that starts work without returning it: `.then((url) => { fetch(url); })`. The inner promise is dropped on the floor, so nothing waits for it and nothing catches it.
Fix: every promise you create should end up awaited, returned, or explicitly handled with `.catch`. "Fire and forget" is a legitimate choice occasionally, but it should be written down — `void doThing().catch(reportError)` says you meant it.
Do not silence it with an empty `catch`. `catch (error) {}` converts a loud failure into a silent one, which is strictly worse than the crash: the program carries on with bad data and the trail is gone.
5. await in a loop, when nothing depends on the last answer
A common mistake
Symptom: something that should take a second takes a minute, and the network panel shows requests going out one after another in a neat staircase.
Cause: `for (const id of ids) { results.push(await loadOne(id)); }` where the fifty loads have nothing to do with each other. Each iteration waits for the previous one for no reason at all.
Fix: `const results = await Promise.all(ids.map(loadOne));` — start them all, wait once.
And the important counterweight: this is not always a bug. Awaiting in a loop is correct when each step needs the previous result, when a rate limit forbids parallel calls, or when firing hundreds of requests at once would overwhelm the server. Then it is a deliberate decision, and it is worth a one-line comment saying so — otherwise the next reader will "fix" it.
6. Mixing .then and await in one function
A common mistake
Symptom: errors are caught in one path and escape in another, and nobody can say why by reading the function.
Cause: half the function uses `await` inside a `try`/`catch` and half uses `.then(...).catch(...)`. The two are compatible — an async function returns a promise and `await` accepts one — but the error paths are now in two different places, and it is easy to leave a `.then` branch with no `.catch` at all.
Fix: pick one style per function. Use `await` in code you are writing; use `.then` when you are handing a handler to somebody else's API. Do not alternate line by line.
The checklist
Recap
Before you call an asynchronous function finished, run these five questions over it. Is every call to an async function awaited, returned, or deliberately handled with `.catch`? Is every `await` inside a `try` that is still open when the promise settles? Does any `forEach` contain an `async` callback? Does any `await` sit inside a loop where the iterations are independent? And can I say, for each promise this function creates, who is listening when it fails?
Every one of those questions is answerable by reading, without running anything. That is the point: these bugs do not announce themselves at runtime, so the check has to happen at reading time.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
What a missing await looks like
The single most useful example in this module, and it runs here in full because nothing is awaited — which is exactly the bug. Four different operations on the same promise, four different flavours of wrong, no errors.
1async function priceOf(item) {2 return item === "coffee" ? 3 : 0;3}4 5const price = priceOf("coffee");6 7console.log("typeof price is " + typeof price);8console.log("price + 1 is " + (price + 1));9console.log("price > 2 is " + (price > 2));10console.log("if (price) treats it as " + (price ? "truthy" : "falsy"));11 - line 5
The bug, in one line. `priceOf` is async, so this is a promise. Everything below is a consequence.
- line 8
"[object Promise]1" — the promise is converted to a string and concatenated. No error, no warning.
- line 9
`false`, because comparing an object with a number produces NaN and every comparison with NaN is false. Note that `price > 2` and `price <= 2` would BOTH be false.
- line 10
Truthy, always. Every object is truthy, so a `if (price)` guard cannot tell a real price from a pending one — this is how bad data gets past validation.
What it prints
typeof price is objectprice + 1 is [object Promise]1price > 2 is falseif (price) treats it as truthy
forEach, for...of and Promise.all
The same intent written three ways: one that silently does not wait, one that waits for each in turn, and one that waits for all at once. MDN's own documentation warns about the first.
1// BROKEN: forEach discards the promise each callback returns.2async function totalBroken(ids) {3 let total = 0;4 ids.forEach(async function (id) {5 total = total + (await loadAmount(id));6 });7 return total; // always 0 — no iteration has finished yet8}9 10// Correct, one at a time. Use when order matters or a rate limit applies.11async function totalSequential(ids) {12 let total = 0;13 for (const id of ids) {14 total = total + (await loadAmount(id));15 }16 return total;17}18 19// Correct, all at once. Use when the loads are independent.20async function totalConcurrent(ids) {21 const amounts = await Promise.all(ids.map(loadAmount));22 return amounts.reduce(function (sum, amount) {23 return sum + amount;24 }, 0);25}26 - line 4
`forEach` expects a synchronous function and does not wait for promises — MDN states this on the forEach page. The callback's promise is dropped.
- line 7
Returns 0. Not sometimes, not usually — every time, because `forEach` returned before any `await` resumed.
- line 13
`for...of` is inside the async function, so `await` genuinely pauses this loop between iterations.
- line 21
`map` keeps the promises rather than discarding them, and `Promise.all` waits for all of them — results in input order, so the reduce is deterministic.
This one does not run here`loadAmount` is an asynchronous stand-in that does not exist here, and no `await` can resume without a job-queue drain. Ironically the broken version is the only one that would return at all.
The try block that was already over
Three versions of the same guard. Only two of them work, and the broken one is the one that looks most like ordinary synchronous code.
1// BROKEN: the try block ends before the promise rejects.2async function saveBroken(record) {3 try {4 persist(record);5 } catch (error) {6 console.log("never printed: " + error.message);7 }8}9 10// Correct: await turns the rejection into an exception, in time.11async function saveAwaited(record) {12 try {13 await persist(record);14 } catch (error) {15 console.log("saved failed: " + error.message);16 }17}18 19// Also correct, and the reason `return await` exists.20async function saveReturned(record) {21 try {22 return await persist(record);23 } catch (error) {24 console.log("save failed: " + error.message);25 return null;26 }27}28 - line 4
`persist` returns a promise immediately. The try block finishes on line 5, and the rejection happens later with nothing listening — an unhandled rejection.
- line 13
The `await` keeps this function suspended INSIDE the try block until the promise settles, so a rejection is thrown where the catch can see it.
- line 22
`return await` rather than `return`. A plain `return persist(record);` would hand the promise to the caller before this catch could ever run.
This one does not run here`persist` is an asynchronous stand-in, and the sandbox cannot resume any of these functions past their `await` without a job-queue drain.
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
`total` is an async function returning 10, and the call on line 5 has no `await`. Predict all three lines.
async function total() { return 10;}const value = total();console.log(typeof value);console.log(value > 5);console.log(Boolean(value));
`ids.forEach(async (id) => { total += await loadAmount(id); }); return total;` — what does this return?
`try { persist(record); } catch (error) { ... }` where `persist` is async and rejects. What happens?
When is `for (const id of ids) { await loadOne(id); }` the right thing to write rather than a performance bug?
No exercise in this lesson
Every mistake here is only observable once promise callbacks run, and this sandbox never drains the job queue (spec item RP-1). The one symptom that is observable — a promise where a value was expected — is shown by the first worked example, which does run.
Worth remembering
- What is the fingerprint of a missing `await`?
A promise where a value should be: `typeof` is "object", string concatenation gives "[object Promise]", every numeric comparison is false because the comparison yields NaN, and `if (x)` is always true because objects are truthy.
- Why does `forEach` with an async callback not wait, and what do you use instead?
forEach expects a synchronous function and discards the callback's return value, so the promises are dropped. Use `for...of` with `await` for one at a time, or `await Promise.all(items.map(fn))` when they are independent.
- Why does `try { work(); } catch {}` catch nothing when `work` is async?
The call returns a promise immediately and the try block ends before it rejects — and a rejection is not a thrown exception anyway. `await work()` inside the try is what converts the rejection into an exception while the block is still open.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: The chaining section on returning versus dropping a promise (a handler that starts a promise without returning it leaves it "floating"), and the Timing section's guarantee that then() callbacks are never called synchronously.
MDN — Array.prototype.forEach()
Check there: The explicit note that forEach expects a synchronous function and does not wait for promises, with the worked example whose sum stays 0.
Check there: That a rejected promise makes the await expression throw the rejection reason.
MDN — Promise.prototype.catch()
Check there: That catch() is shorthand for then(undefined, onRejected) and returns a new promise.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown