Asynchronous programming is at the heart of JavaScript. Whether you're fetching data from an API, reading files, or querying a database, understanding how to write clean async code is essential. In this article, we'll go beyond the basics and explore patterns that will make your async code more readable, maintainable, and robust.

The Evolution: Callbacks → Promises → Async/Await

Before async/await, we had callbacks. They worked, but led to deeply nested code that was hard to follow — the infamous "callback hell":

getUser(id, function(user) {
    getOrders(user.id, function(orders) {
        getOrderDetails(orders[0].id, function(details) {
            console.log(details);
        });
    });
});

Promises improved this significantly by allowing chaining:

getUser(id)
    .then(user => getOrders(user.id))
    .then(orders => getOrderDetails(orders[0].id))
    .then(details => console.log(details))
    .catch(err => console.error(err));

But async/await takes readability to another level — async code that reads like synchronous code:

async function getFullOrderDetails(id) {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    return details;
}

Error Handling Patterns

The most common approach is try/catch, but there are more elegant patterns. One popular approach is a wrapper function that returns a tuple:

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

// Usage:
const [user, err] = await safe(getUser(id));
if (err) {
    console.error('Failed to fetch user:', err.message);
    return;
}
console.log(user);

This Go-style error handling pattern keeps your code flat and avoids deeply nested try/catch blocks.

Running Tasks in Parallel

One of the most common mistakes is running independent async operations sequentially when they could run in parallel:

// Slow — sequential execution
const users = await getUsers();
const products = await getProducts();
const orders = await getOrders();

// Fast — parallel execution
const [users, products, orders] = await Promise.all([
    getUsers(),
    getProducts(),
    getOrders()
]);

If you need all results but want to handle partial failures, use Promise.allSettled:

const results = await Promise.allSettled([
    fetchCriticalData(),
    fetchOptionalData(),
    fetchAnalytics()
]);

results.forEach((result, i) => {
    if (result.status === 'fulfilled') {
        console.log(`Task ${i}: success`);
    } else {
        console.warn(`Task ${i}: failed — ${result.reason}`);
    }
});

Common Pitfalls

1. Forgetting await

Without await, you get a Promise object instead of the resolved value. This is especially tricky in conditionals where the Promise object is always truthy.

2. Async in Array Methods

Array.forEach doesn't wait for async callbacks. Use for...of or Promise.all with map instead:

// Won't work as expected
items.forEach(async (item) => {
    await processItem(item);
});

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

// Parallel processing
await Promise.all(items.map(item => processItem(item)));

3. Unhandled Promise Rejections

Always handle errors. In Node.js, unhandled rejections will terminate the process by default. Add a global handler as a safety net:

process.on('unhandledRejection', (reason, promise) => {
    console.error('Unhandled rejection at:', promise, 'reason:', reason);
});

Wrapping Up

Async/await has transformed how we write JavaScript. The key takeaways:

  • Use async/await for readability, but understand the Promises underneath
  • Handle errors consistently — consider the tuple pattern for flatter code
  • Run independent operations in parallel with Promise.all
  • Be careful with async callbacks in array methods
  • Always handle rejections to prevent silent failures

Master these patterns, and your async code will be cleaner, faster, and more reliable.