This article is published in English.
Fixing Async/Await Error Handling Bugs in Node.js Production Code
Learn five common async/await error-handling mistakes in JavaScript and Node.js that cause silent failures and race conditions, plus concrete fixes.
A checkout system deployed by one team last quarter ended up double-charging customers on the same order, twice a week, for nearly a month before the issue surfaced. The root cause wasn't an unreliable payment gateway. It was a try/catch block wrapped around an await call doing precisely what it was written to do — swallow the error and carry on — while retry logic several layers higher up assumed a resolved promise automatically meant success.
Nobody introduces a bug like that on purpose. Because async/await syntax looks like ordinary synchronous code, developers naturally reason about it the same way. But the underlying error-handling model is still driven by promise rejection, microtask scheduling, and cancellation rules that don't map cleanly onto try/catch intuition. Even experienced engineers get caught out by this, often in code that already sailed through review, because these bugs only surface under concurrency or partial failures — precisely the scenarios unit tests tend to skip.
Below are five recurring mistakes found in production JavaScript and Node.js 22/24 codebases, along with fixes that hold up under real traffic.
Mistake 1: Catching Errors and Silently Continuing
The wrong way:
async function getUserProfile(userId) {
try {
const res = await fetch(`/api/users/${userId}`);
return await res.json();
} catch (err) {
console.error('Failed to fetch user', err);
return null;
}
}
async function renderDashboard(userId) {
const profile = await getUserProfile(userId);
// profile.name throws here if fetch failed — but the stack trace
// now points at renderDashboard, not at the network call that actually failed
document.title = `${profile.name}'s Dashboard`;
}
Wrapping the fetch in a blanket catch converts a specific, traceable failure (a 500 response from /api/users/42) into a vague one (profile is null). By the time an exception actually surfaces — something like TypeError: Cannot read properties of null — it happens far away from the real cause, with nothing hinting that a network request was ever involved. In production, that gap is the difference between a quick five-minute fix and a two-hour trawl through logs.
Correct usage:
async function getUserProfile(userId) {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
cause: { status: res.status, userId },
});
}
return res.json();
}
async function renderDashboard(userId) {
try {
const profile = await getUserProfile(userId);
document.title = `${profile.name}'s Dashboard`;
} catch (err) {
console.error('Dashboard render failed', err, err.cause);
showErrorBanner('Could not load your profile. Please retry.');
}
}
The function that understands the network request the least — the fetching function — should simply throw when something goes wrong. The decision about what "failure" actually means, whether that's displaying a banner, retrying the request, or falling back to cached data, belongs to whichever function actually has a recovery strategy. Using Error.cause (introduced in ES2022 and available in every evergreen browser plus Node.js 16.9 and later) preserves the structured context instead of collapsing it into an opaque string message.
Mistake 2: Reaching for Promise.all When You Need Promise.allSettled
The wrong way:
async function loadDashboardData(userId) {
const [profile, orders, recommendations] = await Promise.all([
fetchProfile(userId),
fetchOrders(userId),
fetchRecommendations(userId), // a third-party service with a 2% error rate
]);
return { profile, orders, recommendations };
}
Promise.all is intentionally fail-fast: as soon as one of the promises rejects, the entire call rejects, discarding the results of the other calls even if they already resolved successfully. So if fetchRecommendations times out, the user loses access to their profile and order history too, despite both of those requests having actually completed without error. This pattern is behind a large share of "flaky dashboard" complaints filed against code that is otherwise perfectly sound.
Correct usage:
async function loadDashboardData(userId) {
const results = await Promise.allSettled([
fetchProfile(userId),
fetchOrders(userId),
fetchRecommendations(userId),
]);
const [profile, orders, recommendations] = results.map((r) =>
r.status === 'fulfilled' ? r.value : null
);
results.forEach((r, i) => {
if (r.status === 'rejected') {
logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
}
});
return { profile, orders, recommendations };
}
A useful rule of thumb: reach for Promise.all only when every operation is truly required and a partial outcome would be meaningless, such as three writes that make up a single atomic transaction. Reach for Promise.allSettled whenever the operations are independent of one another and a partially degraded UI beats an empty one — which, in practice, covers most dashboards, data-aggregation endpoints, and batch-processing jobs.
Mistake 3: Launching Async Work and Never Handling Its Failure
The problematic pattern:
function handleClick(event) {
logAnalyticsEvent(event); // returns a promise, nobody awaits it
updateUI();
}
Here, logAnalyticsEvent is declared async, which means it hands back a promise whether or not anyone cares. Since nobody attaches a .catch, that promise's rejection has nowhere to go. If the analytics service happens to be unreachable, the rejection turns into an unhandled promise rejection — which some browsers quietly ignore, but which crashes the process outright in Node.js, where unhandled rejections have terminated the process by default since Node.js 15. Inside a request handler, this means every user hitting that route gets a 500 error, all because of a background call that nothing was even waiting on.
A safer version:
function handleClick(event) {
void logAnalyticsEvent(event).catch((err) => {
logNonFatal('analytics', err);
});
updateUI();
}
Prefixing the call with void tells both future maintainers and tooling such as @typescript-eslint/no-floating-promises that skipping the await here is deliberate, not an oversight. It's the .catch block, though, that does the real work of stopping a background failure from becoming a foreground outage. If your code runs on Node.js, it's also worth adding a top-level process.on('unhandledRejection', ...) listener as a last line of defense — but treat it as a safety net, not your main strategy. Its job is to log the problem and alert someone, not to compensate for a .catch you forgot to write.
Mistake 4: Letting Async Calls Race Without Cancelling the Ones That Lose
The problematic pattern:
async function search(query) {
const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
renderResults(results);
}
searchInput.addEventListener('input', (e) => search(e.target.value));
Every keystroke triggers a fresh network request. Because responses aren't guaranteed to arrive in the order they were sent, if the lookup for "reac" happens to finish after the lookup for "react", the outdated results end up overwriting the correct ones on screen. This isn't some rare corner case — on a slow or throttled connection it happens all the time, and it's one of the most frequent sources of "the search box shows the wrong results" bug reports in any app with a live-search input.
A safer version:
let activeController = null;
async function search(query) {
activeController?.abort();
activeController = new AbortController();
const { signal } = activeController;
try {
const res = await fetch(`/api/search?q=${query}`, { signal });
const results = await res.json();
renderResults(results);
} catch (err) {
if (err.name !== 'AbortError') {
logNonFatal('search', err);
}
}
}
searchInput.addEventListener('input', (e) => search(e.target.value));
AbortController, available natively in Node.js since version 15 and supported by every modern fetch implementation, converts an implicit race into an explicit, controlled one: the most recent request wins because every earlier one gets actively aborted, not because it happened to win a timing gamble. The same idea applies when a component unmounts partway through a request in React, Angular, or Vue — cancel the request during cleanup instead of just hoping its response never lands.
Mistake 5: Relying on finally in Place of Proper Error Handling
The problematic pattern:
async function processOrder(order) {
let lock;
try {
lock = await acquireLock(order.id);
await chargeCard(order);
await updateInventory(order);
} finally {
releaseLock(lock);
}
}
At first glance this seems safe, since finally always executes and should always release the lock. But if acquireLock itself throws before assigning anything, lock remains undefined by the time finally runs. Calling releaseLock(undefined) at that point either throws a second, unrelated error that hides the original failure, or does nothing — depending on how the locking mechanism is implemented — while a completely different lock stays held indefinitely. finally only promises that its block will run; it says nothing about whether the cleanup logic inside is actually valid for every path that could have led there.
A safer version:
async function processOrder(order) {
const lock = await acquireLock(order.id); // outside try — nothing to release yet
try {
await chargeCard(order);
await updateInventory(order);
} finally {
await releaseLock(lock);
}
}
Only the operations that depend on a lock actually having been acquired should sit inside the try block that triggers cleanup. It's a small structural change, but it separates cleanup that runs precisely when it's warranted from cleanup that can fire under conditions where it would corrupt state instead of fixing it.
The Recap
Async/await never eliminated JavaScript's error-handling difficulties — it just disguised them behind syntax that reads like synchronous code. Five habits address most of the production issues that disguise creates: throw well-defined error types instead of silencing them, using Error.cause to preserve the original failure; use Promise.allSettled when the underlying operations don't depend on each other; never leave a fire-and-forget promise without a .catch; cancel outdated async work using AbortController rather than trusting network timing to sort itself out; and keep finally blocks limited to cleaning up state that was genuinely acquired.
None of these practices are unusual or advanced. What they represent is the gap between async code that holds up under real-world network conditions and async code that only ever worked during a demo.