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.