Your `window.onerror` Handler Has Never Seen a Rejected Promise

javascript dev.to

There is a class of failure that most hand-rolled error tracking has been blind to since promises arrived, and the reason is structural rather than a bug: a rejected promise is not an exception. It is a value. Nothing is thrown, so nothing is caught, and the handler you wired up in 2015 to window.onerror sits there reporting a healthy page while the async half of your application fails quietly beside it.

Two channels, not one

The browser has two separate mechanisms for telling you something went wrong, and they do not overlap.

Synchronous exceptions go through window.onerror (or an error event listener). Anything thrown and not caught in a script, an event handler, a timer callback, arrives here with a message, a filename, a line and column, and the Error object.

Rejected promises with no handler go through a different event entirely. MDN's wording:

The unhandledrejection event is sent to the global scope when a JavaScript Promise that has no rejection handler is rejected.

Note the phrase no rejection handler. A promise rejection is not an error condition in JavaScript. It is a normal state a promise can be in, and it only becomes a problem when nothing is listening. The runtime waits until the end of the current task to see whether a .catch() turns up, and if none does, it fires unhandledrejection. Not error. Nothing is thrown at any point.

Which means this catches nothing from a failed fetch, a rejected async function, or an await inside code with no try:

window.onerror = (message, source, line, col, error) => {
  send({ message, source, line, col, stack: error?.stack });
};
Enter fullscreen mode Exit fullscreen mode

And this is what was missing:

window.addEventListener('unhandledrejection', (event) => {
  send({ reason: event.reason });
});
Enter fullscreen mode Exit fullscreen mode

Why it is worse than a missing handler

Three things about the second channel make it easy to get wrong even once you know it exists.

reason can be anything. A synchronous throw almost always carries an Error, so there is a stack. A promise can be rejected with a string, a number, undefined, a response object, whatever somebody passed to reject(). event.reason.stack is a property access on a value that may not be an object. Half the rejections in a real codebase carry no stack at all, because the code that produced them never constructed an Error.

preventDefault() silences it. The event is cancelable, and MDN is explicit about what cancelling does:

Allowing the unhandledrejection event to bubble will eventually result in an error message being output to the console. You can prevent this by calling preventDefault().

Some libraries do exactly that, to keep the console clean. If one of them loaded before your tracking did, and it calls preventDefault() without forwarding, your handler runs but the console entry that would have alerted a developer never appears. The failure is now recorded nowhere a human looks.

The timing is different. An error event fires at the throw. unhandledrejection fires at the end of the task, after the rejection has propagated through whatever chain it was in. The stack, if there is one, points at where the promise was created, not at where it failed to be handled. For a fetch that rejected because the network dropped, that is a frame in a helper that wraps every request, which tells you which of two hundred calls failed exactly as well as a 404 page tells you which file is missing.

Where this actually bites

The pattern is a page that works, with a feature that silently does not.

A button calls an async function. The function awaits a request. The request fails. Nothing was thrown synchronously, so window.onerror is not involved. The promise rejects with a TypeError: Failed to fetch, nothing catches it, and the button does nothing. The user sees a button that does nothing. The error tracker sees a healthy page.

The user reports: "the save button is broken". The developer clicks save, it works, and closes the ticket. There was an error, it had a message, and it went down a channel nobody had connected.

What to check

Open your production page, and in the console:

Promise.reject(new Error('canary'));
Enter fullscreen mode Exit fullscreen mode

Then look at what your tracker recorded. If nothing arrived, every async failure on that page has been arriving the same way for as long as the tracking has existed. If something arrived but with no stack, that is the reason-is-not-an-Error problem, and it is worth normalising at the boundary:

const reason = event.reason instanceof Error
  ? event.reason
  : new Error(String(event.reason));
Enter fullscreen mode Exit fullscreen mode

And if your handler runs but nothing shows in the console, something upstream is calling preventDefault(), and you have a library to find.

None of this is exotic. It is the difference between the errors that were thrown and the errors that merely happened, and only one of those was ever wired to the thing you check.

Source: dev.to

arrow_back Back to Tutorials