What you'll learn
Quick Answer
try/catch only catches errors thrown while the code inside it is running on the current call stack. A function that returns a promise finishes immediately, so its later rejection escapes the catch unless you await it. Callbacks passed to setTimeout or event listeners run on a fresh stack and are never covered either. Custom Error subclasses let you branch on error type with instanceof, and a return inside finally silently overrides both the returned value and any thrown error.
try/catch wraps a stack, not a block of time
The mental model most people carry is that try protects everything written between its braces. It does not. It protects everything that runs on the current call stack while control is inside the block. The moment a function schedules work for later and returns, the block is finished and the safety net is gone.
Here is the version that catches nothing at all:
try {
setTimeout(() => {
throw new Error('payment timed out');
}, 1000);
} catch (err) {
console.log('never runs');
}The try block ran setTimeout, which registered a callback and returned normally. A second later the callback runs on an empty stack, with no try anywhere below it, so the error reaches the top level. In a browser it lands in the console. In Node it crashes the process. Either way your catch was never involved.
The fix is to put the try inside the callback, where the risky code actually runs:
setTimeout(() => {
try {
processPayment();
} catch (err) {
reportFailure(err);
}
}, 1000);The same rule explains event listeners, fs.readFile callbacks, and anything else that takes a function and calls it later. Each of those runs on its own stack. If you remember one sentence from this article, make it this one: a catch only covers code that is still running underneath it. Everything scheduled for later needs its own handling, and promises are just a structured way of doing that.
The async trap: no await means no catch
Promises follow the same rule, which produces the single most common error-handling bug in JavaScript. A function that returns a promise returns instantly. If you do not await it, the try block completes before the operation has finished, and the eventual rejection has nowhere to go.
async function loadFees() {
const res = await fetch('/api/fees');
if (!res.ok) throw new Error('Request failed: ' + res.status);
return res.json();
}
async function show() {
try {
const fees = loadFees(); // missing await
render(fees); // renders a Promise, not data
} catch (err) {
console.log('never runs');
}
}Two things go wrong at once. render receives a pending promise instead of your data, and the rejection becomes an unhandled rejection that your catch never sees. Add the await and both problems disappear, because await re-throws the rejection at that line, on the same stack the try is watching.
The same trap hides inside array callbacks. forEach ignores the promise each callback returns, so nothing is awaited and failures leak out:
// broken - errors escape, and the loop finishes before the work does
orders.forEach(async (o) => { await save(o); });
// works - each iteration is awaited inside the try
for (const o of orders) {
await save(o);
}
// works and runs in parallel
await Promise.all(orders.map((o) => save(o)));Also worth knowing: an async function never throws synchronously. Even if the very first line throws, the caller receives a rejected promise. So wrapping a call to an async function in try/catch without await is always pointless. Modern Node treats an unhandled rejection as fatal and exits the process by default, which is deliberate, because an unhandled rejection means part of your program has silently stopped.
Custom Error classes and why instanceof matters
Once you are catching errors, you have to decide which ones you can actually handle. Comparing message strings is brittle. Subclassing Error lets you branch on type and attach the context you will want in the log.
class PaymentError extends Error {
constructor(message, orderId, options) {
super(message, options);
this.name = 'PaymentError';
this.orderId = orderId;
}
}
class ValidationError extends Error {
constructor(field) {
super('Invalid value for ' + field);
this.name = 'ValidationError';
this.field = field;
}
}Setting name explicitly matters because it is what appears in the stack trace. Then the catch block becomes a decision, not a guess:
try {
await chargeOrder(order);
} catch (err) {
if (err instanceof ValidationError) {
showFieldError(err.field);
} else if (err instanceof PaymentError) {
showRetry(err.orderId);
} else {
throw err; // not ours - let it travel up
}
}That last throw err is the part people leave out. Swallowing unknown errors turns a crash into a blank screen with no clue in the console. Catch what you can act on, rethrow the rest.
Two details. First, always throw an Error, never a string. throw 'failed' produces a value with no stack trace and no message, so your logging code reading err.message gets undefined. Second, the second argument to Error is an options object supporting cause, which lets you wrap a low-level failure without losing it:
try {
await db.insert(row);
} catch (err) {
throw new PaymentError('Could not record payment', order.id, { cause: err });
}If you compile down to ES5, be aware that subclassing built-ins like Error does not survive the transpilation cleanly and instanceof can return false. Target a modern output and the problem does not arise.
finally, and the return that eats your error
finally runs whether the try block succeeded, threw, or returned. That makes it the right place for cleanup that must happen regardless: closing a database connection, clearing a timer, turning off a loading spinner.
async function submitForm(data) {
setLoading(true);
try {
return await api.post('/enrol', data);
} catch (err) {
showToast('Submission failed, please retry');
throw err;
} finally {
setLoading(false); // runs on success and on failure
}
}Note the return await there. Without await, the function returns the promise and finally runs immediately, turning off the spinner before the request has completed.
Now the trap. A return inside finally overrides everything, including an error on its way out.
function readAmount() {
try {
throw new Error('file missing');
} finally {
return 0; // the error is discarded entirely
}
}
readAmount(); // 0 - and nobody ever learns the file was missingThe same applies to a value: a return in finally beats a return in try. Never return or throw from a finally block. Use it for cleanup only.
Two smaller points. If you do not need the error object, the binding is optional, so catch { ... } is valid. And an empty catch block is one of the worst habits in the language, because it converts a loud failure into a wrong result that surfaces days later. If you truly intend to ignore an error, log it with a comment explaining why, so the next reader knows it was a decision rather than an oversight.
How errors travel through promises
A rejection travels down a promise chain until something handles it, skipping every then on the way. The position of catch therefore changes what it protects.
fetch('/api/marks')
.then((res) => res.json())
.then((data) => render(data))
.catch((err) => showError(err)); // covers all three stepsMove that catch above the render step and a failure inside render is no longer covered. As a rule, put catch at the end of the chain, and only place one in the middle when you deliberately want to recover and continue.
One thing fetch does not do is reject on HTTP error status. A 404 or a 500 resolves normally with res.ok set to false. It rejects only for network-level failures, so you must check res.ok yourself or your error handler will never fire on a server error.
Promise.all rejects as soon as any input rejects, giving you the first error and discarding the rest. The other operations are not cancelled, they continue running, and because Promise.all subscribed to all of them their later rejections do not become unhandled, they are simply dropped. When you want every result regardless, use Promise.allSettled:
const results = await Promise.allSettled([
saveInvoice(a),
saveInvoice(b),
saveInvoice(c),
]);
const failed = results.filter((r) => r.status === 'rejected');
failed.forEach((r) => console.error(r.reason));There is one more placement rule worth internalising. Catching an error at every layer and logging it at each one produces the same failure printed four times with four stack traces, which makes real incidents harder to read, not easier. Handle an error where you can actually do something about it, which usually means the boundary that talks to the user or the request handler at the edge of your server. Everything below that should either add context with cause and rethrow, or leave the error alone.
Finally, add a top-level net so nothing disappears. In Node, listen for unhandledRejection and uncaughtException to log the failure before the process exits, and treat the exit as correct rather than something to suppress. In the browser, listen for window.addEventListener('unhandledrejection', handler) and the error event to report failures your users hit but never tell you about.
