Mastering Async/Await and Error Handling in JavaScript
When I first saw async/await, I thought it was magic. Write asynchronous code that looks synchronous? Sign me up. But after shipping a few bugs to production, I realized the "magic" was hiding complexity I still needed to understand.
The syntax is simple. The mental model is not. Let me share what took me a while to figure out.
What async/await Actually Does
Here's the thing: async/await is just syntax sugar over Promises. When you mark a function async, it automatically returns a Promise. When you await something, you're just pausing until that Promise resolves.
These two functions do exactly the same thing:
// Promise chain
function getUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(user => {
console.log(user);
return user;
});
}
// async/await
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
console.log(user);
return user;
}
The second version is easier to read, but it's not doing anything different under the hood. Understanding this helps when things go wrong.
The Go-Style Error Handling Pattern
Try/catch everywhere gets noisy. I started using a pattern inspired by Go, where errors are returned as values instead of thrown:
async function safeAwait(promise) {
try {
const result = await promise;
return [null, result];
} catch (error) {
return [error, null];
}
}
// Usage
async function loadProfile(userId) {
const [error, user] = await safeAwait(fetchUser(userId));
if (error) {
showError('Could not load profile');
return;
}
renderProfile(user);
}
What I like about this: you can't accidentally forget to handle the error. The destructuring forces you to acknowledge it exists. And your happy-path code isn't buried inside a try block.
The forEach Disaster
I've debugged this exact issue at least five times across different codebases:
async function uploadFiles(files) {
files.forEach(async (file) => {
await uploadFile(file);
console.log('Uploaded:', file.name);
});
console.log('All done!');
}
"All done!" logs immediately. The uploads happen... eventually... in the background. Nobody waits for them.
forEach doesn't know about Promises. It just fires callbacks and moves on. Use a for loop instead:
async function uploadFiles(files) {
for (const file of files) {
await uploadFile(file);
console.log('Uploaded:', file.name);
}
console.log('All done!');
}
Or if order doesn't matter, use Promise.all:
async function uploadFiles(files) {
await Promise.all(files.map(file => uploadFile(file)));
console.log('All done!');
}
Serial vs Parallel: Know the Difference
This is probably the most common performance issue I see:
// ❌ Serial - each waits for the previous
async function loadDashboard(userId) {
const user = await getUser(userId); // 200ms
const posts = await getPosts(userId); // 300ms
const notifications = await getNotifications(userId); // 100ms
// Total: 600ms
}
These requests don't depend on each other. Why wait?
// ✅ Parallel - all at once
async function loadDashboard(userId) {
const [user, posts, notifications] = await Promise.all([
getUser(userId),
getPosts(userId),
getNotifications(userId)
]);
// Total: 300ms (slowest request)
}
I've seen pages go from 2+ second load times to under 500ms just by fixing this pattern.
When Promise.all Bites You
Promise.all is fail-fast. If any promise rejects, the whole thing rejects immediately. Sometimes that's not what you want.
For a dashboard where some widgets are optional, use Promise.allSettled:
const results = await Promise.allSettled([
getUser(userId),
getPosts(userId),
getNotifications(userId) // This might fail
]);
// results[0] = { status: 'fulfilled', value: {...} }
// results[1] = { status: 'fulfilled', value: [...] }
// results[2] = { status: 'rejected', reason: Error }
const user = results[0].status === 'fulfilled' ? results[0].value : null;
const posts = results[1].status === 'fulfilled' ? results[1].value : [];
// Show error badge on notifications widget if it failed
Retry with Backoff
Networks are flaky. Don't give up on the first failure:
async function fetchWithRetry(url, maxAttempts = 3) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxAttempts - 1) throw error;
const delay = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s
await new Promise(r => setTimeout(r, delay));
}
}
}
The exponential backoff is important—you don't want to hammer a struggling server with rapid retries.
Quick Checklist
Things I watch for in code review:
- Missing await: Called an async function without awaiting it? That's a bug waiting to happen.
- forEach with async: Almost always wrong. Use for...of or Promise.all.
- Sequential awaits: Could these run in parallel? Usually yes.
- Empty catch blocks: At minimum, log the error. Silent failures are the worst.
- Mixing .then() and await: Pick one style per function. Mixing is confusing.
async/await makes async code look simple. But the underlying complexity is still there. Understand what's happening beneath the syntax, and you'll write better code.