JavaScript Async Programming with Promises and Async/Await

JavaScript Async Programming with Promises and Async/Await

JavaScript is single-threaded, meaning it can only do one thing at a time. However, it can handle long-running operations efficiently using asynchronous programming. This guide covers the modern approaches to handling async operations in JavaScript.

Understanding Asynchronous JavaScript

In synchronous code, each line executes one after another. If a line takes a long time to complete (like fetching data from a server), everything else waits. Asynchronous code allows other operations to continue while waiting for slow operations to complete.

The Problem with Synchronous Code

console.log("Start");

// This blocks everything else for 3 seconds
function slowOperation() {
    const start = Date.now();
    while (Date.now() - start < 3000) {
        // Wait 3 seconds
    }
    console.log("Slow operation completed");
}

slowOperation();
console.log("End"); // This only runs after 3 seconds

In real applications, this would freeze your entire interface!

Callbacks: The Original Approach

Callbacks were the first way to handle async operations in JavaScript.

console.log("Start");

function fetchData(callback) {
    setTimeout(() => {
        const data = { id: 1, name: "John" };
        callback(data);
    }, 2000);
}

fetchData((data) => {
    console.log("Data received:", data);
});

console.log("End"); // This runs immediately

The Callback Hell Problem

When you have multiple async operations that depend on each other, callbacks create nested code that’s hard to read and maintain:

// Callback hell - avoid this pattern!
getData((a) => {
    getMoreData(a, (b) => {
        getEvenMoreData(b, (c) => {
            getFinalData(c, (finalResult) => {
                console.log(finalResult);
            });
        });
    });
});

Promises: The Better Solution

Promises provide a cleaner way to handle async operations. A Promise represents a value that may be available now, or in the future, or never.

Creating and Using Promises

// Creating a promise
const fetchData = () => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = Math.random() > 0.3; // 70% success rate
            
            if (success) {
                resolve({ id: 1, name: "John", email: "[email protected]" });
            } else {
                reject(new Error("Failed to fetch data"));
            }
        }, 2000);
    });
};

// Using the promise
fetchData()
    .then(data => {
        console.log("Success:", data);
        return data.id; // Pass data to next then()
    })
    .then(id => {
        console.log("User ID:", id);
    })
    .catch(error => {
        console.error("Error:", error.message);
    })
    .finally(() => {
        console.log("Operation completed");
    });

Promise States

A Promise can be in one of three states:

  1. Pending: Initial state, not yet fulfilled or rejected
  2. Fulfilled: Operation completed successfully
  3. Rejected: Operation failed

Promise Chaining

Promises can be chained to handle sequences of async operations:

const getUser = (userId) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ id: userId, name: `User ${userId}` });
        }, 1000);
    });
};

const getPosts = (userId) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve([
                { id: 1, userId, title: "First post" },
                { id: 2, userId, title: "Second post" }
            ]);
        }, 1500);
    });
};

const getComments = (postId) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve([
                { id: 1, postId, text: "Great post!" },
                { id: 2, postId, text: "Very helpful" }
            ]);
        }, 500);
    });
};

// Chaining promises
getUser(1)
    .then(user => {
        console.log("User:", user);
        return getPosts(user.id);
    })
    .then(posts => {
        console.log("Posts:", posts);
        return getComments(posts[0].id);
    })
    .then(comments => {
        console.log("Comments:", comments);
    })
    .catch(error => {
        console.error("Error in chain:", error);
    });

Promise Utility Methods

Promise.all()

Run multiple promises in parallel and wait for all to complete:

const promise1 = new Promise(resolve => setTimeout(() => resolve("Result 1"), 1000));
const promise2 = new Promise(resolve => setTimeout(() => resolve("Result 2"), 2000));
const promise3 = new Promise(resolve => setTimeout(() => resolve("Result 3"), 1500));

Promise.all([promise1, promise2, promise3])
    .then(results => {
        console.log("All results:", results); // ["Result 1", "Result 2", "Result 3"]
    })
    .catch(error => {
        console.error("One promise failed:", error);
    });

Promise.race()

Return the result of the first promise to complete:

const fastPromise = new Promise(resolve => 
    setTimeout(() => resolve("Fast"), 1000)
);
const slowPromise = new Promise(resolve => 
    setTimeout(() => resolve("Slow"), 2000)
);

