跪拜 Guibai
← Back to the summary

The 8 async/await mistakes that keep showing up in code review

Over the past six months I've done roughly 200 code reviews and noticed a pattern: more than half of the bugs are related to async/await. It's not that people can't write it; they write it wrong without realizing it. This article compiles the 8 most common mistakes I've seen, along with the correct approaches — you've probably made at least 3 of them.

A harsh truth first

async/await is the syntax in JavaScript that "looks the simplest but hides the most pitfalls." It makes asynchronous code look synchronous, and that's precisely its greatest danger — because you start writing asynchronous logic with a synchronous mindset and then step into a pile of unexpected traps.

The 8 mistakes below are ranked by how often I encounter them in code reviews, from most to least frequent.


Mistake 1: Using await inside a loop (most frequent)

This is the single most common mistake I see, bar none:

// ❌ Wrong: serial execution, 3 requests take 3x the time
async function getUsers(ids) {
  const users = [];
  for (const id of ids) {
    const user = await fetchUser(id);  // waits for the previous one to finish each time
    users.push(user);
  }
  return users;
}

Three independent requests with no dependencies are forced to queue up. If each request takes 200ms, three requests take 600ms.

// ✅ Correct: parallel execution, total time = the slowest request
async function getUsers(ids) {
  const users = await Promise.all(
    ids.map(id => fetchUser(id))
  );
  return users;
}

The same three requests take only about 200ms when run in parallel.

But note: if there are dependencies between requests (e.g., the second request needs the result of the first), then serial execution is required and you cannot use Promise.all. The criterion is simple: does a later request need the return value of an earlier one?


Mistake 2: Nested try-catch hell

Three or four layers of try-catch inside a single function, with more try-catch inside each catch block:

// ❌ Wrong: nested try-catch, terrible readability
async function handleSubmit(data) {
  try {
    const validated = await validate(data);
    try {
      const result = await submitForm(validated);
      try {
        await sendNotification(result.id);
      } catch (e) {
        console.log('Notification failed', e);
      }
    } catch (e) {
      showError('Submission failed');
    }
  } catch (e) {
    showError('Validation failed');
  }
}

You don't even want to look at this code a second time after writing it.

// ✅ Correct: use a standalone error-handling function, or a unified catch
async function handleSubmit(data) {
  const [validateErr, validated] = await to(validate(data));
  if (validateErr) return showError('Validation failed');

  const [submitErr, result] = await to(submitForm(validated));
  if (submitErr) return showError('Submission failed');

  // Notification failure does not affect the main flow; handle silently
  await sendNotification(result.id).catch(() => {});
}

// Utility function: converts a Promise's resolve/reject into an [err, data] tuple
function to(promise) {
  return promise.then(data => [null, data]).catch(err => [err, null]);
}

This to() utility function is probably my most reused function — 4 lines of code that eliminate all nested try-catch. Put it in your utils and writing async code will feel much smoother from then on.


Mistake 3: Forgetting await

This mistake is so subtle that even testing might not catch it:

// ❌ Wrong: forgot await, deleteUser returns a Promise object
async function removeUser(id) {
  const result = deleteUser(id);  // forgot await!
  console.log(result);  // prints Promise {<pending>}
  return result;
}

An even more dangerous version appears inside conditionals:

// ❌ A more subtle mistake
async function checkPermission(userId) {
  const hasPermission = fetchPermission(userId);  // forgot await
  if (hasPermission) {  // a Promise object is always truthy!
    // this branch always executes; the permission check is completely useless
    doSensitiveOperation();
  }
}

A Promise object is truthy, so if (hasPermission) is always true. You think you've done a permission check, but you actually haven't at all.

// ✅ Correct
async function checkPermission(userId) {
  const hasPermission = await fetchPermission(userId);
  if (hasPermission) {
    doSensitiveOperation();
  }
}

Prevention: enable ESLint's require-await and @typescript-eslint/no-floating-promises rules; your editor will highlight them directly.


Mistake 4: Using async/await inside forEach

This mistake looks similar to Mistake 1, but the nature is completely different — await inside forEach simply does not wait:

// ❌ Wrong: forEach does not wait for await; all requests fire off simultaneously, but you get no results
async function processItems(items) {
  items.forEach(async (item) => {
    await processItem(item);  // forEach does not care about the returned Promise
  });
  console.log('All processing complete');  // this line executes immediately, without waiting at all!
}

forEach does not wait for the Promise returned by its callback. The console.log above will print before all processItem calls have even started.

// ✅ Option 1: use for...of (serial execution)
async function processItems(items) {
  for (const item of items) {
    await processItem(item);
  }
  console.log('All processing complete');  // truly all complete
}

// ✅ Option 2: use Promise.all + map (parallel execution)
async function processItems(items) {
  await Promise.all(items.map(item => processItem(item)));
  console.log('All processing complete');
}

