Stop Losing Promise Errors. unhandledrejection Catches Them.
A rejected promise with no .catch doesn't crash and doesn't show up in your error tracker — it just disappears. The unhandledrejection event is the global safety net most apps never wire up.

Three weeks. That's how long it took anyone to notice that a subset of checkouts were quietly failing — not erroring, not crashing, just... not completing. No red line in the dashboard. No spike in the error tracker. Support tickets trickled in ("I clicked pay and nothing happened"), got marked as user error, and closed.
The bug was two lines of code: a fetch() call inside an async handler, with no .catch() anywhere on its chain. When the payment API returned a validation error, the promise rejected, the handler silently stopped executing, and the rejection went — nowhere. Not to the console in a way anyone was watching. Not to Sentry. Not to anyone.
This is the version almost everyone ships, because it looks complete:
It handles the happy path. It awaits both calls. There's no obvious hole. Run it against a working API and it's correct.
Now make fetch reject — a network drop, a CORS misconfiguration, or res.json() throwing because the server returned an HTML error page instead of JSON. The await re-throws inside the async function, the function's returned promise rejects, and since nothing ever attaches a .catch() to that promise — the event listener doesn't return it to anyone, it just fires it and walks away — the rejection has no handler. button.disabled stays true forever. The user sees a button that quietly stopped working. And unless you know to look, you'll never know it happened.
This is the part that surprises people: an unhandled promise rejection is not the same event as a runtime error, and plenty of error-monitoring setups only wire up the one they thought of first.
- A thrown error outside a promise fires
window.onerror(or theerrorevent onwindow) — most basic error tracking hooks this. - A promise that rejects with nobody attached to catch it fires a different event:
unhandledrejection. If you never listen for it, the only sign it happened is a console line —Uncaught (in promise) TypeError: ...— that nobody sees unless DevTools is already open and someone is looking at exactly the right tab.
Full error-tracking SDKs like Sentry do wire this up for you by default. A hand-rolled window.onerror = reportError does not. That gap is exactly where the checkout bug lived for three weeks.
The fix is one global listener, and it's been supported in every major browser for years:
event here is a PromiseRejectionEvent, with two properties worth knowing: event.promise (the promise that rejected) and event.reason (the value it rejected with — often an Error, but JavaScript lets you reject with anything, so don't assume it has a .message). Calling event.preventDefault() suppresses the browser's own "Uncaught (in promise)" console message — worth doing once you're actually reporting the rejection somewhere, so you're not logging it twice.
There's a companion event, rejectionhandled, that fires if a .catch() shows up after the fact — say, a .then() chained in asynchronously once some other code finishes loading. It exists so you can retract a report you already sent for a rejection that turned out to be handled, just late. Most apps never need it; it's there if your reporting pipeline needs to avoid false positives on timing-sensitive code.
Runs right in your browser — poke at it and watch the concept react live.
Be clear about what this buys you, because it's tempting to treat a global listener like a fix: it isn't one. unhandledrejection doesn't stop the checkout button from getting stuck, doesn't retry the request, and doesn't tell the user anything went wrong. All it does is make a failure that was previously invisible show up somewhere you'll actually read it. The underlying bug — a .catch() or a try/catch that should have been there — still needs fixing at the source. What the listener changes is how long that bug survives before someone notices.
It's also browser-only as written above: in Node.js, the equivalent is process.on("unhandledRejection", (reason, promise) => { ... }), and every Node release since v15 (2020) goes further than a warning — by default, an unhandled rejection now terminates the process instead of just logging it, which is its own argument for not leaving async error handling to chance.
Treat unhandledrejection the way you'd treat a smoke detector: it doesn't put out the fire, it just guarantees you find out about it before the room fills with smoke. Wire it into whatever already collects your errors — Sentry, a logging endpoint, even just a styled console group in staging — and the next silent failure stops being silent. The checkout bug above wasn't hard to fix once someone saw it. The three weeks were the cost of nobody seeing it.
Go check right now: does your app have a global unhandledrejection listener, or does it only catch the errors you remembered to wrap in try/catch?
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- You're rethrowing errors and losing context.
Error.causefixes that. - My .catch() Never Ran. The Bug Was Two Lines Above It
- You reach for
Promise.allfor every concurrent request. Here's when to use the other three.
🚀 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___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.