Promise.race([fastPromise, slowPromise])
    .then(result => {
        console.log("Winner:", result); // "Fast"
    });

Promise.allSettled()

Wait for all promises to complete, regardless of success or failure:

const promise1 = Promise.resolve("Success");
const promise2 = Promise.reject(new Error("Failed"));
const promise3 = Promise.resolve("Another success");

Promise.allSettled([promise1, promise2, promise3])
    .then(results => {
        results.forEach((result, index) => {
            if (result.status === 'fulfilled') {
                console.log(`Promise ${index}: ${result.value}`);
            } else {
                console.log(`Promise ${index} failed: ${result.reason.message}`);
            }
        });
    });

Async/Await: The Modern Approach

Async/await is syntactic sugar over Promises that makes async code look and behave like synchronous code.

Basic Async/Await

// Define an async function
async function fetchUserData() {
    try {
        const user = await getUser(1);
        console.log("User:", user);
        
        const posts = await getPosts(user.id);
        console.log("Posts:", posts);
        
        const comments = await getComments(posts[0].id);
        console.log("Comments:", comments);
        
        return { user, posts, comments };
    } catch (error) {
        console.error("Error:", error.message);
        throw error; // Re-throw if you want calling code to handle it
    }
}

// Call the async function
fetchUserData()
    .then(data => console.log("Final data:", data))
    .catch(error => console.error("Caught error:", error));

Async Function Always Returns a Promise

async function simpleFunction() {
    return "Hello World";
}

simpleFunction().then(result => {
    console.log(result); // "Hello World"
});

Error Handling with Try/Catch

async function fetchWithRetry(url, maxRetries = 3) {
    let lastError;
    
    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            return await response.json();
        } catch (error) {
            lastError = error;
            console.log(`Attempt ${i + 1} failed. Retrying...`);
            
            // Wait before retrying
            await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
        }
    }
    
    throw lastError; // Throw the last error if all retries failed
}

// Usage
fetchWithRetry('https://api.example.com/data')
    .then(data => console.log("Data:", data))
    .catch(error => console.error("All attempts failed:", error));

Real-World Examples

Fetching API Data

// Using async/await with fetch
async function getWeatherData(city) {
    const apiKey = 'your-api-key';
    const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}`;
    
    try {
        const response = await fetch(url);
        
        if (!response.ok) {
            throw new Error(`Weather API error: ${response.status}`);
        }
        
        const data = await response.json();
        
        return {
            city: data.name,
            temperature: Math.round(data.main.temp - 273.15), // Convert to Celsius
            description: data.weather[0].description,
            humidity: data.main.humidity
        };
    } catch (error) {
        console.error("Weather fetch error:", error);
        return null;
    }
}

// Usage
getWeatherData("London")
    .then(weather => {
        if (weather) {
            console.log(`Weather in ${weather.city}: ${weather.temperature}°C, ${weather.description}`);
        }
    });

File Operations (Node.js)

const fs = require('fs').promises; // Node.js v10+

async function processFiles(filePaths) {
    const results = [];
    
    for (const filePath of filePaths) {
        try {
            const content = await fs.readFile(filePath, 'utf8');
            const wordCount = content.split(/\s+/).length;
            
            results.push({
                file: filePath,
                words: wordCount,
                size: content.length
            });
        } catch (error) {
            console.error(`Error reading ${filePath}:`, error.message);
        }
    }
    
    return results;
}

// Usage
processFiles(['file1.txt', 'file2.txt', 'file3.txt'])
    .then(results => {
        results.forEach(result => {
            console.log(`${result.file}: ${result.words} words, ${result.size} characters`);
        });
    });

Sequential vs Parallel Operations

// Sequential - slow
async function fetchSequentially(userIds) {
    const users = [];
    
    for (const id of userIds) {
        const user = await getUser(id);
        users.push(user);
    }
    
    return users;
}

// Parallel - fast
async function fetchInParallel(userIds) {
    const promises = userIds.map(id => getUser(id));
    const users = await Promise.all(promises);
    return users;
}

// Batching for better performance
async function fetchInBatches(userIds, batchSize = 5) {
    const results = [];
    
    for (let i = 0; i < userIds.length; i += batchSize) {
        const batch = userIds.slice(i, i + batchSize);
        const batchResults = await fetchInParallel(batch);
        results.push(...batchResults);
        
        // Optional: Add delay between batches
        if (i + batchSize < userIds.length) {
            await new Promise(resolve => setTimeout(resolve, 100));
        }
    }
    
    return results;
}

Common Patterns and Best Practices

Timeout Pattern

function withTimeout(promise, timeoutMs) {
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs);
    });
    
    return Promise.race([promise, timeoutPromise]);
}

// Usage
const dataPromise = fetchData();
withTimeout(dataPromise, 5000)
    .then(data => console.log("Data:", data))
    .catch(error => console.error("Error:", error.message));

Caching Pattern

const cache = new Map();

async function getCachedData(key, fetcher) {
    if (cache.has(key)) {
        console.log("Returning cached data for:", key);
        return cache.get(key);
    }
    
    console.log("Fetching fresh data for:", key);
    const data = await fetcher();
    cache.set(key, data);
    
    // Optional: Set cache expiration
    setTimeout(() => {
        cache.delete(key);
    }, 5 * 60 * 1000); // 5 minutes
    
    return data;
}

// Usage
getCachedData('user:123', () => getUser(123));

Debouncing API Calls

let debounceTimer;
let pendingCall = null;

async function debouncedFetch(searchTerm) {
    // Cancel previous pending call
    if (pendingCall) {
        clearTimeout(debounceTimer);
    }
    
    return new Promise((resolve) => {
        debounceTimer = setTimeout(async () => {
            try {
                const result = await fetch(`https://api.example.com/search?q=${searchTerm}`);
                const data = await result.json();
                resolve(data);
            } catch (error) {
                resolve([]); // Return empty array on error
            }
        }, 300); // Wait 300ms after last call
    });
}

