HomeProjectsBlogContact

Async/Await Mistakes That Quietly Break JavaScript Apps

Missing awaits, await inside forEach, accidental waterfalls and swallowed errors. The async/await bugs that pass code review and fail in production, with the fix for each one.

Async/Await Mistakes That Quietly Break JavaScript Apps

async/await made asynchronous JavaScript readable. It also made asynchronous bugs look like normal code, which is exactly why they slip through review. These are the mistakes that show up again and again in real codebases, from beginner projects to large production apps.

1. Forgetting await

async function saveUser(user) {
  db.users.insert(user)        // no await
  return { ok: true }
}

The function returns "ok" before the insert finishes. If the insert fails, nobody hears about it. Worse, the bug is intermittent, because it depends on timing.

Fix: await every promise you depend on. Turn on the @typescript-eslint/no-floating-promises rule (or require-await style rules in plain JS). It catches this automatically.

2. await inside forEach

ids.forEach(async (id) => {
  await sendEmail(id)
})
console.log('All emails sent')   // they aren't

forEach ignores the promises your callback returns. The log runs immediately, and errors become unhandled rejections.

Fix: use for...of for sequential work, or Promise.all with map for parallel work:

for (const id of ids) {
  await sendEmail(id)                       // one at a time
}

await Promise.all(ids.map(id => sendEmail(id)))  // all at once

3. Accidental waterfalls

const user = await getUser(id)
const orders = await getOrders(id)
const settings = await getSettings(id)

Three independent requests, run one after another. If each takes 200ms, you've spent 600ms when you could have spent 200.

Fix: start them together.

const [user, orders, settings] = await Promise.all([
  getUser(id),
  getOrders(id),
  getSettings(id),
])

Only keep things sequential when one call genuinely needs the result of another.

4. Promise.all when you wanted partial success

Promise.all rejects as soon as any promise rejects. If you're loading five dashboard widgets and one API is down, you lose all five.

Fix: Promise.allSettled gives you every result, success or failure:

const results = await Promise.allSettled(widgets.map(loadWidget))
const loaded = results.filter(r => r.status === 'fulfilled').map(r => r.value)

5. Unbounded parallelism

The flip side of mistake 3: Promise.all over 10,000 items fires 10,000 requests at once. You'll hit rate limits, exhaust database connections or get your IP blocked.

Fix: process in batches, or use a small concurrency limiter such as p-limit:

import pLimit from 'p-limit'
const limit = pLimit(5)
await Promise.all(items.map(item => limit(() => processItem(item))))

6. try/catch that doesn't catch

try {
  return fetchData()      // returned, not awaited
} catch (err) {
  // never runs for async failures
}

Without await, the promise rejects after the try block has already exited. Use return await inside try blocks.

7. Swallowing errors

try {
  await chargeCard(order)
} catch (e) {
  console.log(e)
}

The payment failed, the log scrolled past, and the code carries on as if everything were fine. Catch errors only where you can do something useful: retry, show a message, fall back. Otherwise let them propagate to a handler that logs and reports them properly.

8. Not checking response.ok with fetch

fetch only rejects on network failure. A 404 or 500 resolves normally.

const res = await fetch('/api/user')
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
const data = await res.json()

This one causes a lot of Cannot read properties of undefined errors three functions later.

9. No timeout

A hung request can wait a very long time. Give important calls a deadline with AbortController:

const res = await fetch(url, { signal: AbortSignal.timeout(8000) })

10. Race conditions in the UI

The user types "re", then "rea", then "react". Three search requests fly out, and the slowest one (for "re") arrives last and overwrites the correct results.

Fix: cancel the previous request when a new one starts, or ignore responses that are no longer current. In React, the effect cleanup function is the natural place for that (more on effects here).

Make these bugs visible

  • Listen for unhandled rejections in Node (process.on('unhandledRejection', ...)) and log them loudly.
  • Use an error tracker like Sentry in production so silent failures stop being silent.
  • Lint for floating promises. Of every rule here, it pays for itself fastest.

When something does break, a systematic approach beats guesswork: how to debug code systematically.

FAQ

Is async/await faster than promises?

No. It's syntax on top of promises. Performance depends on how you structure the calls, especially whether independent work runs in parallel.

Should I use Promise.all or for...of?

Use Promise.all for independent tasks that can run in parallel (with a limit for large lists). Use for...of when order matters or each step depends on the previous one.

Why is my async function returning a Promise instead of the value?

Every async function returns a promise. You need await (or .then) where you call it to get the resolved value.

Keep reading

More from the workbench

All articles →