Common Async/Await Pitfalls in JavaScript and How to Avoid Them

2025-12-03

Async/await has become the standard way to handle asynchronous operations in modern JavaScript. It makes asynchronous code look synchronous and easier to read, but it also introduces subtle pitfalls that can lead to bugs, performance issues, and unhandled errors.

Here are the most common mistakes developers make with async/await and how to fix them.


1. Forgetting to Await in Loops

The Problem

When you need to process multiple items asynchronously, it’s easy to forget await inside loops, or worse, await sequentially when you could run in parallel.

Sequential (Slow) - Common Mistake:

// ❌ BAD: Processes items one by one
async function processItems(items) {
    const results = [];
    for (const item of items) {
        const result = await processItem(item); // Waits for each one
        results.push(result);
    }
    return results;
}

If processItem takes 100ms and you have 10 items, this takes 1000ms total.

Missing Await (Broken) - Critical Mistake:

// ❌ BAD: Doesn't wait, returns promises instead of results
async function processItems(items) {
    const results = [];
    for (const item of items) {
        const result = processItem(item); // Missing await!
        results.push(result); // This is a Promise, not the actual result
    }
    return results; // Returns array of Promises
}

The Solution

Parallel Execution (Fast):

// ✅ GOOD: Processes all items in parallel
async function processItems(items) {
    const promises = items.map(item => processItem(item));
    const results = await Promise.all(promises);
    return results;
}

// Or even more concise:
async function processItems(items) {
    return await Promise.all(items.map(item => processItem(item)));
}

Now all 10 items process in parallel, taking only ~100ms total.

When You Need Sequential Processing:

// ✅ GOOD: Sequential when order matters or dependencies exist
async function processItemsSequentially(items) {
    const results = [];
    for (const item of items) {
        const result = await processItem(item);
        results.push(result);
    }
    return results;
}

2. Unhandled Promise Rejections

The Problem

Async functions always return a Promise. If an error is thrown and not caught, it becomes an unhandled rejection.

// ❌ BAD: Unhandled rejection if fetch fails
async function fetchUserData(userId) {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    return data;
}

// Calling it without error handling:
fetchUserData(123); // Unhandled rejection if network fails!

The Solution

Always Handle Errors:

// ✅ GOOD: Proper error handling
async function fetchUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('Failed to fetch user data:', error);
        throw error; // Re-throw if caller needs to handle it
    }
}

// Or handle at call site:
try {
    const userData = await fetchUserData(123);
    // Use userData
} catch (error) {
    // Handle error
}

Using .catch() for Fire-and-Forget:

// ✅ GOOD: For operations where you don't need to wait
fetchUserData(123).catch(error => {
    console.error('Background fetch failed:', error);
});

3. Mixing Async/Await with .then()/.catch()

The Problem

Mixing async/await with Promise chains creates confusing, hard-to-maintain code.

// ❌ BAD: Mixing styles is confusing
async function getUserData(userId) {
    return await fetch(`/api/users/${userId}`)
        .then(response => response.json())
        .then(data => {
            return processData(data);
        })
        .catch(error => {
            console.error(error);
            throw error;
        });
}

The Solution

Stick to One Style:

// ✅ GOOD: Pure async/await
async function getUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        return processData(data);
    } catch (error) {
        console.error(error);
        throw error;
    }
}

Or Pure Promise Chains (if preferred):

// ✅ GOOD: Pure Promise chains
function getUserData(userId) {
    return fetch(`/api/users/${userId}`)
        .then(response => response.json())
        .then(data => processData(data))
        .catch(error => {
            console.error(error);
            throw error;
        });
}

4. Awaiting Non-Promises

The Problem

Awaiting a value that isn’t a Promise doesn’t cause an error, but it’s unnecessary and can be confusing.

// ❌ BAD: Awaiting a synchronous value
async function getValue() {
    const value = await 42; // Works, but pointless
    const data = await JSON.parse('{"key": "value"}'); // Synchronous!
    return { value, data };
}

The Solution

Only Await Promises:

