JavaScript Promises, Async/Await, and Error Handling: The Complete Guide (2026)
Async JavaScript is everywhere. Promises and async/await are the foundation of modern JS. Master them once, use them forever.
From Callbacks to Promises
// === The Problem: Callback Hell ===
// Nested callbacks make code hard to read and error-prone
getUser(userId, function(user) {
getPosts(user.id, function(posts) {
getComments(posts[0].id, function(comments) {
// 4 levels deep! And this is a simple example.
// Real code can go 10+ levels deep.
});
});
});
// === The Solution: Promises (ES2015) ===
// A Promise is a placeholder for a future value
// Three states: Pending → Fulfilled or Rejected
const promise = new Promise((resolve, reject) => {
// Async work happens here
const success = true;
if (success) {
resolve('Success!'); // Fulfills the promise with a value
} else {
reject(new Error('Failed!')); // Rejects the promise with an error
}
});
// Consuming promises:
promise
.then(result => console.log(result)) // On success
.catch(err => console.error(err)) // On failure
.finally(() => console.log('Done')); // Always runs (cleanup)
// Chaining promises (no more nesting!):
getUser(userId)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(err => console.error('Error anywhere in chain:', err));
// Flat structure! Error handling in ONE place.
Creating Your Own Promises
// Wrapping callback-based APIs in Promises:
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Usage:
readFilePromise('/etc/hostname')
.then(data => console.log('Hostname:', data))
.catch(err => console.error('Read failed:', err));
// Promise.resolve / Promise.reject (for quick returns):
function getCachedData(key) {
const cached = cache.get(key);
if (cached) return Promise.resolve(cached); // Already have it, wrap in promise
return fetchDataFromAPI(key); // Returns a real promise
}
// Common pattern: Timeout wrapper for any promise:
function withTimeout(promise, ms, message = 'Operation timed out') {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(message)), ms)
),
]);
}
withTimeout(fetchData(), 5000); // Rejects after 5 seconds if not resolved
Promise Combinators
// Promise.all — Run all, fail if ANY fails (fast failure)
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
// All must succeed. If one fails, entire thing fails.
// Promise.allSettled — Run ALL, wait for ALL to finish (never fails!)
const results = await Promise.allSettled([
fetchUsers(), // { status: "fulfilled", value: [...] }
fetchPosts(), // { status: "rejected", reason: Error }
fetchComments(), // { status: "fulfilled", value: [...] }
]);
results.forEach(r => {
if (r.status === 'fulfilled') {
console.log('Got:', r.value);
} else {
console.log('Failed:', r.reason);
}
});
// Promise.race — Returns first to settle (win or lose)
const result = await Promise.race([
fetchFromPrimary(),
withTimeout(fetchFromBackup(), 2000), // Must be faster than primary
]);
// Whichever resolves/rejects first wins
// Promise.any — Returns first SUCCESSFUL one (ignores failures until all fail)
const firstWorking = await Promise.any([
fetchFromCDN1(),
fetchFromCDN2(),
fetchFromCDN3(),
]);
// Returns first successful result. Only rejects if ALL fail.
// Practical example: Fetch with fallback URLs:
async function fetchWithFallback(urls) {
let lastError;
for (const url of urls) {
try {
return await fetch(url).then(r => r.json());
} catch (err) {
lastError = err;
console.warn(`${url} failed, trying next...`);
}
}
throw lastError; // All failed
}
Async/Await: Syntactic Sugar That Changes Everything
// async/await makes async code look synchronous!
// Before (promise chains):
function getUserPosts(userId) {
return getUser(userId)
.then(user => getPosts(user.id))
.then(posts => posts.filter(p => p.published))
.then(filtered => ({ count: filtered.length, posts: filtered }));
}
// After (async/await):
async function getUserPosts(userId) {
const user = await getUser(userId); // Wait for user
const posts = await getPosts(user.id); // Wait for posts
const filtered = posts.filter(p => p.published); // Sync-looking!
return { count: filtered.length, posts: filtered };
}
// Same behavior, MUCH more readable.
// Error handling with try/catch (familiar from sync code):
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
logger.error(`Failed to fetch ${url}:`, err.message);
return null; // Graceful fallback
}
}
// Parallel execution (CRITICAL performance tip!):
// ❌ Sequential (slow — each waits for previous):
async function getAllData() {
const users = await fetchUsers(); // Wait 100ms
const posts = await fetchPosts(); // Wait 150ms
const comments = await fetchComments(); // Wait 80ms
// Total: ~330ms
}
// ✅ Parallel (fast — all start at once):
async function getAllData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(), // Start immediately
fetchPosts(), // Start immediately
fetchComments(), // Start immediately
]);
// Total: ~150ms (longest single request)
}
Advanced Patterns
// Pattern 1: Retry logic with exponential backoff
async function retry(fn, options = {}) {
const { maxAttempts = 3, baseDelay = 1000, maxDelay = 30000 } = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxAttempts) throw err; // Final attempt, give up
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage:
const data = await retry(() => flakyAPI.call(), { maxAttempts: 5 });
// Pattern 2: Request queueing (limit concurrent requests)
class RequestQueue {
constructor(concurrency = 5) {
this.concurrency = concurrency;
this.running = 0;
this.queue = [];
}
async run(fn) {
if (this.running >= this.concurrency) {
await new Promise(resolve => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
if (this.queue.length > 0) {
this.queue.shift()(); // Let next waiting request proceed
}
}
}
}
const queue = new RequestQueue(3); // Max 3 concurrent requests
urls.forEach(url => queue.run(() => fetchAndProcess(url)));
// Pattern 3: Caching promises (deduplicate in-flight requests)
const pendingRequests = new Map();
function deduplicatedFetch(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // Return same promise if already in-flight
}
const promise = fetch(url)
.then(res => res.json())
.finally(() => pendingRequests.delete(url)); // Clean up when done
pendingRequests.set(url, promise);
return promise;
}
// If 10 components call deduplicatedFetch('/api/user/123') simultaneously,
// only ONE actual HTTP request is made!
// Pattern 4: Async iterator / generator
async function* paginateAPI(endpoint, pageSize = 50) {
let page = 1;
while (true) {
const response = await fetch(`${endpoint}?page=${page}&size=${pageSize}`);
const data = await response.json();
if (!data.items || data.items.length === 0) break;
yield data.items; // Yield page of results
page++;
if (data.items.length < pageSize) break; // Last page
}
}
// Usage:
for await (const items of paginateAPI('/api/posts')) {
console.log(`Processing ${items.length} posts`);
processBatch(items);
}
Common Mistakes & How to Avoid Them
// ❌ Forgetting await (unhandled promise!)
function updateUser(id, data) {
db.users.update(id, data); // Missing await! Promise floats away
// No error handling, no guarantee it completes
}
// Fix: ALWAYS await promises (or explicitly return them)
// ❌ try/catch inside loop (loses error context)
async function processItems(items) {
for (const item of items) {
try {
await process(item);
} catch (err) {
console.error('Failed:', item.id); // Which item? Need more context
}
}
}
// Better: Collect errors, report together
async function processItems(items) {
const errors = [];
for (const item of items) {
try {
await process(item);
} catch (err) {
errors.push({ id: item.id, error: err.message });
}
}
if (errors.length > 0) {
throw new Error(`${errors.length} items failed: ${JSON.stringify(errors)}`);
}
}
// ❌ Fire-and-forget without error handling
res.send('Processing started...');
heavyAsyncTask(); // If this crashes, nobody knows!
// Fix: At minimum:
heavyAsyncTask().catch(err => logger.error('Background task failed', err));
// Or use a proper job queue (Bull, Agenda, etc.)
// ❌ Mixing callbacks and promises (Zalgo!)
function badFunction(callback) {
if (cache.has(key)) {
callback(null, cache.get(key)); // Synchronous!
} else {
fetchData().then(data => callback(null, data)); // Asynchronous!
}
}
// Caller can't predict if callback fires before or after function returns
// Fix: Always use promises (or always synchronous)
// ❌ Unhandled rejection in Promise constructor
new Promise((resolve, reject) => {
throw new Error('Oops'); // This IS caught by Promise (rejects automatically)
});
// But INSIDE .then() handlers, throws are NOT caught:
Promise.resolve(42)
.then(val => { throw new Error('Not caught here!'); })
.catch(err => console.log('Caught:', err)); // THIS catches it
// Rule: Always end chain with .catch()
// ❌ Awaiting non-Promise values (unnecessary but works)
const result = await 42; // Works but pointless
const name = await "Alice"; // Same
// Not harmful, just unnecessary. Linters will flag it.
What's your favorite async pattern? What async nightmare have you survived?
Follow @armorbreak for more practical developer guides.
Top comments (1)
What I use a lot is this (those are not fetch related):
When needed:
I use this in class constructors too and is often more convenient as adding methods!