Modern JavaScript: Understanding async/await

From Promise Chains to Synchronous-Style Async Code

The Evolution of Asynchronous JavaScript

Imagine you're cooking a complex meal. With traditional Promises, you'd write out separate instructions for each step and chain them together. With async/await, you can write the recipe as if you're doing each step one after another. Let's see this evolution in code:

Traditional Promise Approach


// Cooking dinner with traditional Promises
function prepareIngredients() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Ingredients ready!');
        }, 1000);
    });
}

function cook() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Food cooked!');
        }, 2000);
    });
}

function serve() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Dinner is served!');
        }, 500);
    });
}

// Promise chain approach
function makeDinner() {
    console.log('Starting dinner preparation...');
    prepareIngredients()
        .then(result => {
            console.log(result);
            return cook();
        })
        .then(result => {
            console.log(result);
            return serve();
        })
        .then(result => {
            console.log(result);
        })
        .catch(error => {
            console.error('Dinner preparation failed:', error);
        });
}

Modern async/await Approach


// The same dinner preparation with async/await
async function makeDinnerModern() {
    try {
        console.log('Starting dinner preparation...');
        
        const ingredients = await prepareIngredients();
        console.log(ingredients);
        
        const cookedFood = await cook();
        console.log(cookedFood);
        
        const served = await serve();
        console.log(served);
        
        return 'Dinner preparation complete!';
    } catch (error) {
        console.error('Dinner preparation failed:', error);
    }
}

Understanding async Functions

When you mark a function with async, JavaScript automatically wraps its return value in a Promise. Think of it like putting your function's result in a special delivery package:


// Regular function
function normalGreeting() {
    return "Hello!";
}
console.log(normalGreeting()); // "Hello!"

// async function
async function asyncGreeting() {
    return "Hello!";
}
console.log(asyncGreeting()); // Promise { 'Hello!' }

// Using the async greeting
asyncGreeting().then(message => console.log(message)); // "Hello!"

// Real-world example: Fetching user data
async function getUserProfile(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            throw new Error('User not found');
        }
        const userData = await response.json();
        return {
            ...userData,
            lastFetched: new Date()
        };
    } catch (error) {
        console.error('Failed to fetch user:', error);
        return null;
    }
}

The Power of await

The await keyword is like a "pause and wait for result" button. It can only be used inside async functions and makes asynchronous code look synchronous:


// Simulating a database query
function queryDatabase(query) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({
                rows: [
                    { id: 1, name: 'Product A' },
                    { id: 2, name: 'Product B' }
                ]
            });
        }, 1000);
    });
}

// Traditional Promise approach
function getProducts() {
    queryDatabase('SELECT * FROM products')
        .then(result => {
            console.log(result.rows);
        })
        .catch(error => {
            console.error('Query failed:', error);
        });
}

// Modern async/await approach
async function getProductsModern() {
    try {
        const result = await queryDatabase('SELECT * FROM products');
        console.log(result.rows);
        return result.rows;
    } catch (error) {
        console.error('Query failed:', error);
        return [];
    }
}

Practical Patterns with async/await

1. Sequential Operations


async function processOrder(orderId) {
    try {
        // Each step waits for the previous to complete
        const order = await fetchOrder(orderId);
        const validatedOrder = await validateOrder(order);
        const processedOrder = await processPayment(validatedOrder);
        const shippingLabel = await createShippingLabel(processedOrder);
        
        return {
            orderId,
            status: 'completed',
            shippingLabel
        };
    } catch (error) {
        await handleOrderError(orderId, error);
        throw error;
    }
}

2. Parallel Operations


async function loadDashboard(userId) {
    try {
        // Start all requests simultaneously
        const [
            userProfile,
            userPreferences,
            recentOrders
        ] = await Promise.all([
            fetchUserProfile(userId),
            fetchUserPreferences(userId),
            fetchRecentOrders(userId)
        ]);

        return {
            profile: userProfile,
            preferences: userPreferences,
            orders: recentOrders
        };
    } catch (error) {
        console.error('Dashboard loading failed:', error);
        throw error;
    }
}

3. Error Handling Patterns


async function retryOperation(operation, maxAttempts = 3) {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return await operation();
        } catch (error) {
            if (attempt === maxAttempts) throw error;
            
            console.log(`Attempt ${attempt} failed, retrying...`);
            await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        }
    }
}

// Usage example
async function fetchDataWithRetry() {
    try {
        const data = await retryOperation(async () => {
            const response = await fetch('https://api.example.com/data');
            if (!response.ok) throw new Error('Bad response');
            return response.json();
        });
        
        return data;
    } catch (error) {
        console.error('All retry attempts failed:', error);
        return null;
    }
}

Best Practices and Common Patterns

1. Clean Error Handling


// Helper function for consistent error handling
async function safeAsync(promise) {
    try {
        const result = await promise;
        return [null, result];
    } catch (error) {
        return [error, null];
    }
}

// Usage
async function getUserData(userId) {
    const [error, data] = await safeAsync(fetchUserData(userId));
    
    if (error) {
        console.error('Failed to fetch user data:', error);
        return null;
    }
    
    return data;
}

2. Handling Multiple Promises Efficiently


async function processItems(items) {
    // Process items in batches of 5
    const batchSize = 5;
    const results = [];
    
    for (let i = 0; i < items.length; i += batchSize) {
        const batch = items.slice(i, i + batchSize);
        const batchPromises = batch.map(processItem);
        
        const batchResults = await Promise.all(batchPromises);
        results.push(...batchResults);
    }
    
    return results;
}

// With progress tracking
async function processItemsWithProgress(items, onProgress) {
    const results = [];
    const total = items.length;
    
    for (let i = 0; i < items.length; i++) {
        const result = await processItem(items[i]);
        results.push(result);
        
        onProgress({
            completed: i + 1,
            total,
            percentComplete: ((i + 1) / total) * 100
        });
    }
    
    return results;
}

Advanced Patterns

1. Cancellable Operations


function createCancellableOperation() {
    let isCancelled = false;
    
    const operation = async () => {
        // Simulate long operation
        for (let i = 0; i < 10; i++) {
            if (isCancelled) {
                throw new Error('Operation cancelled');
            }
            
            await new Promise(resolve => setTimeout(resolve, 1000));
            console.log(`Step ${i + 1} completed`);
        }
    };
    
    const cancel = () => {
        isCancelled = true;
    };
    
    return { operation, cancel };
}

// Usage
async function main() {
    const { operation, cancel } = createCancellableOperation();
    
    // Cancel after 3 seconds
    setTimeout(cancel, 3000);
    
    try {
        await operation();
        console.log('Operation completed');
    } catch (error) {
        if (error.message === 'Operation cancelled') {
            console.log('Operation was cancelled');
        } else {
            console.error('Operation failed:', error);
        }
    }
}