DEV Community

Cover image for Stop Losing Promise Errors. `unhandledrejection` Catches Them.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Stop Losing Promise Errors. `unhandledrejection` Catches Them.

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.

The code that looks finished

This is the version almost everyone ships, because it looks complete:

button.addEventListener("click", async () => {
  button.disabled = true;
  const res = await fetch("/api/checkout", { method: "POST", body: cartPayload });
  const order = await res.json();
  showConfirmation(order);
});
Enter fullscreen mode Exit fullscreen mode

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.

Why your error tracker didn't catch it

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 the error event on window) — 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 listener that closes the gap

The fix is one global listener, and it's been supported in every major browser for years:

window.addEventListener("unhandledrejection", (event) => {
  // event.reason is whatever the promise rejected with —
  // usually an Error, but not guaranteed (someone could `reject("oops")`)
  reportError(event.reason, { source: "unhandledrejection" });

  // optional: stop the "Uncaught (in promise) ..." console noise,
  // now that you've reported it somewhere that actually gets read
  event.preventDefault();
});
Enter fullscreen mode Exit fullscreen mode

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.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The one thing it doesn't do

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.

What this is really for

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?

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

📚 Read next


🚀 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:

Top comments (1)

Collapse
 
williamsj04 profile image
Jessica Williams •

The smoke detector comparison fits well. A silent failure that lasts three weeks usually means nothing was listening for it. The reminder that the listener reports the problem but does not fix the missing catch is a fair one, and the Node note is handy.