// Usage (typically in search input event handler)
// debouncedFetch(searchInput.value).then(results => displayResults(results));

Debugging Async Code

Using Promise Debugging Tools

// Track promise states
const trackedPromise = new Promise((resolve) => {
    setTimeout(() => resolve("Test"), 1000);
});

// Log promise state changes
trackedPromise
    .then(result => {
        console.log("Promise resolved with:", result);
        return result;
    })
    .catch(error => {
        console.error("Promise rejected:", error);
    });

Common Pitfalls and Solutions

1. Forgetting await in async functions

// Wrong - doesn't wait
async function wrong() {
    const result = fetchData(); // Returns Promise, not data
    console.log(result); // Promise object
}

// Correct
async function correct() {
    const result = await fetchData(); // Waits for Promise
    console.log(result); // Actual data
}

2. Mixing callbacks and promises

// Don't mix patterns
function mixedPattern(callback) {
    fetchData()
        .then(data => {
            callback(null, data); // Callback-style error handling
        })
        .catch(error => {
            callback(error); // Inconsistent error handling
        });
}

// Better: Stick to one pattern
async function promiseStyle() {
    try {
        const data = await fetchData();
        return data;
    } catch (error) {
        throw error; // Or handle appropriately
    }
}

3. Not handling promise rejections

// Bad - unhandled promise rejection
fetchData().then(data => console.log(data));

// Good - always handle errors
fetchData()
    .then(data => console.log(data))
    .catch(error => console.error("Fetch failed:", error));

// Or in async/await
async function fetchDataSafely() {
    try {
        const data = await fetchData();
        console.log(data);
    } catch (error) {
        console.error("Fetch failed:", error);
    }
}

Performance Considerations

Avoid Unnecessary Sequencing

// Slow - sequential operations
async function slowExample() {
    const user = await getUser(1);
    const posts = await getPosts(1);
    const comments = await getComments(1);
    return { user, posts, comments };
}

// Fast - parallel operations
async function fastExample() {
    const [user, posts, comments] = await Promise.all([
        getUser(1),
        getPosts(1),
        getComments(1)
    ]);
    return { user, posts, comments };
}

Use Appropriate Promise Methods

const operations = [op1, op2, op3];

// Use Promise.all when all operations must succeed
const allResults = await Promise.all(operations);

// Use Promise.allSettled when you want all results regardless of failures
const allSettled = await Promise.allSettled(operations);

// Use Promise.race when you only need the fastest result
const fastest = await Promise.race(operations);

External Resources:

Related Tutorials:

Last updated on