Async/await has transformed how we write asynchronous code, replacing callback pyramids and promise chains with code that reads sequentially. But this familiar syntax hides complexity. The patterns that work synchronously often fail asynchronously. Understanding common pitfalls and effective patterns helps you write async code that's both correct and performant.
The core insight is that async/await is syntactic sugar over promises. Every await pauses the function until the promise resolves, but other code continues executing. This interleaving creates behaviors that surprise developers expecting synchronous semantics.
Sequential vs Parallel Execution
The most common async/await mistake is accidentally serializing operations that could run in parallel. Each await pauses execution until the previous completes. Sequential awaits are correct but often unnecessarily slow.
Consider the difference between these two approaches to fetching user data. The first version waits for each request to complete before starting the next, while the second starts all requests immediately and waits for them together.
// Sequential - each request waits for the previous
async function getUserData(userId) {
const user = await fetchUser(userId); // Wait...
const orders = await fetchOrders(userId); // Then wait...
const recommendations = await fetchRecs(userId); // Then wait...
return { user, orders, recommendations };
}
// Total time: fetchUser + fetchOrders + fetchRecs
// Parallel - all requests start immediately
async function getUserData(userId) {
const [user, orders, recommendations] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchRecs(userId),
]);
return { user, orders, recommendations };
}
// Total time: max(fetchUser, fetchOrders, fetchRecs)
When operations are independent, use Promise.all to run them concurrently. The total time becomes the slowest operation, not the sum of all operations. If each request takes 100ms, the sequential version takes 300ms while the parallel version takes only 100ms.
But be careful with error handling in Promise.all. If any promise rejects, the entire Promise.all rejects immediately, potentially leaving other promises unhandled. When you need to handle partial failures gracefully, Promise.allSettled waits for all promises to complete regardless of success or failure.
async function fetchAllUserData(userIds) {
const results = await Promise.allSettled(
userIds.map(id => fetchUser(id))
);
const successful = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
const failed = results
.filter(r => r.status === 'rejected')
.map((r, i) => ({ userId: userIds[i], error: r.reason }));
return { successful, failed };
}
This pattern gives you fine-grained control over how to handle each result. You can log failures, retry them, or simply continue with the data you did retrieve.
Loops and Async Operations
Array methods like forEach, map, and filter don't naturally handle async operations. The results often surprise developers expecting synchronous behavior. This is one of the most common sources of bugs in async JavaScript code.
// BROKEN: forEach doesn't wait for async operations
async function processItems(items) {
items.forEach(async (item) => {
await processItem(item); // This doesn't block forEach
});
console.log('Done!'); // Prints before processing completes!
}
// Sequential processing with for...of
async function processItems(items) {
for (const item of items) {
await processItem(item);
}
console.log('Done!'); // Correctly waits for all items
}
// Parallel processing with Promise.all
async function processItems(items) {
await Promise.all(items.map(item => processItem(item)));
console.log('Done!'); // Correctly waits for all items
}
The forEach version fails because forEach doesn't return a promise and doesn't await the callback's return value. The callback runs, but forEach immediately moves on.
Choose sequential or parallel based on your needs. Sequential is necessary when order matters or when you want to limit concurrent operations. Parallel is faster when operations are independent. Sometimes you need a middle ground: processing items in batches.
// Controlled concurrency - process 5 at a time
async function processWithLimit(items, concurrency = 5) {
const results = [];
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
results.push(...batchResults);
}
return results;
}
This approach prevents overwhelming downstream services or hitting rate limits while still processing faster than pure sequential execution.
Error Handling
Async functions return promises, so unhandled rejections in async functions become unhandled promise rejections. Always wrap async operations in try/catch or attach .catch() handlers. Unhandled rejections can crash Node.js processes or leave your application in an inconsistent state.
// Error disappears without try/catch
async function riskyOperation() {
const result = await mightFail();
return result;
}
riskyOperation(); // Rejection goes unhandled
// Proper error handling
async function safeOperation() {
try {
const result = await mightFail();
return result;
} catch (error) {
logger.error('Operation failed', { error });
throw error; // Re-throw or handle appropriately
}
}
Errors in callbacks passed to non-async functions are particularly tricky. The async function catches exceptions in its own execution, not in callbacks it passes elsewhere. This subtle distinction causes many production bugs.
// Error in setTimeout callback escapes try/catch
async function problematic() {
try {
setTimeout(async () => {
await mightFail(); // This error is NOT caught below
}, 1000);
} catch (error) {
// Never reached for setTimeout callback errors
}
}
// Move try/catch inside the callback
async function fixed() {
setTimeout(async () => {
try {
await mightFail();
} catch (error) {
logger.error('Async operation in timeout failed', { error });
}
}, 1000);
}
The try/catch must wrap the code where the error can actually occur, not just the code that schedules it.
Avoiding Unnecessary Async
Not every function needs to be async. If a function just returns a promise without needing to await anything, making it async adds unnecessary overhead. The async keyword wraps the return value in a promise, which is redundant when you're already returning one.
// Unnecessarily async
async function getUser(id) {
return userRepository.findById(id); // Already returns a promise
}
// Simpler and equivalent
function getUser(id) {
return userRepository.findById(id);
}
// Async is needed when you await before returning
async function getUserWithValidation(id) {
if (!id) {
throw new Error('ID required');
}
const user = await userRepository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user; // After awaiting, the result is unwrapped
}
The validation example needs async because it awaits the repository call and then performs logic on the result. Without await, you'd have to chain .then() calls.
Race Conditions
Async code creates opportunities for race conditions that don't exist in synchronous code. The state might change between when you read it and when you write it. This is especially dangerous in financial or inventory systems.
// Race condition: balance might change between read and write
async function withdraw(accountId, amount) {
const account = await getAccount(accountId);
if (account.balance >= amount) {
// Another withdrawal might happen here!
account.balance -= amount;
await saveAccount(account);
}
}
// Use optimistic locking
async function withdraw(accountId, amount) {
const account = await getAccount(accountId);
const originalVersion = account.version;
if (account.balance >= amount) {
account.balance -= amount;
account.version += 1;
const updated = await updateAccountIfVersion(account, originalVersion);
if (!updated) {
// Version changed - another operation interfered
throw new ConcurrentModificationError();
}
}
}
Optimistic locking detects conflicts by checking that the data hasn't changed since you read it. If another operation modified the record, the version won't match and the update fails. You can then retry or report the error.
Memory Leaks
Long-running async operations can hold references to large objects, preventing garbage collection. Be mindful of what closures capture. This is especially problematic in long-lived processes like servers.
// Potential memory leak
async function processLargeData() {
const largeData = await fetchLargeDataset(); // 100MB
// This closure keeps largeData alive for 10 seconds
await new Promise(resolve => setTimeout(resolve, 10000));
// Only now can largeData be garbage collected
return summarize(largeData);
}
// Better: release reference when done with data
async function processLargeData() {
let summary;
{
const largeData = await fetchLargeDataset();
summary = summarize(largeData);
} // largeData goes out of scope here
await new Promise(resolve => setTimeout(resolve, 10000));
return summary;
}
The block scope in the improved version ensures largeData can be garbage collected as soon as summarization completes, even while the function continues executing.
Testing Async Code
Testing async code requires ensuring tests wait for async operations to complete. Most test frameworks support async test functions. Forgetting to await or return promises is a common cause of tests that pass but don't actually test anything.
// Jest async test patterns
describe('UserService', () => {
it('should fetch user data', async () => {
const user = await userService.getUser(123);
expect(user.id).toBe(123);
expect(user.name).toBeDefined();
});
it('should handle not found', async () => {
await expect(userService.getUser(999))
.rejects.toThrow('User not found');
});
it('should process items in parallel', async () => {
const startTime = Date.now();
await processItems([1, 2, 3, 4, 5]);
const duration = Date.now() - startTime;
// Parallel processing should be fast
expect(duration).toBeLessThan(1000);
});
});
Notice the use of async in the test function and await before assertions. The rejects matcher lets you assert on rejected promises without wrapping everything in try/catch.
Conclusion
Async/await simplifies asynchronous code but introduces its own complexity. Run independent operations in parallel with Promise.all. Handle errors with try/catch in async functions. Be careful with loops; use for...of or Promise.all instead of forEach. Watch for race conditions and memory leaks.
The key insight is that async/await changes control flow in ways that aren't obvious from the syntax. Code that looks sequential might not execute sequentially. Code that looks like it waits might not wait. Understanding these behaviors prevents subtle bugs and enables efficient async code.