I’m trying to use JavaScript try catch blocks to handle runtime errors, but I’m confused about what actually gets caught and what still crashes the script. Some errors are handled as expected, while others seem to bypass my try catch logic or behave differently in async code. Can someone explain how try catch really works in JavaScript, including common pitfalls with promises and async/await, and show some best practice patterns for error handling in real-world apps?
Short version. try/catch only catches synchronous exceptions that happen inside its own block. A lot of things people expect it to catch sit outside that.
Key points.
- Synchronous errors inside the try
These get caught:
try {
JSON.parse(‘{bad json}’);
} catch (e) {
console.log(‘caught’, e.message);
}
ReferenceError, TypeError, manual throw, etc. As long as they happen during the execution of that block, same tick, same call stack, the catch runs.
- Async stuff ignores your try
This will not catch the timeout error:
try {
setTimeout(() => {
throw new Error(‘boom’);
}, 0);
} catch (e) {
// never runs
}
By the time the callback runs, the try/catch is gone. The error bubbles to window.onerror or crashes the worker/node process.
Fix:
setTimeout(() => {
try {
risky();
} catch (e) {
console.error(‘caught inside callback’, e);
}
}, 0);
Same rule for:
- Promises without async/await
- Event listeners
- Fetch callbacks
- Any other async callback
- Promises and async/await
This fails to catch:
try {
Promise.reject(new Error(‘nope’));
} catch (e) {
// does not run
}
The error lives in the promise, not on the current stack.
Correct patterns:
With .catch:
Promise.reject(new Error(‘nope’))
.catch(e => {
console.error(‘caught’, e);
});
With async/await:
async function run() {
try {
await Promise.reject(new Error(‘nope’));
} catch (e) {
console.error(‘caught with await’, e);
}
}
- Syntax errors
This will kill the script before it even runs:
try {
function () {} // invalid syntax
} catch (e) {}
Parser errors happen before execution, so the try never executes.
- Errors in different “worlds”
Some things throw outside your script context.
Examples:
- Errors in inline event handlers like onclick attributes
- Errors in iframes
- Cross origin script issues
These often end up in window.onerror in the browser or as unhandledRejection / uncaughtException handlers in Node.
- What to do in practice
- Wrap async callbacks individually
button.addEventListener(‘click’, e => {
try {
riskyClickHandler(e);
} catch (err) {
console.error(err);
}
});
-
For promises, always use either await inside a try/catch or attach .catch
-
Use global handlers for “last resort”:
Browser:
window.onerror = (msg, url, line, col, err) => {
console.error(‘global error’, err);
};
window.onunhandledrejection = e => {
console.error(‘unhandled rejection’, e.reason);
};
Node:
process.on(‘uncaughtException’, err => {
console.error(‘uncaught’, err);
});
process.on(‘unhandledRejection’, err => {
console.error(‘unhandled rejection’, err);
});
- Quick checklist when your error “skips” catch
- Did the error happen inside the try block synchronously
- Was it in a callback that runs later
- Was it from a promise without await
- Is it a syntax error
- Is it from another context or global handler
If you share a short code sample where it “bypasses” your catch, people can point to the exact line that escapes.
What actually helped this click for me was to stop thinking of try/catch as a “global error safety net” and instead as “a wrapper around one specific synchronous call stack.”
@shizuka already nailed the basic rule: only synchronous exceptions on the current stack, inside the try, get caught. Let me add a different angle and some edge cases you’ll actually run into.
1. Visualize the call stack
Anything that looks like this:
try {
a();
} catch (e) {
console.log('caught', e);
}
function a() {
b();
}
function b() {
throw new Error('boom');
}
This is caught, even though throw is inside b, not directly in the try. Why? Because it’s still the same continuous call stack:
try-block → a → b → throw
The stack unwinds through catch. As soon as the stack “breaks” (async), your try is useless for that later execution.
2. “Async” is really “different stack, different time”
People often think:
try {
setTimeout(() => {
risky();
}, 0);
} catch (e) {
// expecting magic
}
should “watch” the callback. It doesn’t. When the timeout fires, the original stack has finished. New tick, new stack, your try is gone.
General rule of pain:
- Same turn of the event loop, same stack →
try/catchworks - Later turn (timers, I/O, events, promises without
await) → thattryis dead
So for async, the “boundary” where you need try/catch moves inside the callback or around the await.
3. A tricky one: async functions vs plain promises
A lot of confusion comes from stuff like this:
try {
Promise.reject(new Error('nope'));
} catch (e) {
console.log('never here');
}
Nothing is caught, as @shizuka said.
But if you do:
async function main() {
try {
await Promise.reject(new Error('nope'));
} catch (e) {
console.log('caught!', e.message);
}
}
main();
Now it is caught, because await essentially re-threads the rejected promise back into the function’s control flow. That async function has its own mini “sync-like” world where try/catch works again.
Key mental model: await is what reconnects promise errors to try/catch. No await → use .catch(); with await → use try/catch.
4. Things that look like they should throw but actually don’t
Another place people get bitten: not all failures use exceptions.
Examples that usually do not throw:
const img = new Image();
img.src = 'bad.png';
try {
// this does not throw if image fails
} catch (e) {}
You get an onerror event instead. Same with a lot of APIs that report errors via callbacks, return values, or events instead of exceptions. try/catch won’t see those at all.
Also:
if (someObj.nonExistent.prop)throws (becauseundefined.prop)someMap.get('missing')does not; it just returnsundefined
Not every “bad thing” is an exception.
5. One subtle disagreement with the usual advice
People often say “wrap all your async callbacks in try/catch.” That’s fine in small code, but it becomes noise in larger apps.
A cleaner pattern is to centralize error handling per “layer” instead of sprinkling try/catch everywhere:
function withErrors(fn) {
return async (...args) => {
try {
return await fn(...args);
} catch (err) {
// shared logging / user message
console.error('Handler error', err);
}
};
}
button.addEventListener('click', withErrors(async (e) => {
await doRiskyStuff(e);
}));
Same idea for Node HTTP handlers or route handlers: wrap them once with a helper.
6. When the whole script seems to crash anyway
You’ll see this when:
-
Syntax error
The file doesn’t even load.trycan’t run:try { function () {} // parser dies first } catch (e) {} -
Uncaught in async contexts
In browsers, ends up inwindow.onerror/onunhandledrejection.
In Node,uncaughtException/unhandledRejection.
These are your “last line of defense” hooks, but they are not a replacement for local try/catch. Use them for logging, not as your only form of control flow.
7. Quick mental checklist when something “bypasses” your catch
Ask yourself:
- Did the error happen in the same synchronous flow where the
tryis? - Did I hop into async territory (timer, event, promise callback)?
- Is it a rejected promise that I never
awaited or.catched? - Is it actually an error event / return value, not a thrown exception?
- Is the script even running, or did parsing fail?
If you paste a specific snippet that’s misbehaving, you can literally trace the call stack / event boundaries and see exactly where your try stops mattering. Once you see that a couple of times, the behavior stops feeling random and starts to feel annoyingly consistent.
Think of try/catch as a local plumbing tool, not a global safety net. It only works where the “water pipes” (the call stack) are actually connected.
@sterrenkijker and @shizuka already covered the core rules, so I’ll focus on how to design code so you don’t fight try/catch all the time, plus where people usually overuse it.
1. Structure your code around “error boundaries”
Instead of sprinkling try/catch everywhere, decide where you’re willing to fail gracefully.
Examples of good boundaries:
- A single user interaction (click handler, form submit)
- A single HTTP request on the server
- A scheduled job run
Pattern:
async function pageController() {
try {
await loadData();
renderUI();
} catch (err) {
showErrorMessage(err);
}
}
Inside loadData, you usually let errors bubble unless you can actually fix them there. This keeps error handling predictable and avoids deeply nested try/catch soup.
2. Avoid “over-catching”
One small disagreement with the common advice: wrapping every async callback in try/catch often hides bugs instead of surfacing them.
Bad pattern:
button.addEventListener('click', async () => {
try {
await doEverything();
} catch (e) {
console.log('something went wrong', e);
// then ignore it completely
}
});
You end up logging everything and fixing nothing. Better to:
- Log
- Surface a visible error
- Or rethrow in dev
button.addEventListener('click', withErrorBoundary(async () => {
await doEverything();
}));
function withErrorBoundary(fn) {
return async (...args) => {
try {
return await fn(...args);
} catch (e) {
console.error(e);
showToast('Unexpected error. Try again.');
if (import.meta.env?.DEV) throw e;
}
};
}
You get one consistent place to control behavior.
3. Normalize “error styles” in your own APIs
Big practical win: choose one way your app-level functions fail.
For example, decide:
- “All async functions reject with
Erroror subclasses” - “We never return
{ ok: false }unless we also document that there is no thrown error”
Then implement helpers:
class UserFacingError extends Error {
constructor(message) {
super(message);
this.name = 'UserFacingError';
}
}
async function getUserProfile(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
if (res.status === 404) {
throw new UserFacingError('User not found');
}
throw new Error('Server error');
}
return res.json();
}
async function showProfile(id) {
try {
const profile = await getUserProfile(id);
renderProfile(profile);
} catch (e) {
if (e instanceof UserFacingError) {
showToast(e.message);
} else {
showToast('Something went wrong.');
console.error(e);
}
}
}
Once you control how functions fail, try/catch stops feeling random.
4. Pay attention to “silent failures”
Not everything that goes wrong is an exception:
- Many DOM APIs fail via events (
onerror,onabort) - Some network or storage APIs return error codes instead of throwing
- Custom libraries might use callbacks with
(err, result)patterns
If you do:
try {
img.src = 'broken.png';
} catch (e) {
// never runs on load failure
}
you’re guarding the wrong spot. You need the event:
img.onerror = (e) => {
console.error('image failed', e);
};
When try/catch “does nothing,” often there is simply no exception to catch.
5. Debugging why a specific error skipped your try/catch
When something slips past, use this little trace approach:
- Add
console.log('A')just before the code you think throws. - Add
console.log('B')as the first line of yourcatch. - If you see A but never B, ask:
- Did I hit an async boundary between A and the error?
- Am I dealing with a promise I did not
await? - Is the failure actually reported by an event, status code, or callback?
For promises, a nice trick is to force a .catch just for debugging:
doSomethingRisky()
.catch(e => {
console.error('unhandled here', e);
throw e; // rethrow so existing logic still sees it if needed
});
Helps you see missing catches during development.
6. Global handlers: use, but with caution
You should hook these up, but not rely on them for “normal” control flow:
Browser:
window.onerror = (msg, url, line, col, err) => {
// Send to logging backend
};
window.onunhandledrejection = e => {
// Log unhandled promise rejections
};
Node:
process.on('uncaughtException', err => {
// Log & possibly shut down process
});
process.on('unhandledRejection', err => {
// Log; decide if you want to crash in prod
});
Use them for telemetry and last-ditch cleanup, not as an excuse to skip proper try/catch/.catch around known risky code.
7. Quick comparison of approaches from others here
- @sterrenkijker did a solid deep dive into when
try/catchactually works. - @shizuka clarified the async versus sync boundary very clearly.
Where I’d slightly disagree is the idea of wrapping every async callback individually. That scales poorly in larger apps. A small abstraction like withErrorBoundary or centralized route wrappers keeps your surface area of try/catch manageable and your mental model simpler.
If you post a specific snippet that “crashes the script,” you can trace:
script load → parsing → first sync call → async boundary → callback → where the throw actually happens. Once you map those hops, it becomes obvious why a particular try has no chance to see that exception.