Promise .then() resolve async await Pending Fulfilled Rejected
Frontend

JavaScript Promises and Async/Await

Mayur Dabhi
Mayur Dabhi
March 18, 2026
25 min read

Asynchronous programming is the backbone of modern JavaScript applications. Whether you're fetching data from APIs, reading files, or handling user interactions, understanding how to work with Promises and async/await is essential for every JavaScript developer. In this comprehensive guide, we'll master these powerful tools that make asynchronous code both readable and maintainable.

From callback hell to elegant async/await syntax, JavaScript's approach to handling asynchronous operations has evolved dramatically. We'll explore not just the syntax, but the underlying concepts, common patterns, error handling strategies, and real-world use cases that will transform how you write asynchronous JavaScript.

What You'll Learn
  • Understanding the JavaScript event loop and asynchronous execution
  • Creating and consuming Promises effectively
  • Mastering Promise methods: all(), race(), allSettled(), any()
  • Writing clean code with async/await syntax
  • Error handling strategies and best practices
  • Common patterns and real-world examples
  • Performance considerations and anti-patterns to avoid

Understanding Asynchronous JavaScript

Before diving into Promises, let's understand why we need asynchronous programming. JavaScript is single-threaded, meaning it can only execute one operation at a time. Without asynchronous patterns, operations like network requests would block the entire application.

Synchronous vs Asynchronous Execution ❌ Synchronous (Blocking) Task 1: Fetch Data ⏳ WAITING... Task 2: Process Data ⏳ WAITING... Task 3: Update UI Total: 9 seconds 😴 ✅ Asynchronous (Non-blocking) Fetch Data Process UI Update ~3 sec Total: 3 seconds 🚀 Tasks run concurrently!

Asynchronous operations allow multiple tasks to progress simultaneously without blocking

The Evolution: From Callbacks to Promises

Before Promises, JavaScript developers used callbacks to handle asynchronous operations. This often led to deeply nested code known as "callback hell" or the "pyramid of doom":

❌ Callback Hell (The Old Way)
// The dreaded callback pyramid
getUser(userId, function(user) {
    getOrders(user.id, function(orders) {
        getOrderDetails(orders[0].id, function(details) {
            getShippingInfo(details.shippingId, function(shipping) {
                updateUI(user, orders, details, shipping, function() {
                    console.log('Finally done!');
                    // 😱 Error handling becomes a nightmare here
                }, handleError);
            }, handleError);
        }, handleError);
    }, handleError);
}, handleError);
Problems with Callbacks
  • Readability: Code becomes hard to read and maintain
  • Error Handling: Each callback needs its own error handler
  • Inversion of Control: You trust the callback will be called correctly
  • Composition: Difficult to combine multiple async operations

Understanding Promises

A Promise is an object representing the eventual completion or failure of an asynchronous operation. Think of it as a placeholder for a value that will be available in the future.

Promise Lifecycle PENDING ⏳ Waiting... resolve(value) reject(error) FULFILLED ✅ .then(value) REJECTED ❌ .catch(error) .finally() 🏁 Always runs

A Promise transitions from pending to either fulfilled or rejected, never both

Creating Promises

Let's start by creating our own Promise to understand how they work internally:

Creating a Promise
// Creating a basic Promise
const myPromise = new Promise((resolve, reject) => {
    // Simulate an async operation (like fetching data)
    const success = true;
    
    setTimeout(() => {
        if (success) {
            resolve({ id: 1, name: 'Product' }); // ✅ Fulfilled
        } else {
            reject(new Error('Failed to fetch data')); // ❌ Rejected
        }
    }, 1000);
});

// Consuming the Promise
myPromise
    .then(data => {
        console.log('Success:', data);
        return data.id; // Pass value to next .then()
    })
    .then(id => {
        console.log('Product ID:', id);
    })
    .catch(error => {
        console.error('Error:', error.message);
    })
    .finally(() => {
        console.log('Cleanup: Operation completed');
    });

Real-World Example: Fetching Data

Promise-based Data Fetching
// Fetch API returns a Promise
function fetchUserData(userId) {
    return fetch(`https://api.example.com/users/${userId}`)
        .then(response => {
            // Check if response is ok (status 200-299)
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json(); // Also returns a Promise!
        })
        .then(user => {
            console.log('User fetched:', user.name);
            return user;
        });
}

