Skip to main content

Mastering Promises

A Promise is an object representing the future result of an asynchronous operation. It solves callback hell and allows you to write more declarative async code.


What is a Promise?

A Promise has three states:

pending   → Initial state, no result yet
fulfilled → Operation succeeded, result value available
rejected → Operation failed, error available

The state changes only once and cannot be reversed (pending → fulfilled or pending → rejected).

// Visualizing Promise states
const p1 = new Promise((resolve, reject) => {
// pending state...
setTimeout(() => resolve('Success!'), 1000);
// fulfilled state after 1 second
});

const p2 = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Failure!')), 1000);
// rejected state after 1 second
});

console.log(p1); // Promise { <pending> }

Creating a Promise

// Basic creation
const promise = new Promise((resolve, reject) => {
// Perform async operation
const success = Math.random() > 0.5;

if (success) {
resolve('Operation succeeded!'); // Transition to fulfilled state
} else {
reject(new Error('Operation failed!')); // Transition to rejected state
}
});

// Practical example: wrapping an HTTP request
function fetchUser(id) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `/api/users/${id}`);

xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};

xhr.onerror = () => reject(new Error('Network error'));
xhr.send();
});
}

// Timer Promise
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function example() {
console.log('Start');
await delay(2000);
console.log('After 2 seconds');
}

.then(), .catch(), .finally()

.then() - Handling Success

fetchUser(1)
.then(user => {
console.log(user.name);
return user.id; // Passed to the next .then
})
.then(id => {
console.log('ID:', id);
});

// .then(onFulfilled, onRejected) - errors can also be handled via the second argument
fetchUser(1).then(
user => console.log('Success:', user),
err => console.error('Failure:', err) // Similar to .catch but with a difference
);

.catch() - Handling Errors

fetchUser(999)
.then(user => processUser(user))
.catch(err => {
// Catches errors thrown anywhere in the chain
if (err.message.includes('404')) {
console.log('User not found');
} else {
console.error('Unexpected error:', err);
}
});

// Same as .then(null, handler) but .catch is recommended

.finally() - Always Runs

// Runs regardless of success or failure (useful for cleanup)
showLoadingSpinner();

fetchData('/api/large-dataset')
.then(data => displayData(data))
.catch(err => showErrorMessage(err))
.finally(() => {
hideLoadingSpinner(); // Always runs
// finally does not change the value
});

// finally does not affect the Promise value
Promise.resolve(42)
.finally(() => console.log('Cleanup')) // Prints 'Cleanup'
.then(val => console.log(val)); // Prints 42 (value preserved)

Promise Chaining

// Each .then() returns a new Promise
const result = fetchUser(1)
.then(user => {
console.log('User:', user.name);
return fetch(`/api/orders?userId=${user.id}`); // Returns a Promise
})
.then(response => response.json()) // Automatically flattens the Promise
.then(orders => {
console.log('Order count:', orders.length);
return orders.filter(o => o.status === 'active');
})
.then(activeOrders => {
console.log('Active orders:', activeOrders);
return activeOrders; // Final result
})
.catch(err => {
console.error('Error:', err);
return []; // Return default value to continue the chain
});

Static Methods

Promise.resolve() / Promise.reject()

// Wrapping an existing value in a Promise
const p1 = Promise.resolve(42);
p1.then(val => console.log(val)); // 42

// Creating an immediate rejection
const p2 = Promise.reject(new Error('Immediate failure'));
p2.catch(err => console.error(err.message)); // Immediate failure

// Useful for normalizing function return types
function getConfig(useCache) {
if (useCache && cachedConfig) {
return Promise.resolve(cachedConfig); // Wrap sync value in Promise
}
return fetchConfig(); // Async
}

Promise.all() - Wait for All to Complete

// Succeeds only if all Promises succeed. Fails immediately if any one fails
const [user, posts, comments] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchComments(1)
]);

// Parallel execution improves performance
console.time('sequential');
const a = await fetch('/api/a').then(r => r.json());
const b = await fetch('/api/b').then(r => r.json());
console.timeEnd('sequential'); // ~200ms (serial)

console.time('parallel');
const [c, d] = await Promise.all([
fetch('/api/a').then(r => r.json()),
fetch('/api/b').then(r => r.json())
]);
console.timeEnd('parallel'); // ~100ms (parallel)

// Behavior on failure
Promise.all([
Promise.resolve(1),
Promise.reject(new Error('Failure!')),
Promise.resolve(3)
]).catch(err => console.error(err.message)); // 'Failure!' - others ignored

Promise.race() - First One to Complete

// Returns the result of whichever Promise settles first (success or failure)
const fastest = await Promise.race([
fetch('/api/server1').then(r => r.json()),
fetch('/api/server2').then(r => r.json()),
fetch('/api/server3').then(r => r.json())
]);

// Implementing a timeout pattern
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout: exceeded ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}

try {
const data = await withTimeout(fetchLargeData(), 5000);
console.log(data);
} catch (err) {
console.error(err.message); // Timeout error if it takes more than 5 seconds
}

Promise.allSettled() - All Complete (Regardless of Success/Failure)

// Collects results of all Promises (continues even if some fail)
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(999), // 404 error
fetchUser(2)
]);

