Promises, Async/Await, and Error Handling: The Complete Guide (2026)
Async JavaScript is everywhere — but most developers only scratch the surface. Here's the complete picture from callbacks to modern async patterns.
The Evolution of Async JavaScript
// Phase 1: Callbacks (the old way — avoid "callback hell")
fs.readFile('data.json', 'utf8', function(err, data) {
if (err) return console.error(err);
fs.writeFile('output.json', data, function(err) {
if (err) return console.error(err);
console.log('Done!');
// Nesting deeper with each step = callback hell!
});
});
// Phase 2: Promises (ES2015 — chainable, composable)
readFilePromise('data.json')
.then(data => writeFilePromise('output.json', data))
.then(() => console.log('Done!'))
.catch(err => console.error(err)); // Single error handler!
// Phase 3: Async/Await (ES2017 — synchronous-looking code)
async function processFile() {
try {
const data = await readFilePromise('data.json');
await writeFilePromise('output.json', data);
console.log('Done!');
} catch (err) {
console.error(err);
}
}
// This is the same as Phase 2 but reads top-to-bottom!
Promises Deep Dive
// Creating promises:
const myPromise = new Promise((resolve, reject) => {
// Async operation here:
const success = doSomething();
if (success) resolve(resultValue); // Fulfilled!
else reject(errorReason); // Rejected!
});
// Promise states:
// 1. Pending: Initial state, neither fulfilled nor rejected
// 2. Fulfilled: Operation completed successfully (.then fires)
// 3. Rejected: Operation failed (.catch fires)
// 4. Settled: Either fulfilled or rejected (final state, won't change)
// Static methods:
// Promise.resolve() / Promise.reject():
const resolved = Promise.resolve(42); // Already fulfilled
const rejected = Promise.reject(new Error('fail')); // Already rejected
// Promise.all(): Run multiple promises, wait for ALL to settle
// ⚠️ If ANY rejects → entire Promise.all rejects immediately!
const results = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchComments(id),
]);
// results = [user, posts, comments] — in order!
// Promise.allSettled(): Wait for ALL, never rejects (returns status for each)
const outcomes = await Promise.allSettled([
maybeFails(),
alsoMaybeFails(),
]);
outcomes.forEach(o => {
if (o.status === 'fulfilled') use(o.value);
else handleFailure(o.reason); // Doesn't throw!
});
// Promise.race(): Returns result/rejection of FIRST to settle
const result = await Promise.race([
fetchFromPrimary(),
timeoutAfter(3000), // Rejects after 3 seconds
]);
// Promise.any(): Returns result of FIRST to FULFILL (ignores rejections until all fail)
const firstSuccess = await Promise.any([
tryServerA(),
tryServerB(),
tryServerC(),
]); // First one that works wins! All fail → AggregateError
// Chaining: Each .then() returns a new promise
fetch('/api/user')
.then(res => res.json()) // Transform response
.then(user => { // Use transformed value
console.log(user.name);
return user.id; // Pass to next .then()
})
.then(userId => fetch(`/api/posts/${userId}`))
.then(res => res.json())
.then(posts => console.log(posts))
.catch(err => { // Catches errors from ANY step above
console.error('Failed:', err.message);
})
.finally(() => { // ALWAYS runs (success or failure)
loadingSpinner.style.display = 'none';
});
// Common mistake: Forgetting to return in .then():
fetch('/api/data')
.then(res => res.json()) // ✅ Returns promise
.then(data => { // ❌ No return! Next .then gets undefined
processData(data); // Fix: return processData(data)
})
.then(result => console.log(result)); // undefined!
Async/Await Mastery
// Basic syntax:
async function fetchData(url) {
const response = await fetch(url); // Pauses here until fetch resolves
const data = await response.json(); // Then pauses here until json parses
return data;
}
// Arrow async:
const getData = async () => { ... };
// Object method async:
const obj = { async fetch() { ... } };
// Class method async:
class Service { async load() { ... } }
// Parallel awaits vs sequential (CRITICAL performance difference):
// ❌ Sequential (SLOW — each waits for the previous):
async function getUserDataSlow(userId) {
const user = await db.users.findById(userId); // 100ms
const posts = await db.posts.findByUser(userId); // +150ms
const comments = await db.comments.findFor(posts); // +80ms
return { user, posts, comments }; // Total: ~330ms
}
// ✅ Parallel (FAST — all run simultaneously):
async function getUserDataFast(userId) {
const [user, posts, comments] = await Promise.all([
db.users.findById(userId), // 100ms
db.posts.findByUser(userId), // 150ms (runs in parallel!)
db.comments.findForPosts(userId), // 80ms (runs in parallel!)
]);
return { user, posts, comments }; // Total: ~150ms (slowest one wins)
}
// Error handling patterns:
// Pattern 1: Try/catch around everything
async function safeOperation() {
try {
const data = await riskyCall();
return { success: true, data };
} catch (err) {
logError(err);
return { success: false, error: err.message };
}
}
// Pattern 2: Individual error handling per operation
async function multiStep() {
let user, posts;
try {
user = await fetchUser(); // If this fails...
} catch (err) {
user = getDefaultUser(); // ...use fallback instead of crashing
}
try {
posts = await fetchPosts(user.id);
} catch (err) {
posts = []; // Empty array fallback
}
return { user, posts };
}
// Pattern 3: Higher-order error wrapper (reusable!)
function withFallback(fn, fallback) {
return async (...args) => {
try { return await fn(...args); }
catch (err) {
console.warn(`${fn.name} failed, using fallback:`, err.message);
return typeof fallback === 'function' ? fallback(...args) : fallback;
}
};
}
const safeFetch = withFallback(fetchData, []);
const safeUser = withFallback(getUser, { name: 'Guest' });
// Awaiting non-promise values (works fine):
const result = await 42; // Resolves immediately to 42
const result = await null; // Resolves to null
const result = await Promise.resolve(42); // Same as above
// Top-level await (in ES modules only):
// module.js (with "type": "module" in package.json):
const config = await fetchConfig(); // Works at top level!
console.log(config.apiKey); // Waits for config before continuing
Advanced Patterns
// Retry pattern with exponential backoff:
async function retry(fn, options = {}) {
const {
maxAttempts = 3,
baseDelay = 1000,
maxDelay = 30000,
backoff = 2,
retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED'],
} = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const isRetryable = retryableErrors.some(code =>
err.code === code || err.message?.includes(code)
);
if (!isRetryable || attempt === maxAttempts) throw err;
const delay = Math.min(baseDelay * Math.pow(backoff, attempt - 1), maxDelay);
console.warn(`Attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`);
await new Promise(r => setTimeout(r, delay));
}
}
}
// Usage:
const data = await retry(() => fetchFromAPI(endpoint), { maxAttempts: 5 });
// Timeout wrapper:
function withTimeout(promise, ms, error = new Error('Timeout')) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(error), ms))
]);
}
// Usage:
const user = await withTimeout(fetchUser(id), 5000, new Error('DB query timed out'));
// Request concurrency limiter:
class ConcurrencyLimiter {
constructor(maxConcurrent) {
this.max = maxConcurrent;
this.running = 0;
this.queue = [];
}
async run(fn) {
if (this.running >= this.max) {
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()();
}
}
}
}
const limiter = new ConcurrencyLimiter(5); // Max 5 concurrent requests
urls.forEach(url => limiter.run(() => fetchAndProcess(url)));
// Async iterator pattern (for streaming/pagination):
async function* paginate(apiFn, params) {
let page = 1;
while (true) {
const result = await apiFn({ ...params, page, limit: 100 });
yield result.items;
if (result.items.length < 100 || page >= result.totalPages) break;
page++;
}
}
for await (const batch of paginate(fetchUsers, {})) {
processBatch(batch); // Process 100 at a time
}
Common Mistakes & Anti-Patterns
// ❌ Anti-pattern #1: Fire-and-forget without error handling
async function updateUser(userId) {
await db.users.update(userId, { name: 'New Name' }); // If this throws...
sendEmailNotification(userId); // ...this NEVER runs!
}
// Fix: Wrap in try/catch or ensure errors propagate
// ❌ Anti-pattern #2: Unnecessary await
const userId = await req.user.id; // req.user.id is NOT a promise!
// Remove await — it adds unnecessary microtask overhead
// ❌ Anti-pattern #3: Using await inside Promise constructor
new Promise(async (resolve, reject) => { // DON'T DO THIS!
const data = await fetchData(); // Errors here won't be caught by reject!
resolve(data);
});
// Fix: Just make the outer function async instead
// ❌ Anti-pattern #4: Mixing callbacks and promises incorrectly
fs.readFile('file.txt', (err, data) => {
// This callback doesn't return anything to a promise chain
});
// Fix: Use fs.promises.readFile or util.promisify(fs.readFile)
// ❌ Anti-pattern #5: Forgetting that map + async = fire-and-forget
const users = [1, 2, 3];
const results = users.map(async id => { // Returns array of PROMISES, not values!
return await fetchUser(id);
});
// Fix: await Promise.all(users.map(async id => await fetchUser(id)))
// ❌ Anti-pattern #6: catch blocks that swallow errors silently
try { await riskyOp(); } catch (e) { /* oops */ } // Error disappears forever!
// Fix: At minimum: log it. Better: re-throw or handle properly.
What's your favorite async pattern? What async pitfall has bitten you before?
Follow @armorbreak for more practical developer guides.
Top comments (0)