Async Error Handling Patterns
The Silent Failure That Cost $2 Million
A fintech startup had this code running in production for six months:
async function syncTransactions(userId) {
const accounts = await fetchAccounts(userId);
for (const account of accounts) {
processAccount(account);
}
}
Spot the bug? processAccount is async, but there's no await. It returns a promise that nobody catches. When it throws, the error vanishes into the void. No alert fires. No log entry. Transactions silently fail to sync. They discovered it when a user noticed $2M in missing records.
This is the floating promise problem, and it's the most dangerous async error pattern in JavaScript. Let's make sure you never ship it.
The Floating Promise Problem
A floating promise is like sending a letter without a return address. If something goes wrong at the destination, nobody can tell you. The promise resolves or rejects, but since nothing is listening, errors just... disappear.
There are three common ways promises float:
// 1. Forgetting await in async functions
async function save() {
validate(); // if validate() is async, its errors vanish
await persist();
}
// 2. Fire-and-forget in event handlers
button.addEventListener('click', () => {
submitForm(); // async function, no await, no .catch()
});
// 3. Array methods that don't await
items.forEach(async (item) => {
await processItem(item); // each iteration creates a floating promise
});
The third one is particularly sneaky. forEach doesn't care about the return value of its callback. Even though you wrote await inside, the promises returned by each callback iteration are never collected or awaited.
Fixing Floating Promises
// Fix 1: Always await async calls
async function save() {
await validate();
await persist();
}
// Fix 2: Attach .catch() for fire-and-forget
button.addEventListener('click', () => {
submitForm().catch(handleError);
});
// Fix 3: Use Promise.all with map, never forEach
await Promise.all(items.map(item => processItem(item)));
- 1Never call an async function without await or .catch() unless you intentionally want fire-and-forget
- 2Never use forEach with async callbacks — use for...of or Promise.all with map
- 3Enable the no-floating-promises ESLint rule (from typescript-eslint) to catch these at compile time
try/catch With async/await: The Gotchas
The try/catch pattern looks straightforward, but there are sharp edges most developers miss.
Gotcha 1: try/catch Only Catches Awaited Rejections
async function loadData() {
try {
const a = fetchA(); // no await — rejection floats
const b = await fetchB();
return [a, b];
} catch (err) {
// Only catches errors from fetchB
// fetchA's rejection is never caught here
}
}
Gotcha 2: Error Swallowing With Empty catch
async function getUser(id) {
try {
return await fetchUser(id);
} catch {
// Swallowed. Caller has no idea something failed.
// At minimum, return null and document it:
}
}
// Better:
async function getUser(id) {
try {
return await fetchUser(id);
} catch (err) {
logger.error('Failed to fetch user', { userId: id, error: err });
return null;
}
}
Gotcha 3: return vs return await
This one bites even senior engineers:
async function getUser() {
try {
return fetchUser(); // no await — catch block never fires on rejection!
} catch (err) {
handleError(err); // this NEVER runs
}
}
When you return a promise without await, the function returns the promise directly. The try/catch exits before the promise settles. You need return await inside try/catch:
async function getUser() {
try {
return await fetchUser(); // await means catch works
} catch (err) {
handleError(err);
}
}
Error.cause: Building Error Chains
Before Error.cause (ES2022), wrapping errors meant losing the original stack trace:
// Old approach — original error context is lost
try {
await database.query(sql);
} catch (err) {
throw new Error('Database query failed'); // where did err go?
}
Error.cause lets you chain errors while preserving the full trace:
try {
await database.query(sql);
} catch (err) {
throw new Error('Failed to load user profile', { cause: err });
}
Now the caller can inspect the entire chain:
try {
await loadUserProfile(id);
} catch (err) {
console.log(err.message); // "Failed to load user profile"
console.log(err.cause.message); // "connection refused" (original DB error)
console.log(err.cause.stack); // full original stack trace
}
Building a Deep Cause Chain
In production services, errors traverse multiple layers. Error.cause lets you build a chain that tells the full story:
async function fetchUserProfile(userId) {
try {
return await apiClient.get(`/users/${userId}`);
} catch (err) {
throw new Error(`API request failed for user ${userId}`, { cause: err });
}
}
async function renderDashboard(userId) {
try {
const profile = await fetchUserProfile(userId);
return buildDashboard(profile);
} catch (err) {
throw new Error('Dashboard render failed', { cause: err });
}
}
When something goes wrong, you get: Dashboard render failed → API request failed for user 42 → TypeError: Failed to fetch with complete stack traces at every level.
AggregateError: When Multiple Things Fail
Promise.any rejects with an AggregateError when ALL promises reject. But you can also create AggregateError yourself for batch operations:
async function validateAll(inputs) {
const results = await Promise.allSettled(
inputs.map(input => validate(input))
);
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
if (errors.length > 0) {
throw new AggregateError(errors, `${errors.length} validations failed`);
}
}
Handling an AggregateError:
try {
await validateAll(formInputs);
} catch (err) {
if (err instanceof AggregateError) {
for (const validationError of err.errors) {
showFieldError(validationError);
}
}
}
AggregateError.errors is a plain Array — but don't assume it's frozen. It's mutable, so defensive code should copy it before iterating if mutation is a concern (e.g., [...err.errors].forEach(...)). Also, an AggregateError with zero errors is valid — new AggregateError([]) doesn't throw. Always check err.errors.length before acting on it.
Unhandled Rejection Tracking
In production, you need a safety net. The unhandledrejection event catches promises that nobody handled:
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
logger.error('Unhandled promise rejection', {
message: error?.message ?? String(error),
stack: error?.stack,
cause: error?.cause,
});
event.preventDefault();
});
In Node.js:
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection', { reason });
process.exit(1);
});
The global handler is a safety net, not an error handling strategy. If you're relying on unhandledrejection to catch errors, your code has floating promises. Fix those first. The global handler exists for errors you didn't anticipate.
Typed Error Patterns
In production, you need to distinguish between different failure modes. Plain Error objects don't give you much to work with:
class NetworkError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'NetworkError';
}
}
class TimeoutError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'TimeoutError';
}
}
class ValidationError extends Error {
constructor(message, field, options) {
super(message, options);
this.name = 'ValidationError';
this.field = field;
}
}
Now your error handling becomes precise:
try {
await submitOrder(order);
} catch (err) {
if (err instanceof NetworkError) {
showRetryButton();
} else if (err instanceof TimeoutError) {
showTimeoutMessage();
} else if (err instanceof ValidationError) {
highlightField(err.field);
} else {
showGenericError();
reportToMonitoring(err);
}
}
The Result Pattern: Errors as Values
Sometimes throwing is the wrong abstraction. For expected failures (validation, not-found, permission denied), the Result pattern makes error handling explicit:
function ok(value) {
return { ok: true, value };
}
function err(error) {
return { ok: false, error };
}
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return err(new NetworkError(`HTTP ${response.status}`));
}
return ok(await response.json());
} catch (e) {
return err(new NetworkError('Request failed', { cause: e }));
}
}
const result = await fetchUser(42);
if (result.ok) {
renderProfile(result.value);
} else {
handleFailure(result.error);
}
The caller can't accidentally ignore the error — they have to check result.ok to get the value. No try/catch gymnastics, no accidental error swallowing.
| What developers do | What they should do |
|---|---|
| Using forEach with async callbacks forEach ignores the returned promises, creating floating promises whose rejections silently vanish | Use for...of or Promise.all(items.map(...)) |
| return promise inside try/catch without await Without await, the promise bypasses the catch block entirely since the function returns before the promise settles | return await promise inside try/catch |
| throw new Error(originalError.message) to wrap errors String wrapping loses the original stack trace, error type, and any custom properties. Error.cause preserves the complete chain | throw new Error('Context message', { cause: originalError }) |
| Using empty catch blocks to prevent crashes Empty catch blocks hide bugs. At minimum, log the error so you know something went wrong | Log the error, then decide: rethrow, return a fallback, or return null with documentation |
Production Error Handling Architecture
Here's a pattern that ties everything together — a layered error handling approach:
async function apiRequest(url, options = {}) {
const { retries = 3, timeout = 5000 } = options;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeout),
});
if (!response.ok) {
throw new NetworkError(`HTTP ${response.status}: ${response.statusText}`, {
cause: new Error(`HTTP error at ${url}`, { cause: { status: response.status, url } }),
});
}
return await response.json();
} catch (err) {
const isLastAttempt = attempt === retries;
const isRetryable = err instanceof NetworkError && err.cause?.status >= 500;
if (isLastAttempt || !isRetryable) {
throw new Error(`Request to ${url} failed after ${attempt} attempts`, {
cause: err,
});
}
await new Promise(r => setTimeout(r, 2 ** attempt * 100));
}
}
}
Promise.try(): Uniform Error Handling for Sync and Async
One persistent headache: wrapping a function that might be sync or async in a promise. If the function throws synchronously, Promise.resolve(fn()) doesn't catch it — the throw escapes before the promise wraps it. Promise.try() (ES2025, now baseline) solves this:
const result = await Promise.try(() => mightThrowSync());
Promise.try() calls the function and wraps both sync throws and async rejections into a rejected promise. No more try { await fn() } vs Promise.resolve().then(fn) ambiguity. It's particularly useful for library code where you don't control whether the callback is sync or async.
You're building a checkout flow that calls three microservices in sequence: inventory check, payment processing, and order creation. If payment succeeds but order creation fails, you need to refund the payment. Design the error handling strategy, including what error types you'd create and how you'd handle partial failures.