results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`${index}: Success -`, result.value);
} else {
console.log(`${index}: Failure -`, result.reason.message);
}
});

// In practice: collecting results from multiple API calls
async function fetchMultipleUsers(ids) {
const results = await Promise.allSettled(
ids.map(id => fetchUser(id))
);

return {
successful: results
.filter(r => r.status === 'fulfilled')
.map(r => r.value),
failed: results
.filter(r => r.status === 'rejected')
.map(r => r.reason)
};
}

Promise.any() - First One to Succeed

// Returns the first success. Throws AggregateError if all fail
try {
const result = await Promise.any([
fetch('/api/primary').then(r => r.json()),
fetch('/api/backup1').then(r => r.json()),
fetch('/api/backup2').then(r => r.json())
]);
console.log('First success:', result);
} catch (err) {
if (err instanceof AggregateError) {
console.error('All requests failed:', err.errors);
}
}

// Difference between Promise.race and Promise.any
// race: first settled (success or failure)
// any: first fulfilled (success only)

Practical Examples

Parallel API Call Pattern

// Load all user dashboard data at once
async function loadDashboard(userId) {
const [user, stats, notifications, recentActivity] = await Promise.all([
api.getUser(userId),
api.getUserStats(userId),
api.getNotifications(userId),
api.getRecentActivity(userId)
]);

return {
user,
stats,
notifications,
recentActivity
};
}

// Continue even if some fail
async function loadDashboardRobust(userId) {
const results = await Promise.allSettled([
api.getUser(userId),
api.getUserStats(userId),
api.getNotifications(userId),
api.getRecentActivity(userId)
]);

const [userResult, statsResult, notifResult, activityResult] = results;

return {
user: userResult.status === 'fulfilled' ? userResult.value : null,
stats: statsResult.status === 'fulfilled' ? statsResult.value : {},
notifications: notifResult.status === 'fulfilled' ? notifResult.value : [],
recentActivity: activityResult.status === 'fulfilled' ? activityResult.value : []
};
}

Retry Pattern

function retry(fn, attempts = 3, delay = 1000) {
return new Promise((resolve, reject) => {
function attempt(remaining) {
fn()
.then(resolve)
.catch(err => {
if (remaining <= 1) {
reject(err);
} else {
console.log(`Retrying... remaining attempts: ${remaining - 1}`);
setTimeout(() => attempt(remaining - 1), delay);
}
});
}
attempt(attempts);
});
}

// Usage
retry(() => fetchUnstableAPI(), 3, 2000)
.then(data => console.log('Success:', data))
.catch(err => console.error('Failed after 3 attempts:', err));

Building a Pipeline with Promise Chaining

// Data processing pipeline
function processImage(imageUrl) {
return fetch(imageUrl)
.then(response => {
if (!response.ok) throw new Error('Image load failed');
return response.blob();
})
.then(blob => createImageBitmap(blob))
.then(bitmap => {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
return canvas;
})
.then(canvas => canvas.toDataURL('image/webp', 0.8))
.catch(err => {
console.error('Image processing failed:', err);
return '/images/placeholder.webp'; // Fallback
});
}

Promise Anti-Patterns

// Bad example 1: Unnecessary wrapping in the Promise constructor (Promise Constructor Anti-pattern)
// Bad
function fetchData() {
return new Promise((resolve, reject) => {
fetch('/api/data')
.then(r => r.json())
.then(resolve)
.catch(reject);
});
}

// Good - if it already returns a Promise, just return it directly
function fetchData() {
return fetch('/api/data').then(r => r.json());
}

// Bad example 2: Forgetting .catch()
async function riskyOperation() {
fetch('/api/data')
.then(r => r.json())
.then(processData);
// No .catch() → errors are silently ignored (Unhandled Rejection)
}

// Bad example 3: Using Promise without await
async function missingAwait() {
const result = fetchData(); // No await!
console.log(result); // Prints Promise { <pending> }
}

Pro Tips

1. Promise.withResolvers() (ES2024)

// Old approach: inconvenient access to resolve/reject from outside
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});

// ES2024: Promise.withResolvers()
const { promise, resolve, reject } = Promise.withResolvers();

// Practical example
function createDeferred() {
return Promise.withResolvers();
}

const { promise: userPromise, resolve: resolveUser } = createDeferred();

// Can resolve from somewhere else later
setTimeout(() => resolveUser({ name: 'Alice' }), 1000);
const user = await userPromise;

2. Preserving access to values in Promise chaining

// When you need to access intermediate values later
async function withMultipleValues() {
// Approach 1: async/await
const user = await getUser(1);
const orders = await getOrders(user.id);
console.log(user.name, orders.length); // Both accessible

// Approach 2: pass as an object
return getUser(1)
.then(user => getOrders(user.id).then(orders => ({ user, orders })))
.then(({ user, orders }) => console.log(user.name, orders.length));
}

3. Concurrency Limiting

// Process only N items at a time
async function batchProcess(items, processor, concurrency = 5) {
const results = [];

for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}

return results;
}

// Process 100 items in parallel batches of 5
const processed = await batchProcess(
Array.from({ length: 100 }, (_, i) => i),
async (item) => {
await delay(100);
return item * 2;
},
5
);