Async code trips up developers of all experience levels. The reason is timing: async operations don't finish immediately, so when something goes wrong, the error surfaces in a completely different execution context than where you started. The rules you learned for synchronous code don't fully apply here.
Why regular try/catch doesn't work
This is the core thing to understand. A try/catch block only catches errors that are thrown synchronously within it. If you kick off an async operation and don't wait for it, any error it throws will be uncaught.
// Synchronous - try/catch works perfectly
function getUser() {
throw new Error('Not found');
}
try {
getUser();
} catch (error) {
console.error('Caught:', error.message); // Works
}
// Asynchronous - try/catch does NOT catch this
function getUserLater() {
setTimeout(() => {
throw new Error('Not found'); // Uncaught!
}, 1000);
}
try {
getUserLater(); // Returns immediately - nothing to catch yet
} catch (error) {
console.error('Caught:', error.message); // Never runs
}
// Error is thrown 1 second later, outside the try/catch scopeThe fix for promises and async/awaitWhat is async/await?A syntax that lets you write asynchronous code (like fetching data) in a readable, step-by-step style instead of chaining callbacks. is to bring the error handling inside the async context.
Handling errors in promiseWhat is promise?An object that represents a value you don't have yet but will get in the future, letting your code keep running while it waits. chains
When using .then() chains, always add a .catch() at the end. Without it, any rejection in the chain becomes an unhandled promise rejection.
// BAD - no error handling
fetch('/api/users')
.then(res => res.json())
.then(users => console.log(users));
// If the network request fails, you get an unhandled rejection
// GOOD - catch at the end handles all rejections in the chain
fetch('/api/users')
.then(res => res.json())
.then(users => console.log(users))
.catch(error => {
console.error('Failed to fetch users:', error.message);
});In Node.js, unhandled promise rejections produce a warning that looks like this:
UnhandledPromiseRejectionWarning: Error: ECONNREFUSED
(node:12345) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
This error originated either by throwing inside of an async function
without a catch block, or by rejecting a promise which was not handled with .catch().Newer versions of Node.js crash the process entirely on unhandled rejections. Don't leave them unhandled.
Handling errors with async/awaitWhat is async/await?A syntax that lets you write asynchronous code (like fetching data) in a readable, step-by-step style instead of chaining callbacks.
With async/await, you wrap your await calls in a try/catch block. This is the most readable approach and the one you'll see most often in modern code.
// GOOD - wraps all awaits in one try/catch
async function getUsers() {
try {
const response = await fetch('/api/users');
const users = await response.json();
return users;
} catch (error) {
console.error('Failed to load users:', error.message);
return []; // return a safe fallback so the rest of the app can continue
}
}You can also validate the response before parsing it, which gives you clearer error messages:
async function getUsers() {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('getUsers failed:', error.message);
return [];
}
}response.ok after a fetch. The promise only rejects on network failures (like no internet). A 404 or 500 response is considered a "successful" fetch, you have to check the status yourself.Common async mistakes
Forgetting await
This is the single most common async bug. Without await, you get back a Promise object, not the resolved value.
// BUG: response is a Promise, not a Response object
async function getUser(id) {
const response = fetch(`/api/users/${id}`); // Missing await!
return response.json(); // TypeError: response.json is not a function
}
// FIXED
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
return await response.json();
}Sequential awaits when parallel is possible
Each await pauses execution until that operation finishes. If the operations are independent of each other, you're waiting unnecessarily.
// SLOW: waits for each request to finish before starting the next
async function loadDashboard() {
const users = await fetch('/api/users').then(r => r.json());
const posts = await fetch('/api/posts').then(r => r.json());
const stats = await fetch('/api/stats').then(r => r.json());
// If each request takes 300ms, total = ~900ms
return { users, posts, stats };
}
// FAST: all three requests start at the same time
async function loadDashboard() {
const [users, posts, stats] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/stats').then(r => r.json()),
]);
// Total = ~300ms (as fast as the slowest request)
return { users, posts, stats };
}Using forEach with async functions
Array.forEach doesn't know or care about promises. If you pass it an async callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs., errors from inside that callback are swallowed silently.
// BAD: errors are lost, and there's no way to know when all updates are done
async function updateAll(users) {
users.forEach(async (user) => {
await updateUser(user); // Errors here are not caught
});
}
// GOOD option 1: sequential with for...of (easier to reason about)
async function updateAll(users) {
for (const user of users) {
try {
await updateUser(user);
} catch (error) {
console.error(`Failed to update user ${user.id}:`, error.message);
}
}
}
// GOOD option 2: parallel with Promise.all (faster)
async function updateAll(users) {
await Promise.all(
users.map(user =>
updateUser(user).catch(error =>
console.error(`Failed to update user ${user.id}:`, error.message)
)
)
);
}Async stack traces
Modern JavaScript engines preserve async stack traces when you use await consistently. If you skip await anywhere in the chain, the trace gets cut off and becomes much harder to follow.
// With proper awaits - full stack trace preserved
async function a() { await b(); }
async function b() { await c(); }
async function c() { throw new Error('Something broke'); }
a();
// Error: Something broke
// at c (app.js:3)
// at async b (app.js:2)
// at async a (app.js:1)
// Full chain visible - easy to trace// Without awaits - stack trace is truncated
function a() { b(); } // Not awaited
function b() { c(); } // Not awaited
async function c() { throw new Error('Something broke'); }
a();
// UnhandledPromiseRejectionWarning: Error: Something broke
// at c (app.js:3)
// No trace back to a() or b() - much harder to debugQuick reference
| Pattern | Correct approach |
|---|---|
async/await error handling | Wrap await calls in try/catch; return a fallback in catch |
| Promise chain error handling | Add .catch() at the end of every chain |
| Multiple independent async ops | Use Promise.all instead of sequential awaits |
| Async callbacks in loops | Use for...of with try/catch, or Promise.all with .map() |
| Debugging lost stack traces | Make sure every async call in the chain uses await |