Remember: forEach, map, filter, reduce — none of these array methods wait for await. When you need to wait, use either for...of or Promise.all.


Mistake 5: Catching but not handling

// ❌ Wrong: swallowing the error; impossible to troubleshoot when something goes wrong
async function fetchData() {
  try {
    const res = await fetch('/api/data');
    return await res.json();
  } catch (e) {
    // swallows the error, does nothing
  }
}

Worse than "not catching" is "catching but doing nothing." Not catching at least leaves an error message in the console; swallowing it leaves you with zero clues when something breaks.

// ✅ Correct: at least log the error, then decide how to degrade
async function fetchData() {
  try {
    const res = await fetch('/api/data');
    return await res.json();
  } catch (e) {
    console.error('Failed to fetch data:', e);
    return null;  // explicitly return a fallback value
  }
}

If you don't know how to handle a particular error, the best practice is not to catch it — let it bubble up. Only catch where you know how to degrade gracefully.


Mistake 6: Redundant await on return in an async function

// ❌ Unnecessary await
async function getUser(id) {
  return await fetchUser(id);
}

// ✅ Just return the Promise directly
async function getUser(id) {
  return fetchUser(id);
}

If the final step of an async function is to return a Promise, the await is redundant. An async function automatically wraps its return value in a Promise.

But there is one exception: if your return is inside a try-catch, you must add await, otherwise the catch block cannot capture the error:

// ⚠️ In this case, await is required
async function getUser(id) {
  try {
    return await fetchUser(id);  // must await, otherwise catch cannot capture the error
  } catch (e) {
    return defaultUser;
  }
}

If you remove await, the Promise rejection will occur outside the try-catch, and catch will not intercept it at all.


Mistake 7: Not controlling concurrency

Mistake 1 teaches you to use Promise.all for parallel execution, but if you don't control the number, you fall into another trap:

// ❌ Dangerous: 1000 requests fire off simultaneously; the API gets hammered
async function downloadAll(urls) {
  await Promise.all(urls.map(url => fetch(url)));
}

A thousand requests hitting the backend at once will get you rate-limited with a 429, and the browser itself limits concurrent connections per domain (Chrome caps it at 6).

// ✅ Correct: control concurrency, max 5 at a time
async function downloadAll(urls) {
  const limit = 5;
  const results = [];

  for (let i = 0; i < urls.length; i += limit) {
    const batch = urls.slice(i, i + limit);
    const batchResults = await Promise.all(
      batch.map(url => fetch(url))
    );
    results.push(...batchResults);
  }

  return results;
}

Batching is the simplest form of concurrency control. If you need finer control (e.g., one failure should not affect others), use Promise.allSettled instead of Promise.all.


Mistake 8: Mixing .then() and await

// ❌ Wrong: mixed styles, poor readability
async function loadData() {
  const config = await getConfig();
  return fetch(config.url)
    .then(res => res.json())
    .then(data => {
      return processData(data);
    })
    .catch(err => {
      console.error(err);
    });
}

Since you're already using async/await, unify the style — don't mix half await and half .then():

// ✅ Correct: use await consistently
async function loadData() {
  const config = await getConfig();
  const res = await fetch(config.url);
  const data = await res.json();
  return processData(data);
}

Quick reference: common async/await mistakes

# Mistake Consequence Fix
1 Using await inside a loop Requests run serially, N times slower Promise.all + map
2 Nested try-catch Terrible readability to() utility function
3 Forgetting await You get a Promise object, logic breaks ESLint rules auto-detect
4 Using await inside forEach await has no effect; subsequent code runs early for...of or Promise.all
5 Catching but not handling Error swallowed, impossible to troubleshoot At least console.error, or don't catch
6 Redundant await on return Slight performance cost (exception: required inside try) Return directly in the final step
7 Not controlling concurrency API gets hammered, 429 rate limit Batch execution or concurrency pool
8 Mixing .then and await Inconsistent style, hard to maintain Use await uniformly

A copy-paste ESLint config

Add these rules to your .eslintrc and most of the mistakes above will be highlighted directly in your editor:

{
  "rules": {
    "no-await-in-loop": "warn",
    "require-await": "warn",
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/no-misused-promises": "error",
    "@typescript-eslint/await-thenable": "error"
  }
}

No need to memorize everything — let the tools block them for you.


Finally

The syntax of async/await itself is not difficult, but its ability to disguise asynchronous code as synchronous code causes many people to let their guard down. I stepped on most of these 8 mistakes myself early on and gradually corrected them through code reviews and ESLint rules.

If you find you've made more than 3 of them, don't worry — it just means you haven't written enough async code yet. Write more and you'll naturally remember.

What's the most outrageous async/await mistake you've seen in a code review? Share in the comments — let's see who has encountered something even more absurd.