When I first saw async/await, I thought it was magic. Write asynchronous code that looks synchronous? Sign me up. But after shipping a few bugs to production, I realized the "magic" was hiding complexity I still needed to understand.

The syntax is simple. The mental model is not. Let me share what took me a while to figure out.

What async/await Actually Does

Here's the thing: async/await is just syntax sugar over Promises. When you mark a function async, it automatically returns a Promise. When you await something, you're just pausing until that Promise resolves.

These two functions do exactly the same thing:

// Promise chain
function getUser(id) {
    return fetch(`/api/users/${id}`)
        .then(res => res.json())
        .then(user => {
            console.log(user);
            return user;
        });
}

// async/await
async function getUser(id) {
    const res = await fetch(`/api/users/${id}`);
    const user = await res.json();
    console.log(user);
    return user;
}

The second version is easier to read, but it's not doing anything different under the hood. Understanding this helps when things go wrong.

The Go-Style Error Handling Pattern

Try/catch everywhere gets noisy. I started using a pattern inspired by Go, where errors are returned as values instead of thrown:

async function safeAwait(promise) {
    try {
        const result = await promise;
        return [null, result];
    } catch (error) {
        return [error, null];
    }
}

// Usage
async function loadProfile(userId) {
    const [error, user] = await safeAwait(fetchUser(userId));
    
    if (error) {
        showError('Could not load profile');
        return;
    }
    
    renderProfile(user);
}

What I like about this: you can't accidentally forget to handle the error. The destructuring forces you to acknowledge it exists. And your happy-path code isn't buried inside a try block.

The forEach Disaster

I've debugged this exact issue at least five times across different codebases:

async function uploadFiles(files) {
    files.forEach(async (file) => {
        await uploadFile(file);
        console.log('Uploaded:', file.name);
    });
    console.log('All done!');
}

"All done!" logs immediately. The uploads happen... eventually... in the background. Nobody waits for them.

forEach doesn't know about Promises. It just fires callbacks and moves on. Use a for loop instead:

async function uploadFiles(files) {
    for (const file of files) {
        await uploadFile(file);
        console.log('Uploaded:', file.name);
    }
    console.log('All done!');
}

Or if order doesn't matter, use Promise.all:

async function uploadFiles(files) {
    await Promise.all(files.map(file => uploadFile(file)));
    console.log('All done!');
}

Serial vs Parallel: Know the Difference

This is probably the most common performance issue I see:

// ❌ Serial - each waits for the previous
async function loadDashboard(userId) {
    const user = await getUser(userId);      // 200ms
    const posts = await getPosts(userId);    // 300ms  
    const notifications = await getNotifications(userId); // 100ms
    // Total: 600ms
}

These requests don't depend on each other. Why wait?

// ✅ Parallel - all at once
async function loadDashboard(userId) {
    const [user, posts, notifications] = await Promise.all([
        getUser(userId),
        getPosts(userId),
        getNotifications(userId)
    ]);
    // Total: 300ms (slowest request)
}

I've seen pages go from 2+ second load times to under 500ms just by fixing this pattern.

When Promise.all Bites You

Promise.all is fail-fast. If any promise rejects, the whole thing rejects immediately. Sometimes that's not what you want.

For a dashboard where some widgets are optional, use Promise.allSettled:

const results = await Promise.allSettled([
    getUser(userId),
    getPosts(userId),
    getNotifications(userId)  // This might fail
]);

// results[0] = { status: 'fulfilled', value: {...} }
// results[1] = { status: 'fulfilled', value: [...] }
// results[2] = { status: 'rejected', reason: Error }

const user = results[0].status === 'fulfilled' ? results[0].value : null;
const posts = results[1].status === 'fulfilled' ? results[1].value : [];
// Show error badge on notifications widget if it failed

Retry with Backoff

Networks are flaky. Don't give up on the first failure:

async function fetchWithRetry(url, maxAttempts = 3) {
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
        try {
            const response = await fetch(url);
            if (response.ok) return await response.json();
            throw new Error(`HTTP ${response.status}`);
        } catch (error) {
            if (attempt === maxAttempts - 1) throw error;
            
            const delay = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s
            await new Promise(r => setTimeout(r, delay));
        }
    }
}

The exponential backoff is important—you don't want to hammer a struggling server with rapid retries.

Quick Checklist

Things I watch for in code review:

async/await makes async code look simple. But the underlying complexity is still there. Understand what's happening beneath the syntax, and you'll write better code.

← Back to JavaScript Articles

Back to Home