Last Tuesday I was reviewing a pull request from a junior dev on our team. The function was 80 lines long. Forty of those lines were try-catch blocks nested three levels deep. The actual business logic -- maybe 15 lines -- was buried inside like a Russian doll. I left a comment: "let me show you some other ways to do this." Then I realized I should probably write it all down.
The Nesting Problem
Here's what most async code looks like when you only know one pattern:
async function getUserData(userId) {
try {
const user = await fetchUser(userId);
try {
const posts = await fetchPosts(user.id);
try {
const comments = await fetchComments(posts[0].id);
return { user, posts, comments };
} catch (err) {
console.error('Failed to fetch comments:', err);
}
} catch (err) {
console.error('Failed to fetch posts:', err);
}
} catch (err) {
console.error('Failed to fetch user:', err);
}
}
It works. But it's callback hell wearing a trench coat. Each async step needs its own catch because you want different error messages. So the nesting grows and grows. I've seen production code nested six levels deep like this. Nobody enjoyed debugging it.
The other problem with this shape of code is that control flow becomes invisible. If the comments fetch fails, what gets returned? undefined, implicitly, because neither the outer try blocks nor the function itself have a return after the catch. You end up with functions that silently return nothing on failure, and the calling code does not know whether it got no data because of an error or because there genuinely was not any data. I have debugged that exact ambiguity more times than I would like to admit. The junior dev on my team had this exact issue -- three of the five callers of that function were checking for undefined as if it meant "no data found" when it actually meant "something broke silently." That kind of confusion spreads through a codebase fast if you do not address the error handling patterns early.
Pattern 1: Tuple Returns (Stolen From Go)
Go doesn't have exceptions. Every function returns (result, error). I hated this when I first saw it. Then I tried it in JavaScript and I'm a convert.
async function to(promise) {
try {
const result = await promise;
return [null, result];
} catch (error) {
return [error, null];
}
}
// Usage
async function getUserData(userId) {
const [userErr, user] = await to(fetchUser(userId));
if (userErr) return handleError('User fetch failed', userErr);
const [postsErr, posts] = await to(fetchPosts(user.id));
if (postsErr) return handleError('Posts fetch failed', postsErr);
const [commentsErr, comments] = await to(fetchComments(posts[0].id));
if (commentsErr) return handleError('Comments fetch failed', commentsErr);
return { user, posts, comments };
}
Flat. No nesting. Each error gets a clear label. And you can handle each failure differently -- maybe a missing user is fatal but missing comments is fine, you just return null for that field.
I named the helper to because it's short and I type it fifty times a day. Some people call it handle or safe. Doesn't matter. The pattern is what counts.
One thing I want to call out: the beauty of this pattern is that errors become values. They are data you can inspect, pass around, log, and make decisions about -- rather than an invisible control flow jump that skips over your code. When errors are values, your logic reads top to bottom, which is how my brain wants to follow code. There is an npm package called await-to-js that does essentially this same thing if you do not want to write the helper yourself, but honestly it is so small I just copy it into every project.
Pattern 2: Typed Errors
Generic errors are useless when you need to decide what to do. "Error: something went wrong" -- great, thanks, very helpful.
I started creating specific error classes a couple years ago and it changed how we handle failures across our whole app:
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
this.statusCode = 400;
}
}
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}
class AuthenticationError extends Error {
constructor() {
super('Authentication required');
this.name = 'AuthenticationError';
this.statusCode = 401;
}
}
Now your catch blocks can make real decisions:
try {
await createUser(data);
} catch (err) {
if (err instanceof ValidationError) {
showFieldError(err.field, err.message);
} else if (err instanceof NotFoundError) {
redirectTo404();
} else {
reportToErrorTracking(err);
}
}
The downstream benefit is huge. Our Express error middleware checks err.statusCode and automatically sends the right HTTP response. Our frontend checks err.name and shows the right UI. No more string matching on error messages.
One lesson I learned the hard way: always set the name property in your custom error classes. When errors get serialized and deserialized across network boundaries -- say, between a microservice and an API gateway, or between your server and client via JSON -- you lose the prototype chain. instanceof will not work anymore. But err.name === 'ValidationError' still works because it is just a string property. In our codebase we check instanceof first (it is faster and more type-safe), but the middleware falls back to checking err.name for errors that arrived via HTTP.
function isNotFoundError(err) {
return err instanceof NotFoundError || err.name === 'NotFoundError';
}
Small utility, but it saved us from a class of bugs where the API returned a 404 error object and the client-side catch block did not recognize it.
Pattern 3: Let Everything Run, Sort It Out After
We had a dashboard that loaded user profile, posts, and notifications in parallel. Standard Promise.all. Worked great until the notifications service went down -- and suddenly the entire page was blank. Promise.all rejects on the first failure, so one bad service killed three good requests.
Promise.allSettled fixed it in five minutes:
const results = await Promise.allSettled([
fetchUserProfile(userId),
fetchUserPosts(userId),
fetchUserNotifications(userId)
]);
const [profile, posts, notifications] = results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
console.warn(`Operation ${index} failed:`, result.reason);
return null;
});
// Page renders with whatever data we got
renderDashboard({ profile, posts, notifications });
Now if notifications fail, the page still loads with profile and posts. The notifications panel shows a "couldn't load" message. Users barely notice. Way better than a blank screen.
I have since written a small helper that makes the allSettled result easier to work with, because manually checking status === 'fulfilled' on every result gets old fast:
function settledResults(results) {
return results.map(r => {
if (r.status === 'fulfilled') return { data: r.value, error: null };
return { data: null, error: r.reason };
});
}
const [profile, posts, notifications] = settledResults(
await Promise.allSettled([
fetchUserProfile(userId),
fetchUserPosts(userId),
fetchUserNotifications(userId)
])
);
if (profile.error) {
// handle specifically
}
renderDashboard({
profile: profile.data,
posts: posts.data,
notifications: notifications.data,
});
Same idea, friendlier shape. I find .data and .error much more readable than .status and .value/.reason.
Pattern 4: Retry Because Networks Are Unreliable
Network requests fail. DNS blips, load balancer hiccups, brief outages during deployments. Showing an error on the first failure is often premature. A retry two seconds later usually works.
async function withRetry(fn, maxRetries = 3, baseDelay = 1000) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries) throw err;
const delay = baseDelay * Math.pow(2, attempt);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const data = await withRetry(() => fetch('https://api.example.com/data'), 3, 500);
The exponential backoff is important. First retry after 500ms, then 1000ms, then 2000ms. You don't want to hammer a struggling service with rapid retries -- that just makes the outage worse.
We added this to our API client layer and our error rate in monitoring dropped noticeably. Most transient failures resolve themselves within a second or two.
One addition I made after we started using this in production: not all errors deserve a retry. If the server returns a 400 (bad request) or 422 (validation error), retrying is pointless -- you are going to get the same response. Only transient errors like 500, 502, 503, and network timeouts should trigger retries.
async function withSmartRetry(fn, { maxRetries = 3, baseDelay = 1000, retryable = isTransientError } = {}) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const shouldRetry = attempt < maxRetries && retryable(err);
if (!shouldRetry) throw err;
const jitter = Math.random() * 200;
const delay = baseDelay * Math.pow(2, attempt) + jitter;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
function isTransientError(err) {
if (err.name === 'TypeError') return true; // network failure
if (err.status >= 500) return true;
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') return true;
return false;
}
Notice the jitter -- a small random addition to the delay. Without it, if 50 clients all fail at the same time and retry with the same backoff, they all hit the server simultaneously again. The jitter spreads them out. It is a small detail, but it matters at scale.
When I Mix and Match
These patterns aren't mutually exclusive. In our production code I'll often combine several:
- Tuple returns for sequential operations where each step depends on the previous one
- Custom error classes everywhere, so any catch block downstream can make smart decisions
Promise.allSettledfor parallel fetches where partial data is acceptable- Retry wrapper around external API calls specifically (not internal logic -- if your own code throws, retrying won't help)
The real skill is reading the situation and picking the right tool. I have a rough mental checklist I run through when I am writing a new async function. Sequential and each step matters? Go-style tuples. Parallel and partial failure is okay? allSettled. Calling a flaky third-party service? Retry. Need to react differently to different failures? Typed errors.
The Safety Net: Global Error Handlers
No matter how careful you are with per-function error handling, something will slip through. Unhandled promise rejections are the async equivalent of uncaught exceptions, and they will crash your Node process (as of Node 15+, unhandled rejections terminate the process by default).
I always add these two handlers at the entry point of every Node application I work on:
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled promise rejection', {
reason: reason instanceof Error ? reason.stack : reason,
});
// Do not exit -- but send to error tracking so we know about it
errorTracker.captureException(reason);
});
process.on('uncaughtException', (err) => {
logger.fatal('Uncaught exception, shutting down', { error: err.stack });
errorTracker.captureException(err);
// Give the error tracker time to flush, then exit
setTimeout(() => process.exit(1), 1000);
});
These are not substitutes for proper error handling in your functions. They are a last-resort safety net. But they have saved us from silent failures more than once. We had a case where a background job was failing every night at 2am due to an unhandled rejection, and nobody knew until we added the unhandledRejection handler and started seeing alerts. Within the first week of adding it, we caught three separate unhandled rejections that had been silently failing for months. That alone justified the five minutes it took to add those handlers.
What I Haven't Figured Out Yet
Honestly, there's still stuff I'm uncertain about. Like: what's the right retry strategy when you're inside a database transaction? Do you retry the whole transaction or just the failed query? And how do you handle errors in long-running streams where you can't just return an error to the caller because the response is already being sent? I've got half-solutions for both of these but nothing I'm really happy with. If anyone's cracked these, I'd genuinely love to hear about it.
Comments (2)
The Go-style error return pattern is incredible. Just refactored a 200-line controller using this approach and the code is so much cleaner now. Wish I learned this sooner.
Promise.allSettled is underused. We had a dashboard that would completely break if one API call failed. Switching to allSettled with graceful fallbacks fixed the user experience overnight.