Rohan Yeole - Homepage Rohan Yeole

JavaScript Async/Await Best Practices: Error Handling, Parallelism, and Pitfalls

Mar 29, 20261 min read

Sequential await inside a for loop is the single most common async performance mistake in JavaScript. It turns parallel work into serial work — each request waits for the previous one to complete, multiplying your total wait time. Here is how to fix it, and the other async patterns that matter.

The Sequential Await Problem

// SLOW — each fetch waits for the previous one
const userIds = [1, 2, 3, 4, 5];
const users = [];
for (const id of userIds) {
    const user = await fetchUser(id);  // 5 requests, one at a time
    users.push(user);
}
// Total time: sum of all individual request times

With 5 requests each taking 200ms, this takes 1000ms. The requests have no dependency on each other — they can run in parallel.

// FAST — all requests run simultaneously
const userIds = [1, 2, 3, 4, 5];
const users = await Promise.all(userIds.map(id => fetchUser(id)));
// Total time: ~200ms (the slowest single request)

Promise.all starts all promises simultaneously and resolves when all complete. For independent async operations, always prefer this pattern.

Understanding why this works at the mechanism level: Node.js runs a single-threaded event loop. When you await fetchUser(id), your async function suspends and returns control to the event loop — the HTTP request is queued in the OS network stack, and the event loop is free to handle other callbacks. In a for loop with sequential await, you suspend once, wait for the response, then suspend again for the next — only one HTTP request is in-flight at any time. Promise.all schedules all five requests before any await suspends execution — all five HTTP requests are in-flight simultaneously in the OS network stack, and the event loop processes their response callbacks as they arrive. The constraint is not JavaScript concurrency; it is how many in-flight OS network operations you have at once.

Promise.all vs Promise.allSettled

Promise.all fails fast — if any promise rejects, the whole Promise.all rejects immediately.

// If fetchUser(3) throws, the whole thing rejects
const users = await Promise.all([fetchUser(1), fetchUser(2), fetchUser(3)]);

Promise.allSettled waits for all promises regardless of success or failure:

const results = await Promise.allSettled([fetchUser(1), fetchUser(2), fetchUser(3)]);

const successful = results
    .filter(r => r.status === "fulfilled")
    .map(r => r.value);

const failed = results
    .filter(r => r.status === "rejected")
    .map(r => r.reason);

Use Promise.allSettled when you want to process all results even if some fail — for example, batch-processing items where a few failures should not stop the rest.

Error Handling Patterns

try/catch is not always the right tool

// Fine for simple cases
async function getUser(id) {
    try {
        const user = await fetchUser(id);
        return user;
    } catch (error) {
        console.error("Failed to fetch user:", error);
        return null;
    }
}

But try/catch gets messy when you need to handle different error types differently:

async function getUser(id) {
    try {
        return await fetchUser(id);
    } catch (error) {
        if (error instanceof NetworkError) {
            showNetworkError();
        } else if (error instanceof NotFoundError) {
            return null;  // Expected — user not found is not a bug
        } else {
            throw error;  // Re-throw unexpected errors
        }
    }
}

The important pattern: re-throw errors you did not handle. Swallowing all errors silently is worse than no error handling — it hides bugs.

Catching at the call site vs in the function

Catching inside the function makes the function self-contained but hides errors from callers:

// Caller never knows if this failed
await sendEmail(user);

Catching at the call site lets the caller decide how to handle it:

// In the email function:
async function sendEmail(user) {
    const response = await emailService.send({ to: user.email, ... });
    return response;
    // Do not catch here — let it propagate
}

// At the call site:
try {
    await sendEmail(user);
    showSuccess("Email sent");
} catch (error) {
    showError("Email failed — we'll retry shortly");
    await scheduleEmailRetry(user);
}

Avoiding await in forEach

Array.prototype.forEach does not understand promises. await inside forEach does not actually wait:

// BUG — the forEach callback returns promises that are ignored
const userIds = [1, 2, 3];
userIds.forEach(async (id) => {
    await processUser(id);  // These run but are not awaited
});
// Code here runs before processUser calls complete

Fix: use Promise.all with .map():

await Promise.all(userIds.map(async (id) => {
    await processUser(id);
}));
// Now this line runs after all processUser calls complete

Request Cancellation with AbortController

Long-running or stale requests should be cancellable. Use AbortController:

// In a React component
useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
        try {
            const response = await fetch("/api/data", {
                signal: controller.signal,
            });
            const data = await response.json();
            setData(data);
        } catch (error) {
            if (error.name !== "AbortError") {
                setError(error);
            }
            // AbortError is expected — not a real error
        }
    }

    fetchData();

    return () => controller.abort();  // Cancel on unmount
}, []);

Without cancellation, a component that unmounts while a fetch is in progress may try to update state after unmount — triggering React warnings and potential memory leaks.

With Axios:

const controller = new AbortController();

const response = await axios.get("/api/data", {
    signal: controller.signal,
});

// Cancel when needed
controller.abort();

Timeout Pattern

fetch and axios do not have built-in timeouts. Add one with AbortController:

async function fetchWithTimeout(url, ms = 5000) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), ms);

    try {
        const response = await fetch(url, { signal: controller.signal });
        return await response.json();
    } catch (error) {
        if (error.name === "AbortError") {
            throw new Error(`Request timed out after ${ms}ms`);
        }
        throw error;
    } finally {
        clearTimeout(timeout);
    }
}

async/await vs .then() Chains

Both are valid. The practical rule: use async/await for sequential logic, use .then() chains when you need to process a stream of transformations:

// async/await: clear sequential flow
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);

// .then() chains: clean transformation pipelines
fetch("/api/data")
    .then(res => res.json())
    .then(data => data.results)
    .then(results => results.filter(r => r.active))
    .then(setItems)
    .catch(setError);

Mixing async/await with .then() works but reduces readability — pick one style per function.

Summary

PatternUse when
Promise.all(arr.map(async ...))Independent async operations in a loop
Promise.allSettledNeed results of all, even failures
AbortControllerCancellable requests (component unmount, timeout)
Re-throw unexpected errorsError handling that does not cover all cases
await in forEachNever

If you need a JavaScript or React frontend built with async patterns done correctly, hire me as a JavaScript developer or as a React developer.

Chat with me on WhatsApp