// Usage
fetchUserData(123)
    .then(user => {
        console.log('Got user:', user);
    })
    .catch(error => {
        console.error('Failed to fetch user:', error);
    });

Promise Methods: Working with Multiple Promises

JavaScript provides several static methods on the Promise object to handle multiple promises concurrently. Each method has specific use cases that can dramatically improve your code.

Promise.all()

Waits for ALL promises to resolve. Rejects immediately if ANY promise rejects.

Promise.allSettled()

Waits for ALL promises to settle (resolve or reject). Never short-circuits.

Promise.race()

Returns as soon as the FIRST promise settles (resolves or rejects).

Promise.any()

Returns the FIRST fulfilled promise. Only rejects if ALL reject.

Promise.all() - All or Nothing

Promise.all() Example
// Fetch multiple resources in parallel
async function fetchDashboardData(userId) {
    try {
        const [user, orders, notifications] = await Promise.all([
            fetch(`/api/users/${userId}`).then(r => r.json()),
            fetch(`/api/orders?userId=${userId}`).then(r => r.json()),
            fetch(`/api/notifications?userId=${userId}`).then(r => r.json())
        ]);
        
        return { user, orders, notifications };
    } catch (error) {
        // If ANY request fails, we catch it here
        console.error('Failed to load dashboard:', error);
        throw error;
    }
}

// All three requests run simultaneously! ⚡
// Total time ≈ slowest request (not sum of all)
When to Use Promise.all()

Use Promise.all() when you need ALL results and a single failure should abort everything. Perfect for loading related data that's only useful when complete.

Promise.allSettled() - Get All Results

Promise.allSettled() Example
// Send analytics to multiple services (some might fail)
async function sendAnalytics(event) {
    const results = await Promise.allSettled([
        sendToGoogleAnalytics(event),
        sendToMixpanel(event),
        sendToCustomBackend(event)
    ]);
    
    // Check each result individually
    results.forEach((result, index) => {
        const services = ['Google Analytics', 'Mixpanel', 'Custom Backend'];
        
        if (result.status === 'fulfilled') {
            console.log(`✅ ${services[index]}: Success`);
        } else {
            console.warn(`❌ ${services[index]}: ${result.reason.message}`);
        }
    });
    
    // Returns: [
    //   { status: 'fulfilled', value: {...} },
    //   { status: 'rejected', reason: Error },
    //   { status: 'fulfilled', value: {...} }
    // ]
}

Promise.race() - First to Finish

Promise.race() - Timeout Pattern
// Implement a timeout for any async operation
function withTimeout(promise, ms) {
    const timeout = new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error(`Operation timed out after ${ms}ms`));
        }, ms);
    });
    
    return Promise.race([promise, timeout]);
}

// Usage: Fetch with 5 second timeout
async function fetchWithTimeout(url) {
    try {
        const response = await withTimeout(
            fetch(url),
            5000 // 5 second timeout
        );
        return response.json();
    } catch (error) {
        if (error.message.includes('timed out')) {
            console.error('Request took too long!');
        }
        throw error;
    }
}

Promise.any() - First Success Wins

Promise.any() - Redundant Sources
// Try multiple CDNs, use whichever responds first
async function loadScript(scriptName) {
    const cdns = [
        `https://cdn1.example.com/${scriptName}`,
        `https://cdn2.example.com/${scriptName}`,
        `https://cdn3.example.com/${scriptName}`
    ];
    
    try {
        const fastestResponse = await Promise.any(
            cdns.map(url => fetch(url))
        );
        
        console.log('Loaded from:', fastestResponse.url);
        return fastestResponse;
    } catch (error) {
        // AggregateError: All promises rejected
        console.error('All CDNs failed:', error.errors);
        throw new Error('Could not load script from any CDN');
    }
}
Method Resolves When Rejects When Use Case
Promise.all() All promises fulfill Any promise rejects Load dependent data
Promise.allSettled() All promises settle Never (always resolves) Independent operations
Promise.race() First promise settles First promise rejects Timeouts, racing
Promise.any() First promise fulfills All promises reject Redundant sources

Async/Await: Syntactic Sugar

async/await is syntactic sugar built on top of Promises. It makes asynchronous code look and behave more like synchronous code, making it easier to read and write.

Promise Chain Syntax

