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

JavaScript Promises and Async/Await

Mayur Dabhi
Mayur Dabhi
March 18, 2026
25 min read

You log a value right after a fetch() call and get Promise { <pending> } instead of your data. Or a dashboard takes three seconds to load because every request waits for the one before it, even though none of them depend on each other. Most async bugs I run into in JavaScript come down to not quite knowing what a Promise is doing at a given moment.

This post goes through how Promises work, the four combinators (all, allSettled, race, any), how async/await sits on top of them, error handling, and a few patterns I actually use: retries, concurrency limits and debouncing. It ends with the mistakes that show up most often in code review.

What's in here
  • Why JavaScript needs async code at all
  • Creating and consuming Promises
  • Promise methods: all(), race(), allSettled(), any()
  • async/await syntax and its rules
  • Error handling, including unhandled rejections
  • Retries, concurrency limits and debouncing
  • Anti-patterns that slow code down or hide bugs

Understanding Asynchronous JavaScript

JavaScript runs your code on a single thread. It can only execute one thing at a time, so if a network request blocked that thread, the whole page would freeze until the response came back. Async APIs hand the waiting off to the browser or Node's runtime and let your code carry on.

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!

Async code lets independent tasks overlap instead of waiting in line

The Evolution: From Callbacks to Promises

Before Promises, you passed callbacks. Once you had a few dependent steps, the code nested deeper with each one, which is where the names "callback hell" and "pyramid of doom" come from:

❌ 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: every extra step adds a level of indentation
  • Error Handling: each callback needs its own error handler, and it's easy to miss one
  • Inversion of Control: you're trusting the library to call your callback once, and only once
  • Composition: running things in parallel or adding a timeout means writing your own bookkeeping

Understanding Promises

When you call fetch(), you don't get a response back. You get a Promise: an object that stands in for a value you'll have later, or an error explaining why you won't. It starts out pending and settles exactly once.

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

You'll rarely need new Promise() in app code (mostly for wrapping callback APIs like setTimeout), but building one makes the moving parts obvious:

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

One thing that trips people up: fetch() only rejects on network failures. A 404 or 500 still resolves, so you have to check response.ok yourself.

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

Once you have several Promises in flight, you need a way to wait on them together. There are four static methods for this, and the difference between them is mostly about what happens when one of them fails.

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 every result and one failure makes the rest useless, like a page that can't render without both the user and their settings. Note that it doesn't cancel the other requests when one fails. They keep running; you just stop listening.

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

Nested .then() calls get awkward as soon as one step needs a value from an earlier one. async/await fixes that. It's still Promises underneath, but the code reads top to bottom like synchronous code. Compare the two versions:

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

Only works inside an async function (or at the top level of an ES module). Use it anywhere else and you get SyntaxError: await is only valid in async functions and the top level bodies of modules. It pauses that function 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

Most async bugs I see aren't wrong logic. They're errors that went nowhere: a rejection nobody caught, or a catch block that logged and swallowed the problem. Here are the patterns I reach for.

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

Since Node.js 15, an unhandled rejection crashes the process by default, with an [UnhandledPromiseRejection] error in the logs. In browsers it's quieter: you get an "Uncaught (in promise)" line in the console and the user sees nothing. Every Promise chain needs a .catch() or a try/catch somewhere up the call stack. In Node, a process.on('unhandledRejection', ...) handler is a good place to log the ones you missed.

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

Here's a small API client that combines timeouts, retries and typed errors. Treat it as a starting point rather than a drop-in library:

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;
    }
}

When I'd reach for each tool (and when I wouldn't)

Trade-offs worth knowing

  • async/await by default. I write almost everything this way. The exception is short one-off transforms like fetch(url).then(r => r.json()), where a single .then() is shorter than a separate await.
  • Don't parallelize blindly. Promise.all over 500 IDs fires 500 requests at once. That's a good way to hit rate limits or flood your own database. Use a concurrency limit like the one above once the list isn't small and fixed.
  • Sequential is sometimes correct. If step two depends on step one, or order matters (migrations, writes to the same row), a plain for...of with await is the right answer, not a slow one.
  • A timeout via race() isn't cancellation. The losing request keeps running. If you actually need to stop the work, pass an AbortController signal, as the API service does.
  • Promises don't make CPU work faster. Wrapping a heavy loop in an async function still blocks the thread. For that you need a Web Worker (browser) or worker_threads (Node).
  • Reach for something bigger when flows get complex. Streams of events, cancellation chains or lots of retries with shared state may be easier with async iterators (for await...of) or a library like RxJS. For ordinary request/response code, plain Promises are enough.
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.