Sentry paged me at 2am for an unhandled exception in a function that had a .catch() on every single call site. I read the code three times before 2:15 and still couldn't find how it got past. The error wasn't hiding in the .catch(). It was two lines above it, in code that looked completely unremarkable.
Here's the shape of it, trimmed to the part that matters:
function getUser(id) {
if (typeof id !== "number") {
throw new TypeError(`id must be a number, got ${typeof id}`);
}
return fetch(`/api/users/${id}`).then((r) => r.json());
}
getUser(userId)
.then(renderProfile)
.catch(reportError);
Somewhere upstream, userId had come through as undefined. getUser did exactly what it was written to do — it threw. And the page crashed anyway, reportError never called, like the .catch() wasn't even there.
Before you scroll — where's the bug?
Take a second. The .catch() is right there, chained to the call. What did I miss?
Here's the part that took me embarrassingly long to see: getUser is a plain function, not an async one. When you write getUser(userId), JavaScript runs the entire body of getUser synchronously, top to bottom, before it hands anything back to the caller. If id is bad, the throw fires during that synchronous run — before getUser has returned anything, including before it's returned a promise.
.then() and .catch() are methods on a promise object. No promise object ever got created. There was nothing for .catch() to attach to, because the line getUser(userId).then(...).catch(...) never finished evaluating getUser(userId) in the first place. The exception just does what exceptions do in synchronous code: it unwinds the call stack until something catches it. Nothing did.
The unsettling bit is that getUser works fine 99% of the time. When id is valid, it returns a real promise from fetch(...).then(...), and the .catch() chained after it works exactly as expected. The bug only shows up on the one path that skips the return and throws instead — which is exactly the path most likely to be a bad input nobody tested.
The fix everyone reaches for first
The obvious patch is to wrap the call site:
try {
getUser(userId).then(renderProfile).catch(reportError);
} catch (err) {
reportError(err);
}
This works. It also means reportError now has two separate roads leading to it, and every place that calls getUser needs the same wrapper or it's back to square one. I found three other call sites that didn't have it.
If you own the function: make it async
The clean fix, when you can change getUser itself, is one keyword:
async function getUser(id) {
if (typeof id !== "number") {
throw new TypeError(`id must be a number, got ${typeof id}`);
}
return fetch(`/api/users/${id}`).then((r) => r.json());
}
An async function always returns a promise, full stop — and a synchronous throw inside it is automatically converted into a rejection of that promise instead of an exception that escapes to the caller. Now every call site's .catch() works, with zero changes at the call site. This is genuinely the right fix when the function is yours.
When it isn't yours
The case that actually bit me: getUser came from a small internal library, and I didn't want to fork it just to add async. More generally — you're wrapping a callback someone handed you, a plugin hook, an event handler — something that might throw synchronously, might return a rejected promise, and you don't control which, and you can't turn it into an async function because you don't own its source.
The old trick for this is Promise.resolve().then(() => fn(...args)) — running fn inside a .then callback so any synchronous throw becomes a rejection instead of an escaped exception. It works. It also reads like a no-op if you don't already know the idiom, and it costs an extra microtask turn before fn even starts.
Promise.try() is that idiom, built in and named for what it does:
Promise.try(getUser, userId)
.then(renderProfile)
.catch(reportError);
Promise.try(fn, ...args) calls fn immediately with the given arguments and always hands back a promise: resolved with whatever fn returned, chained to it if fn returned a promise, or — the part that matters here — rejected with whatever fn threw. Now every path through getUser reaches reportError through the exact same .catch(), no try/catch duplication, no library fork.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The detail both old workarounds get slightly wrong
Here's the one thing Promise.try does that neither try/catch around the call site nor the Promise.resolve().then(fn) hack quite matches: timing.
Promise.resolve().then(() => fn()) doesn't run fn in the same tick as the call — .then() always schedules its callback as a microtask, so fn starts running after the current synchronous code finishes, even on the success path. Promise.try(fn) calls fn synchronously, right when you call it — the same contract an async function's body has before its first await. If fn does something time-sensitive before it returns (kicks off a synchronous side effect, reads something off document.activeElement, starts a timer), that now happens exactly when you called it, not one microtask later. Small, and easy to not care about — until you're debugging why something that "obviously" runs first actually ran second.
Promise.try reached Baseline in 2025 and by now runs in current Chrome, Firefox, and Safari, plus Node 22 and newer, without a polyfill.
When the old way is still fine
If you own the function, keep using async — it's clearer to the next person reading it than a Promise.try wrapper at every call site. Reach for Promise.try specifically at the boundary: wrapping something you didn't write and can't safely assume is async-shaped.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The .catch() didn't fail me at 2am. I had, three months earlier, when I wrote a function that wasn't safe to chain in the first place. If you've got five minutes, grep your own codebase for a non-async function that both returns a promise and can throw before it gets there — I'd bet you find at least one. What did yours look like?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)