function getUserWithOrders(userId) {
    return fetch(`/api/users/${userId}`)
        .then(response => response.json())
        .then(user => {
            return fetch(`/api/orders?userId=${user.id}`)
                .then(response => response.json())
                .then(orders => {
                    return { user, orders };
                });
        })
        .catch(error => {
            console.error('Error:', error);
            throw error;
        });
}

Async/Await Syntax

async function getUserWithOrders(userId) {
    try {
        const userResponse = await fetch(`/api/users/${userId}`);
        const user = await userResponse.json();
        
        const ordersResponse = await fetch(`/api/orders?userId=${user.id}`);
        const orders = await ordersResponse.json();
        
        return { user, orders };
    } catch (error) {
        console.error('Error:', error);
        throw error;
    }
}

Key Rules of Async/Await

1

The async Keyword

Placed before a function declaration. Makes the function automatically return a Promise. Any value returned is wrapped in Promise.resolve().

2

The await Keyword

Can ONLY be used inside an async function (or at module top-level). Pauses execution until the Promise settles, then returns the resolved value.

3

Error Handling with try/catch

Rejected Promises throw exceptions when awaited. Use try/catch blocks to handle errors, similar to synchronous code.

Async Function Patterns
// Function declaration
async function fetchData() {
    const data = await fetch('/api/data');
    return data.json();
}

// Arrow function
const fetchData = async () => {
    const data = await fetch('/api/data');
    return data.json();
};

// Object method
const api = {
    async getUser(id) {
        const response = await fetch(`/api/users/${id}`);
        return response.json();
    }
};

// Class method
class UserService {
    async getUser(id) {
        const response = await fetch(`/api/users/${id}`);
        return response.json();
    }
}

// IIFE (Immediately Invoked Function Expression)
(async () => {
    const data = await fetchData();
    console.log(data);
})();

// Top-level await (ES2022, in modules)
const config = await fetch('/config.json').then(r => r.json());

Error Handling Strategies

Proper error handling is crucial for building robust applications. Let's explore different patterns for handling errors with Promises and async/await.

Basic Error Handling