// ✅ GOOD: Only await actual Promises
async function getValue() {
    const value = 42; // No await needed
    const data = JSON.parse('{"key": "value"}'); // Synchronous, no await
    const apiData = await fetch('/api/data').then(r => r.json()); // This needs await
    return { value, data, apiData };
}

5. Race Conditions and Stale Closures

The Problem

When multiple async operations update shared state, race conditions can occur.

// ❌ BAD: Race condition
let userData = null;

async function loadUser(userId) {
    userData = await fetchUser(userId);
}

async function updateUser(userId, updates) {
    // userData might be stale or null!
    const current = userData;
    await saveUser(userId, { ...current, ...updates });
}

The Solution

Fetch Fresh Data or Use Proper State Management:

// ✅ GOOD: Always fetch fresh data
async function updateUser(userId, updates) {
    const current = await fetchUser(userId); // Fresh data
    await saveUser(userId, { ...current, ...updates });
}

// Or use proper state management (React example):
const [userData, setUserData] = useState(null);

async function updateUser(userId, updates) {
    const current = await fetchUser(userId);
    const updated = { ...current, ...updates };
    await saveUser(userId, updated);
    setUserData(updated); // Update state
}

6. Forgetting That Async Functions Return Promises

The Problem

Even with async/await, async functions still return Promises. Forgetting this leads to bugs.

// ❌ BAD: Expecting synchronous return
async function getData() {
    return await fetch('/api/data').then(r => r.json());
}

const data = getData(); // This is a Promise, not the data!
console.log(data.name); // undefined (or error)

The Solution

Always Await Async Functions:

// ✅ GOOD: Properly await the async function
async function getData() {
    return await fetch('/api/data').then(r => r.json());
}

const data = await getData(); // Now data is the actual result
console.log(data.name); // Works!

7. Error Handling in Promise.all()

The Problem

Promise.all() fails fast - if any promise rejects, the entire operation fails.

// ❌ BAD: One failure stops everything
async function loadMultipleUsers(userIds) {
    const promises = userIds.map(id => fetchUser(id));
    const users = await Promise.all(promises); // Fails if any user fetch fails
    return users;
}

The Solution

Use Promise.allSettled() for Partial Success:

// ✅ GOOD: Handles partial failures gracefully
async function loadMultipleUsers(userIds) {
    const promises = userIds.map(id => fetchUser(id));
    const results = await Promise.allSettled(promises);
    
    const users = [];
    const errors = [];
    
    results.forEach((result, index) => {
        if (result.status === 'fulfilled') {
            users.push(result.value);
        } else {
            errors.push({ userId: userIds[index], error: result.reason });
        }
    });
    
    return { users, errors };
}

Or Handle Errors Individually:

// ✅ GOOD: Catch errors per promise
async function loadMultipleUsers(userIds) {
    const promises = userIds.map(id => 
        fetchUser(id).catch(error => {
            console.error(`Failed to fetch user ${id}:`, error);
            return null; // Return null for failed fetches
        })
    );
    const users = await Promise.all(promises);
    return users.filter(user => user !== null); // Filter out failures
}

Best Practices Summary

  1. Always await async operations - Don’t forget await in loops or function calls
  2. Handle errors properly - Use try/catch or .catch() for all async operations
  3. Use parallel execution when possible - Use Promise.all() instead of sequential awaits
  4. Be consistent - Stick to async/await or Promise chains, don’t mix
  5. Only await Promises - Don’t await synchronous operations
  6. Remember async functions return Promises - Always await them when calling
  7. Use Promise.allSettled() - When you need partial success handling

Quick Reference: Common Patterns

Parallel Execution:

const results = await Promise.all(items.map(item => processItem(item)));

Sequential Execution:

for (const item of items) {
    await processItem(item);
}

Error Handling:

try {
    const result = await asyncOperation();
} catch (error) {
    // Handle error
}

Partial Success:

const results = await Promise.allSettled(promises);

By understanding these common pitfalls and applying the solutions, you’ll write more robust, performant, and maintainable asynchronous JavaScript code.