JavaScript Promises, Async/Await, and Error Handling: The Complete Guide (2026)
Async code doesn't have to be confusing. Master these patterns and you'll handle any async scenario.
The Evolution of Async in JavaScript
Callback Hell → Promises → Async/Await → Top-level Await
Each step solved real problems:
→ Callbacks: No composition, nesting nightmare
→ Promises: Composable but still .then() chains
→ Async/Await: Reads like synchronous code
→ Top-level Await: Use await in modules (Node.js 14+)
Callbacks: Where It Started
// The old way (still used in Node.js core APIs)
const fs = require('fs');
// ❌ Callback hell (pyramid of doom)
fs.readFile('file1.txt', 'utf8', (err, data1) => {
if (err) return console.error(err);
fs.readFile('file2.txt', 'utf8', (err, data2) => {
if (err) return console.error(err);
fs.writeFile('output.txt', data1 + data2, (err) => {
if (err) return console.error(err);
console.log('Done!');
});
});
});
// Problems:
// → Error handling repeated at every level
// → Deeply nested, hard to read
// → Can't easily compose multiple async operations
// → Control flow is inverted (you don't call, you get called)
Promises: The Foundation
Creating Promises
// Basic promise creation
function fetchData(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send();
});
}
// Promise states:
// → Pending: Initial state, neither fulfilled nor rejected
// → Fulfilled: Operation completed successfully (has a value)
// → Rejected: Operation failed (has a reason/error)
// → Settled: Either fulfilled or rejected (final state)
// Once settled, a promise CANNOT change state again.
// This immutability is the key to reliable async code.
Consuming Promises
fetchData('https://api.example.com/data')
.then(data => {
console.log('Got:', data);
return JSON.parse(data); // Return value becomes next .then()'s input
})
.then(parsed => {
console.log('Parsed:', parsed);
return parsed.items; // Pass only items to next handler
})
.then(items => {
console.log(`Found ${items.length} items`);
return processItems(items); // Returns another Promise!
})
.catch(error => { // Catches ANY rejection in the chain
console.error('Something failed:', error.message);
// If you want the chain to continue, return a value here
return []; // Fallback value
})
.finally(() => { // ALWAYS runs, regardless of success/failure
console.log('Request complete');
loadingIndicator.hide(); // Cleanup code goes here
});
// Key insight: .catch() is also a .then(undefined, onRejected)
// So it returns a new Promise, allowing chaining to continue!
Common Promise Patterns
// Pattern 1: Parallel execution (all start immediately)
Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchPreferences(userId),
])
.then(([user, orders, prefs]) => {
// All resolved! Data arrives in order.
renderDashboard({ user, orders, prefs });
})
.catch(err => {
// ANY one rejects → entire Promise.all rejects
console.error('Failed to load dashboard:', err);
});
// Pattern 2: Race (first to settle wins)
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
);
Promise.race([fetchData(url), timeout])
.then(data => console.log(data))
.catch(err => console.error(err)); // Could be timeout OR fetch error
// Pattern 3: AllSettled (wait for ALL, collect results/errors)
Promise.allSettled([
fetch('/api/a'),
fetch('/api/b'),
fetch('/api/c'), // This might fail
])
.then(results => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`API ${i}:`, result.value);
} else {
console.warn(`API ${i} failed:`, result.reason);
}
});
});
// Pattern 4: Any (first SUCCESS wins)
Promise.any([
fetchFromPrimary(url),
fetchFromBackup(url),
fetchFromCache(url),
])
.then(data => console.log('Got data from somewhere!'))
.catch(err => {
// All rejected (AggregateError with all reasons)
console.error('All sources failed');
});
// Pattern 5: Sequential execution (each waits for previous)
async function processItemsSequentially(items) {
const results = [];
for (const item of items) {
const result = await processItem(item); // Wait for each
results.push(result);
}
return results;
}
// vs parallel (all at once):
async function processItemsParallel(items) {
return Promise.all(items.map(item => processItem(item)));
}
Async/Await: Syntactic Sugar That Changes Everything
Basic Syntax
// async function ALWAYS returns a Promise
async function getUser(id) {
// Inside async functions, use await instead of .then()
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
// This reads TOP TO BOTTOM like synchronous code!
// No nesting, no .then() chains, no callback pyramids
return user;
// Implicitly wrapped in Promise.resolve(user)
}
// Calling an async function always returns a Promise
getUser(123).then(user => console.log(user));
// You can also destructure the await:
const { name, email } = await getUser(123);
// Works in arrow functions too:
const getData = async () => {
const res = await fetch('/data');
return res.json();
};
Error Handling with Try/Catch
// This is where async/await REALLY shines over .then().catch()
async function robustFetch(url) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
// HTTP errors (4xx, 5xx) do NOT reject! Handle explicitly.
const errorBody = await response.json().catch(() => ({}));
throw new FetchError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
errorBody
);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new FetchError('Request timed out', 408);
}
if (error.code === 'ECONNREFUSED') {
throw new FetchError('Server unreachable', 503);
}
// Re-throw unknown errors
throw error;
}
}
class FetchError extends Error {
constructor(message, status, body) {
super(message);
this.name = 'FetchError';
this.status = status;
this.body = body;
}
}
// Usage:
try {
const data = await robustFetch('/api/data');
console.log(data);
} catch (error) {
if (error instanceof FetchError) {
if (error.status === 408) showTimeoutMessage();
else if (error.status === 404) showNotFound();
else showError(error.message);
} else {
showError('Unexpected error');
}
}
Advanced Patterns
// Pattern 1: Retry with exponential backoff
async function retry(fn, maxAttempts = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < maxAttempts) {
const delay = baseDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s
console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
throw lastError; // All attempts exhausted
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// Usage:
const data = await retry(() => fetchAPI('/expensive-operation'));
// Pattern 2: Concurrent with concurrency limit
async function concurrentMap(items, fn, concurrency = 5) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex], currentIndex);
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, worker);
await Promise.all(workers);
return results;
}
// Process 100 API calls, 5 at a time:
const results = await concurrentMap(urls, url => fetch(url).then(r => r.json()), 5);
// Pattern 3: Request caching (deduplicate in-flight requests)
const pendingRequests = new Map();
function cachedFetch(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // Return existing Promise
}
const promise = fetch(url)
.then(res => res.json())
.finally(() => pendingRequests.delete(url));
pendingRequests.set(url, promise);
return promise;
}
// Multiple components can call cachedFetch simultaneously,
// but only ONE actual network request is made.
// Pattern 4: Timeout wrapper
async function withTimeout(promise, ms, message = 'Operation timed out') {
const timer = new Promise((_, reject) =>
setTimeout(() => reject(new Error(message)), ms)
);
return Promise.race([promise, timer]);
}
// Usage:
const user = await withTimeout(fetchUser(id), 3000, 'User lookup timed out');
// Pattern 5: Async iterator (for streaming/pagination)
async function* paginate(endpoint, pageSize = 50) {
let page = 1;
while (true) {
const response = await fetch(`${endpoint}?page=${page}&size=${pageSize}`);
const { items, hasMore } = await response.json();
yield* items; // Yield each item individually
if (!hasMore || items.length === 0) break;
page++;
}
}
// Usage with for-await-of:
for await (const item of paginate('/api/users')) {
console.log(item.name);
// Processes items as they arrive, not after ALL pages load
}
Common Mistakes & Anti-Patterns
// ❌ Mistake 1: Forgetting await in loops
// This fires ALL requests simultaneously without waiting:
urls.forEach(async url => {
const data = await fetch(url); // Each runs independently
processData(data);
});
// Fix: Use for...of or Promise.all
// ❌ Mistake 2: Fire-and-forget (unhandled promise rejection)
async function updateUser(id) {
await fetch(`/api/users/${id}`, { method: 'PATCH', body: {...} });
// What if this fails? Silent failure!
}
updateUser(123); // No .catch(), no await → unhandled rejection!
// Fix: Always handle or at least suppress intentionally:
updateUser(123).catch(console.error);
// ❌ Mistake 3: Unnecessary async
async function getValue() {
return 42; // Wrapping synchronous value in Promise unnecessarily
}
// Fix: Just return the value directly (unless API requires Promise)
// ❌ Mistake 4: try/catch around everything
try {
const a = await syncFunction(); // Not needed for sync code
const b = await anotherSync();
} catch (e) { ... }
// ❌ Mistake 5: Mixing callbacks and promises
fs.readFile('file.txt', (err, data) => {
// Now you're back in callback land inside an async function
});
// Fix: Use fs.promises (or util.promisify):
import { readFile } from 'fs/promises';
const data = await readFile('file.txt', 'utf8'); // Clean!
// ❌ Mistake 6: Awaiting inside map without returning promises
const results = items.map(async item => {
return await process(item); // Returns array of Promises, NOT results!
});
// results is [Promise, Promise, ...] not [result, result, ...]
// Fix: Use Promise.all:
const results = await Promise.all(items.map(item => process(item)));
Performance Tips
// Tip 1: Parallel > Sequential when order doesn't matter
// Slow: Each request waits for the previous
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
// Fast: All fire at once
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// Total time = max(a, b, c) instead of a + b + c
// Tip 2: Don't await outside try/catch if you want partial success
const results = await Promise.allSettled(
items.map(item => riskyOperation(item))
);
const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failures = results.filter(r => r.status === 'rejected').map(r => r.reason);
// Tip 3: Lazy evaluation for expensive operations
function lazyAsync(fn) {
let cache;
return () => cache ??= fn(); // Compute once, reuse forever
}
const heavyData = lazyAsync(() => fetchExpensiveData());
// Only actually fetches when first awaited
// Tip 4: Use AbortController for cancellable operations
function cancellableFetch(url, signal) {
return fetch(url, { signal }); // Pass signal through
}
const controller = new AbortController();
const promise = cancellableFetch(url, controller.signal);
// Later, if user navigates away:
controller.abort(); // Cancels the fetch!
// Tip 5: Batch small operations
async function batchWrite(items, batchSize = 10) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
await db.batchInsert(batch); // One DB round-trip per batch
}
}
What's your favorite async pattern? Which one took you longest to understand?
Follow @armorbreak for more practical developer guides.
Top comments (0)