Error Handling Patterns
// Pattern 1: try/catch block
async function fetchUser(id) {
    try {
        const response = await fetch(`/api/users/${id}`);
        
        if (!response.ok) {
            throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        return await response.json();
    } catch (error) {
        // Handle both network errors and HTTP errors
        console.error('Failed to fetch user:', error.message);
        throw error; // Re-throw to let caller handle it
    }
}

// Pattern 2: .catch() on the async function call
fetchUser(123)
    .then(user => console.log(user))
    .catch(error => showErrorMessage(error));

// Pattern 3: Error wrapper utility
async function tryCatch(promise) {
    try {
        const data = await promise;
        return [null, data];
    } catch (error) {
        return [error, null];
    }
}

// Usage of wrapper
const [error, user] = await tryCatch(fetchUser(123));
if (error) {
    console.error('Error:', error);
} else {
    console.log('User:', user);
}

Handling Multiple Async Operations

Error Handling with Multiple Operations
// Individual error handling for each operation
async function loadDashboard(userId) {
    const results = {
        user: null,
        orders: null,
        notifications: null,
        errors: []
    };
    
    // Each operation handled independently
    try {
        results.user = await fetchUser(userId);
    } catch (error) {
        results.errors.push({ source: 'user', error });
    }
    
    try {
        results.orders = await fetchOrders(userId);
    } catch (error) {
        results.errors.push({ source: 'orders', error });
    }
    
    try {
        results.notifications = await fetchNotifications(userId);
    } catch (error) {
        results.errors.push({ source: 'notifications', error });
    }
    
    return results;
}

// Or use Promise.allSettled for parallel execution
async function loadDashboardParallel(userId) {
    const [userResult, ordersResult, notificationsResult] = await Promise.allSettled([
        fetchUser(userId),
        fetchOrders(userId),
        fetchNotifications(userId)
    ]);
    
    return {
        user: userResult.status === 'fulfilled' ? userResult.value : null,
        orders: ordersResult.status === 'fulfilled' ? ordersResult.value : null,
        notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : null,
        errors: [userResult, ordersResult, notificationsResult]
            .filter(r => r.status === 'rejected')
            .map(r => r.reason)
    };
}
Common Mistake: Unhandled Rejections

Always handle Promise rejections! Unhandled rejections can crash your Node.js application or cause silent failures in browsers. Use .catch() or try/catch blocks.

Common Patterns and Best Practices

Sequential vs Parallel Execution

Sequential vs Parallel
// ❌ SEQUENTIAL - Each awaits the previous (SLOW)
async function fetchSequential(ids) {
    const results = [];
    for (const id of ids) {
        const data = await fetchItem(id); // Waits for each one
        results.push(data);
    }
    return results;
}
// Time: 1s + 1s + 1s = 3 seconds

// ✅ PARALLEL - All run at once (FAST)
async function fetchParallel(ids) {
    const promises = ids.map(id => fetchItem(id));
    return Promise.all(promises);
}
// Time: max(1s, 1s, 1s) = 1 second

// ✅ PARALLEL with individual handling
async function fetchParallelSafe(ids) {
    const promises = ids.map(async (id) => {
        try {
            return await fetchItem(id);
        } catch (error) {
            return { error, id };
        }
    });
    return Promise.all(promises);
}

Controlled Concurrency

Limiting Concurrent Requests
// Process items with limited concurrency
async function processWithLimit(items, limit, processor) {
    const results = [];
    const executing = [];
    
    for (const item of items) {
        const promise = processor(item).then(result => {
            executing.splice(executing.indexOf(promise), 1);
            return result;
        });
        
        results.push(promise);
        executing.push(promise);
        
        if (executing.length >= limit) {
            await Promise.race(executing);
        }
    }
    
    return Promise.all(results);
}

// Usage: Process 100 items, max 5 concurrent
const items = Array.from({ length: 100 }, (_, i) => i);
const results = await processWithLimit(items, 5, async (item) => {
    const response = await fetch(`/api/items/${item}`);
    return response.json();
});

Retry Pattern

Retry with Exponential Backoff
async function fetchWithRetry(url, options = {}) {
    const { 
        maxRetries = 3, 
        baseDelay = 1000,
        maxDelay = 10000 
    } = options;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            const response = await fetch(url);
            
            if (!response.ok && response.status >= 500) {
                throw new Error(`Server error: ${response.status}`);
            }
            
            return response;
        } catch (error) {
            const isLastAttempt = attempt === maxRetries;
            
            if (isLastAttempt) {
                throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);
            }
            
            // Exponential backoff with jitter
            const delay = Math.min(
                baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
                maxDelay
            );
            
            console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
            await new Promise(resolve => setTimeout(resolve, delay));
        }
    }
}

// Usage
const response = await fetchWithRetry('/api/data', { maxRetries: 5 });

Debouncing Async Operations

Async Debounce
function debounceAsync(fn, delay) {
    let timeoutId;
    let pendingPromise = null;
    
    return function(...args) {
        return new Promise((resolve, reject) => {
            clearTimeout(timeoutId);
            
            timeoutId = setTimeout(async () => {
                try {
                    const result = await fn.apply(this, args);
                    resolve(result);
                } catch (error) {
                    reject(error);
                }
            }, delay);
        });
    };
}

// Usage: Debounced search
const debouncedSearch = debounceAsync(async (query) => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json();
}, 300);

// In an input handler
searchInput.addEventListener('input', async (e) => {
    const results = await debouncedSearch(e.target.value);
    displayResults(results);
});

Anti-Patterns to Avoid

❌ Forgetting to Await

// ❌ WRONG: Returns Promise, not the actual data
async function getData() {
    const data = fetch('/api/data'); // Missing await!
    return data; // Returns Promise<Response>
}

// ✅ CORRECT
async function getData() {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
}

❌ Using Async/Await in forEach

// ❌ WRONG: forEach doesn't wait for async callbacks
async function processItems(items) {
    items.forEach(async (item) => {
        await processItem(item); // Doesn't wait!
    });
    console.log('Done!'); // Runs before processing completes
}

// ✅ CORRECT: Use for...of for sequential
async function processItems(items) {
    for (const item of items) {
        await processItem(item);
    }
    console.log('Done!');
}

// ✅ CORRECT: Use map + Promise.all for parallel
async function processItems(items) {
    await Promise.all(items.map(item => processItem(item)));
    console.log('Done!');
}

❌ Creating Unnecessary Promises

