JavaScript Promises: Complete Guide with Examples
JavaScript's single-threaded nature requires asynchronous patterns for operations like API calls, file I/O, and timers. Before Promises, callbacks were the primary mechanism, leading to deeply nested...
Key Insights
- Promises provide a cleaner alternative to callbacks for handling asynchronous operations, with three states (pending, fulfilled, rejected) that represent the lifecycle of an async operation
- Modern async/await syntax is syntactic sugar over Promises that makes asynchronous code read like synchronous code while maintaining all Promise functionality
- Understanding Promise static methods (all, race, allSettled, any) is critical for managing multiple concurrent operations efficiently in production applications
Introduction to Promises
JavaScript’s single-threaded nature requires asynchronous patterns for operations like API calls, file I/O, and timers. Before Promises, callbacks were the primary mechanism, leading to deeply nested code known as “callback hell.”
Here’s the difference:
// Callback hell
fetchUser(userId, (userError, user) => {
if (userError) {
handleError(userError);
return;
}
fetchPosts(user.id, (postsError, posts) => {
if (postsError) {
handleError(postsError);
return;
}
fetchComments(posts[0].id, (commentsError, comments) => {
if (commentsError) {
handleError(commentsError);
return;
}
displayData(user, posts, comments);
});
});
});
// Promise-based approach
fetchUser(userId)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => displayData(comments))
.catch(error => handleError(error));
Promises represent a value that will be available in the future. They provide a standardized interface for async operations with built-in error handling and composability.
Promise Fundamentals
A Promise exists in one of three states:
- Pending: Initial state, operation hasn’t completed
- Fulfilled: Operation completed successfully
- Rejected: Operation failed
The Promise constructor takes an executor function with resolve and reject callbacks:
const simplePromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Operation completed');
}, 1000);
});
// Promise that resolves with data
const fetchUserData = new Promise((resolve, reject) => {
const userData = { id: 1, name: 'Alice', email: 'alice@example.com' };
resolve(userData);
});
// Promise that rejects with an error
const failingOperation = new Promise((resolve, reject) => {
const error = new Error('Database connection failed');
reject(error);
});
Once a Promise settles (fulfills or rejects), its state is immutable. You cannot resolve a rejected Promise or vice versa.
Consuming Promises: then, catch, and finally
The .then() method handles fulfilled Promises, .catch() handles rejections, and .finally() runs regardless of outcome:
fetchUserData
.then(user => {
console.log('User:', user.name);
return user.id;
})
.catch(error => {
console.error('Error:', error.message);
});
// Chaining multiple operations
fetch('https://api.example.com/users/1')
.then(response => response.json())
.then(user => {
console.log('Fetched user:', user);
return fetch(`https://api.example.com/posts?userId=${user.id}`);
})
.then(response => response.json())
.then(posts => {
console.log('User posts:', posts);
})
.catch(error => {
console.error('Chain failed:', error);
});
// Using finally for cleanup
let loadingIndicator = true;
fetchData()
.then(data => processData(data))
.catch(error => logError(error))
.finally(() => {
loadingIndicator = false;
console.log('Cleanup complete');
});
Each .then() returns a new Promise, enabling chaining. If you return a value from .then(), it’s wrapped in a resolved Promise. If you return a Promise, the chain waits for it.
Promise Static Methods
Promise static methods handle multiple Promises concurrently with different semantics:
Promise.all() - Waits for all Promises to fulfill or any to reject:
const apiCalls = [
fetch('https://api.example.com/users'),
fetch('https://api.example.com/posts'),
fetch('https://api.example.com/comments')
];
Promise.all(apiCalls)
.then(responses => Promise.all(responses.map(r => r.json())))
.then(([users, posts, comments]) => {
console.log('All data loaded:', { users, posts, comments });
})
.catch(error => {
console.error('At least one call failed:', error);
});
Promise.race() - Resolves or rejects with the first settled Promise:
function fetchWithTimeout(url, timeout = 5000) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), timeout);
});
return Promise.race([
fetch(url),
timeoutPromise
]);
}
fetchWithTimeout('https://api.example.com/slow-endpoint', 3000)
.then(response => response.json())
.catch(error => console.error('Failed or timed out:', error));
Promise.allSettled() - Waits for all Promises to settle, regardless of outcome:
const mixedPromises = [
fetch('https://api.example.com/valid'),
fetch('https://api.example.com/invalid'),
Promise.resolve({ cached: true })
];
Promise.allSettled(mixedPromises)
.then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Promise ${index} succeeded:`, result.value);
} else {
console.log(`Promise ${index} failed:`, result.reason);
}
});
});
Promise.any() - Resolves with the first fulfilled Promise, ignoring rejections:
const mirrors = [
fetch('https://mirror1.example.com/data'),
fetch('https://mirror2.example.com/data'),
fetch('https://mirror3.example.com/data')
];
Promise.any(mirrors)
.then(response => response.json())
.then(data => console.log('Got data from fastest mirror:', data))
.catch(error => console.error('All mirrors failed:', error));
Async/Await: Modern Promise Syntax
Async/await makes asynchronous code look synchronous, improving readability dramatically:
// Promise chain
function getUserPosts(userId) {
return fetchUser(userId)
.then(user => fetchPosts(user.id))
.then(posts => posts.filter(p => p.published))
.catch(error => {
console.error('Error:', error);
throw error;
});
}
// Async/await equivalent
async function getUserPosts(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
return posts.filter(p => p.published);
} catch (error) {
console.error('Error:', error);
throw error;
}
}
Error handling with try/catch is more intuitive than chaining .catch():
async function loadDashboard() {
try {
const user = await fetchCurrentUser();
const [posts, notifications, settings] = await Promise.all([
fetchUserPosts(user.id),
fetchNotifications(user.id),
fetchUserSettings(user.id)
]);
return { user, posts, notifications, settings };
} catch (error) {
if (error.status === 401) {
redirectToLogin();
} else {
showErrorMessage('Failed to load dashboard');
}
throw error;
}
}
Parallel execution with async/await requires explicit Promise.all():
// Sequential - slow (waits for each)
async function sequentialFetch() {
const users = await fetch('/users').then(r => r.json());
const posts = await fetch('/posts').then(r => r.json());
const comments = await fetch('/comments').then(r => r.json());
return { users, posts, comments };
}
// Parallel - fast (concurrent requests)
async function parallelFetch() {
const [users, posts, comments] = await Promise.all([
fetch('/users').then(r => r.json()),
fetch('/posts').then(r => r.json()),
fetch('/comments').then(r => r.json())
]);
return { users, posts, comments };
}
Common Patterns and Best Practices
Always return Promises in chains to avoid silent failures:
// Wrong - forgotten return
fetchUser(userId)
.then(user => {
fetchPosts(user.id); // Missing return!
})
.then(posts => {
console.log(posts); // undefined
});
// Correct
fetchUser(userId)
.then(user => {
return fetchPosts(user.id);
})
.then(posts => {
console.log(posts); // Works correctly
});
Propagate errors properly by rethrowing or handling them:
async function processData(id) {
try {
const data = await fetchData(id);
return await transformData(data);
} catch (error) {
// Log but rethrow for upstream handling
console.error('Processing failed:', error);
throw new Error(`Failed to process data for ${id}: ${error.message}`);
}
}
Choose sequential vs. parallel execution based on dependencies:
// Sequential when operations depend on each other
async function createUserWorkflow(userData) {
const user = await createUser(userData);
const profile = await createProfile(user.id);
const settings = await initializeSettings(profile.id);
return { user, profile, settings };
}
// Parallel when operations are independent
async function loadPageData(userId) {
const [user, posts, followers] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchFollowers(userId)
]);
return { user, posts, followers };
}
Real-World Application
Here’s a production-ready data fetching utility with retries, timeouts, and comprehensive error handling:
class APIClient {
constructor(baseURL, options = {}) {
this.baseURL = baseURL;
this.timeout = options.timeout || 5000;
this.retries = options.retries || 3;
}
async fetchWithRetry(endpoint, options = {}, attempt = 1) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${this.baseURL}${endpoint}`, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (attempt < this.retries && this.shouldRetry(error)) {
console.warn(`Retry ${attempt}/${this.retries} for ${endpoint}`);
await this.delay(Math.pow(2, attempt) * 1000);
return this.fetchWithRetry(endpoint, options, attempt + 1);
}
throw error;
}
}
shouldRetry(error) {
return error.name === 'AbortError' ||
error.message.includes('Network') ||
error.message.includes('500');
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async fetchMultiple(endpoints) {
const results = await Promise.allSettled(
endpoints.map(endpoint => this.fetchWithRetry(endpoint))
);
return results.map((result, index) => ({
endpoint: endpoints[index],
success: result.status === 'fulfilled',
data: result.status === 'fulfilled' ? result.value : null,
error: result.status === 'rejected' ? result.reason.message : null
}));
}
}
// Usage
const api = new APIClient('https://api.example.com', {
timeout: 3000,
retries: 3
});
async function loadUserDashboard(userId) {
try {
const results = await api.fetchMultiple([
`/users/${userId}`,
`/users/${userId}/posts`,
`/users/${userId}/notifications`
]);
const [userData, postsData, notificationsData] = results;
if (!userData.success) {
throw new Error('Failed to load user data');
}
return {
user: userData.data,
posts: postsData.success ? postsData.data : [],
notifications: notificationsData.success ? notificationsData.data : []
};
} catch (error) {
console.error('Dashboard load failed:', error);
throw error;
}
}
This implementation demonstrates retry logic with exponential backoff, timeout handling, parallel requests with partial failure tolerance, and comprehensive error reporting—all patterns essential for production JavaScript applications.