Error handling in async code: where errors go, and where they get lost
Place catch and finally correctly, avoid forgotten awaits, know why return await matters inside try, and what browsers and Node.js do with unhandled rejections.
Comes up in: JavaScript, Coding, Debugging
Learn#
Arjun’s profile page shows a blank screen when the API is down. He wrote a try/catch with a fallback name, but the fallback never appears.
Inside an async function, await turns a rejected promise into a thrown error, so try/catch can handle it. Without await, there is nothing to catch.
const fail = async () => { throw new Error("503"); };
async function withoutAwait() {
try { return fail(); } catch { return "fallback"; }
}
async function withAwait() {
try { return await fail(); } catch { return "fallback"; }
}
try { console.log(await withoutAwait()); }
catch (e) { console.log("escaped:", e.message); }
console.log(await withAwait());Output (verified)
escaped: 503 fallback
return fail() leaves the try block before the promise rejects.
Call stack
- withoutAwait
- try { return fail() }
State
empty
Console
empty
withoutAwait() enters try and calls fail(). fail returns a promise that will reject.
Read the steps as text
- withoutAwait() enters try and calls fail(). fail returns a promise that will reject.
- return hands that promise out at once. The function leaves try, so its catch is no longer active.
- fail()'s promise rejects. The catch block never sees it; the caller gets the error.
- With return await, the function waits inside try. The rejection becomes a throw there, and catch runs.
Error propagation is how an error travels up to a handler. In async code it travels along promises, so every promise must be awaited or returned to someone who handles it.
Predict#
Predict first
A Node.js 26 script runs Promise.reject(new Error('x')) and has no handler anywhere. What happens?
Since Node.js 15, an unhandled rejection crashes the process by default, like an uncaught exception. Older Node.js only printed a warning, which is why many people still expect one. Browsers do not crash the page; they fire an unhandledrejection event and log to the console.
Interview angle#
Say it like this — about 30 seconds
“Rejections travel along promises, so a catch only works if it waits on the right promise. Inside try, I write return await, otherwise the function returns before the promise rejects and catch never runs. finally runs either way and should not return a value. In the browser, an unhandled rejection fires the unhandledrejection event; in Node.js since version 15 it crashes the process by default. I also watch for forgotten awaits, such as async callbacks passed to forEach.”
Follow-up What happens to an unhandled rejection in the browser?
unhandledrejection event, and the error is logged to the console.Follow-up return promise vs return await promise inside try?
return await lets that try’s catch and finally see the rejection; outside try they behave the same.Follow-up Why is forEach with an async callback a bug?
for...of with await, or Promise.all.Practice#
On your own
Fix four real bugs where an error is swallowed, escapes the handler, or skips cleanup.
Done when
getNamereturns “guest” when the API rejects.saveAllresolves only after every save, and rejects if one fails.loadUserlogs the error and still rejects with the same error.withLockcallsreleaseexactly once, on success and on failure.
Remember#
Try to answer each card before you open it. Then add them to your reviews.
Inside try, why use return await instead of return?
So the rejection happens inside try, and catch or finally can see it.
What does Node.js do with an unhandled rejection by default?
Crashes the process with exit code 1 (since Node.js 15).
Which browser event fires for an unhandled rejection?
unhandledrejection, on window.
Why does items.forEach(async ...) lose errors?
forEach ignores the returned promises, so nobody waits for or handles them.
Go deeper
Four layers. Stop at the one your interview needs; each ends with a quick check.
The core ideaEvery round
catch handles errors from everything before it in the chain. Put it right after a step to give that step a fallback; put it at the end to handle any failure once.await is one of the most common async bugs. The code runs on with a pending promise, and its error has no handler.Check yourself: Where should a catch go if you want a fallback for one step only?
Directly after that step, before the later steps in the chain.
How it works underneathSenior rounds
finally runs after success or failure and passes the original result through. If finally itself returns a value or throws, that replaces the original result or error, and an error can be lost. Use it only for cleanup.Check yourself: What happens if finally returns a value?
It replaces the result or swallows the error of the try block.
Edge cases and trapsWhere people slip
unhandledrejection and, if a handler comes later, rejectionhandled Source: MDN: unhandledrejection event. Node.js crashes by default since 15.0.0 Source: Node.js: --unhandled-rejections.Check yourself: Can a rejection that is handled later still be reported as unhandled?
Yes. If no handler is attached by the time the microtasks finish, it is reported; a later handler fires rejectionhandled.
In productionStaff-level answers
Turn on the no-floating-promises and no-misused-promises lint rules from typescript-eslint: they catch forgotten awaits and async callbacks passed to forEach. Report unhandled rejections to your error tracker (JS.13.02), and catch errors where you can show the user something useful.
Check yourself: Which lint rule catches promises nobody awaits?
@typescript-eslint/no-floating-promises.
Finished the five steps? Mark it, and its cards join your reviews.
Last checked 2026-10-31. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.
Interview questions this answers
- What happens to an unhandled promise rejection in the browser and in Node?
- What is the difference between
return promiseandreturn await promiseinsidetry?