JavaScript Promises and Async/Await
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.
- 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.
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":
// 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);
- 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.
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 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
// 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
// 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)
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
// 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
// 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
// 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
The async Keyword
Placed before a function declaration. Makes the function automatically return a Promise. Any value returned is wrapped in Promise.resolve().
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.
Error Handling with try/catch
Rejected Promises throw exceptions when awaited. Use try/catch blocks to handle errors, similar to synchronous code.
// 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
// 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
// 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)
};
}
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 - 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
// 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
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
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:
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
Now that you've mastered Promises and async/await, explore these advanced topics:
- Async Iterators:
for await...offor 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