// ❌ WRONG: Wrapping already-async code
async function getData() {
    return new Promise(async (resolve, reject) => {
        try {
            const data = await fetch('/api/data');
            resolve(data);
        } catch (e) {
            reject(e);
        }
    });
}

// ✅ CORRECT: async functions already return Promises
async function getData() {
    return fetch('/api/data');
}

// ❌ WRONG: Awaiting non-Promises unnecessarily
async function calculate(a, b) {
    return await (a + b); // await is pointless here
}

// ✅ CORRECT
function calculate(a, b) {
    return a + b;
}

Real-World Example: API Service

Let's put everything together in a production-ready API service:

Complete API Service Class
class ApiService {
    constructor(baseURL, options = {}) {
        this.baseURL = baseURL;
        this.timeout = options.timeout || 10000;
        this.retries = options.retries || 3;
    }
    
    async request(endpoint, options = {}) {
        const url = `${this.baseURL}${endpoint}`;
        const controller = new AbortController();
        
        // Set up timeout
        const timeoutId = setTimeout(() => controller.abort(), this.timeout);
        
        const config = {
            ...options,
            signal: controller.signal,
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            }
        };
        
        try {
            const response = await this.fetchWithRetry(url, config);
            clearTimeout(timeoutId);
            
            if (!response.ok) {
                const error = await response.json().catch(() => ({}));
                throw new ApiError(response.status, error.message || response.statusText);
            }
            
            return response.json();
        } catch (error) {
            clearTimeout(timeoutId);
            
            if (error.name === 'AbortError') {
                throw new ApiError(408, 'Request timed out');
            }
            
            throw error;
        }
    }
    
    async fetchWithRetry(url, config, attempt = 0) {
        try {
            return await fetch(url, config);
        } catch (error) {
            if (attempt < this.retries && this.isRetryable(error)) {
                const delay = Math.pow(2, attempt) * 1000;
                await new Promise(r => setTimeout(r, delay));
                return this.fetchWithRetry(url, config, attempt + 1);
            }
            throw error;
        }
    }
    
    isRetryable(error) {
        return error.name === 'TypeError' || // Network error
               error.status >= 500;          // Server error
    }
    
    // Convenience methods
    get(endpoint) {
        return this.request(endpoint, { method: 'GET' });
    }
    
    post(endpoint, data) {
        return this.request(endpoint, {
            method: 'POST',
            body: JSON.stringify(data)
        });
    }
    
    put(endpoint, data) {
        return this.request(endpoint, {
            method: 'PUT',
            body: JSON.stringify(data)
        });
    }
    
    delete(endpoint) {
        return this.request(endpoint, { method: 'DELETE' });
    }
}

class ApiError extends Error {
    constructor(status, message) {
        super(message);
        this.status = status;
        this.name = 'ApiError';
    }
}

// Usage
const api = new ApiService('https://api.example.com', {
    timeout: 5000,
    retries: 3
});

async function loadUserProfile(userId) {
    try {
        const [user, posts, followers] = await Promise.all([
            api.get(`/users/${userId}`),
            api.get(`/users/${userId}/posts`),
            api.get(`/users/${userId}/followers`)
        ]);
        
        return { user, posts, followers };
    } catch (error) {
        if (error instanceof ApiError) {
            console.error(`API Error ${error.status}: ${error.message}`);
        }
        throw error;
    }
}

Key Takeaways

Summary

  • Promises represent eventual values and enable cleaner async code than callbacks
  • async/await makes async code look synchronous and is built on Promises
  • Use Promise.all() for parallel operations where all must succeed
  • Use Promise.allSettled() when you need results from all operations regardless of failures
  • Use Promise.race() for timeouts and first-response scenarios
  • Use Promise.any() for redundant sources where you need the first success
  • Always handle errors with try/catch or .catch()
  • Run independent operations in parallel for better performance
  • Avoid common pitfalls like forEach with async or missing awaits
Next Steps

Now that you've mastered Promises and async/await, explore these advanced topics:

  • Async Iterators: for await...of for streaming data
  • AbortController: Canceling fetch requests and async operations
  • Web Workers: True parallelism for CPU-intensive tasks
  • RxJS: Reactive programming with Observables for complex async flows
JavaScript Promises Async/Await Asynchronous ES6+ Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building web applications with Laravel, React, and Node.js.