JavaScript Promises and Async/Await: The Complete Visual Guide
Async programming is one of the hardest concepts to master in JavaScript. This guide covers everything from callbacks to Promises to async/await — with visual explanations and real-world patterns.
The Problem: Callback Hell
// The old way — "callback pyramid of doom"
getUser(userId, function(err, user) {
if (err) return handleError(err);
getOrders(user.id, function(err, orders) {
if (err) return handleError(err);
getProducts(orders[0].id, function(err, products) {
if (err) return handleError(err);
// Now you're 3 levels deep...
displayResults(user, orders, products);
});
});
});
Promises and async/await solve this.
Understanding Promises
A Promise is an object representing the eventual completion (or failure) of an async operation.
// Creating a promise
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve('Data loaded!'); // Success
} else {
reject(new Error('Failed to load')); // Failure
}
}, 1000);
});
Three states:
- Pending — initial state, operation in progress
- Fulfilled — operation completed successfully
- Rejected — operation failed
Promise Chaining
// Flat, readable chain
fetchUser(userId)
.then(user => fetchOrders(user.id)) // Each .then receives previous result
.then(orders => fetchProducts(orders))
.then(products => displayProducts(products))
.catch(error => console.error('Something failed:', error))
.finally(() => setLoading(false)); // Always runs
Key rule: .then() always returns a new Promise, so you can chain infinitely.
Async/Await — Cleaner Syntax
// Same logic, but reads like synchronous code
async function loadUserData(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const products = await fetchProducts(orders);
displayProducts(products);
} catch (error) {
console.error('Something failed:', error);
} finally {
setLoading(false);
}
}
Rules of async/await:
asyncfunction always returns a Promiseawaitcan only be used insideasyncfunctionsawaitpauses execution until the Promise resolves
Parallel Execution — The Most Important Pattern
❌ Sequential (slow) — awaiting one at a time:
// This takes 3 seconds if each request takes 1 second
async function loadDashboard() {
const users = await fetchUsers(); // 1s
const orders = await fetchOrders(); // 1s
const stats = await fetchStats(); // 1s
return { users, orders, stats }; // Total: 3s
}
✅ Parallel (fast) — run simultaneously:
// This takes 1 second (all run at the same time)
async function loadDashboard() {
const [users, orders, stats] = await Promise.all([
fetchUsers(),
fetchOrders(),
fetchStats(),
]);
return { users, orders, stats }; // Total: 1s
}
When to use which:
- Use
Promise.allwhen requests are independent - Use sequential
awaitwhen request B depends on result of request A
Promise Methods Explained
Promise.all — All or Nothing
// Resolves when ALL resolve; rejects if ANY reject
const [user, profile, settings] = await Promise.all([
fetchUser(id),
fetchProfile(id),
fetchSettings(id),
]);
Promise.allSettled — Get All Results
// Resolves when ALL complete (regardless of success/failure)
const results = await Promise.allSettled([
fetchUser(id),
fetchProfile(id),
fetchSettings(id),
]);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.log('Failed:', result.reason);
}
});
Promise.race — First Wins
// Resolves/rejects with the FIRST settled promise
// Useful for timeouts
const result = await Promise.race([
fetchData(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
)
]);
Promise.any — First Success
// Resolves with first FULFILLED promise
// Rejects only if ALL reject (AggregateError)
const result = await Promise.any([
fetchFromServer1(),
fetchFromServer2(),
fetchFromServer3(),
]);
// Returns whichever server responds first successfully
Error Handling Patterns
The Try/Catch Pattern
async function fetchUserSafe(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else if (error.name === 'TypeError') {
console.log('Network error — are you online?');
} else {
console.error('Unexpected error:', error);
}
return null; // Or re-throw, depending on your needs
}
}
Result Pattern (Go-style error handling)
// Returns [data, error] tuple — no try/catch needed at call site
async function safeAsync(promise) {
try {
const data = await promise;
return [data, null];
} catch (error) {
return [null, error];
}
}
// Usage
const [user, error] = await safeAsync(fetchUser(id));
if (error) {
console.error('Failed:', error);
return;
}
// user is guaranteed to be defined here
console.log(user.name);
Common Mistakes
Mistake 1: Not Awaiting in Loops
// ❌ Wrong — all requests fire simultaneously, won't catch errors properly
async function processItems(items) {
items.forEach(async (item) => {
await processItem(item); // forEach doesn't await async callbacks!
});
}
// ✅ Correct — sequential processing
async function processItemsSequential(items) {
for (const item of items) {
await processItem(item);
}
}
// ✅ Correct — parallel processing
async function processItemsParallel(items) {
await Promise.all(items.map(item => processItem(item)));
}
Mistake 2: Missing Await
// ❌ Wrong — console.log gets a Promise object, not the data
async function getUser() {
const user = fetchUser(); // Missing await!
console.log(user); // Promise { <pending> }
}
// ✅ Correct
async function getUser() {
const user = await fetchUser();
console.log(user); // { id: 1, name: 'John' }
}
Mistake 3: Unhandled Promise Rejections
// ❌ Dangerous — unhandled rejection can crash Node.js
async function riskyOperation() {
await doSomething();
}
riskyOperation(); // No .catch() anywhere!
// ✅ Always handle rejections
riskyOperation().catch(console.error);
// Or use top-level await in modules
try {
await riskyOperation();
} catch (e) {
console.error(e);
}
Mistake 4: Creating Unnecessary Promises
// ❌ Redundant — wrapping an async function in a new Promise
function fetchData() {
return new Promise(async (resolve, reject) => {
try {
const data = await fetch('/api/data');
resolve(data);
} catch (error) {
reject(error);
}
});
}
// ✅ Async functions already return Promises
async function fetchData() {
const data = await fetch('/api/data');
return data; // Automatically wrapped in a Promise
}
Real-World Pattern: Data Fetching with Retry
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (attempt === retries) throw error;
// Exponential backoff: wait 1s, 2s, 4s...
const delay = Math.pow(2, attempt - 1) * 1000;
console.log(`Attempt ${attempt} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const data = await fetchWithRetry('https://api.example.com/data');
Summary
| Pattern | Use When |
|---|---|
await sequentially |
Each step depends on the previous |
Promise.all |
Independent operations, need all results |
Promise.allSettled |
Need all results, some may fail |
Promise.race |
Timeout pattern, first result wins |
Promise.any |
Multiple sources, need first success |
→ Test JSON API responses with JSON Viewer and decode JWT tokens at JWT Parser.