Get started
window.onerror and unhandledrejection
The browser has two events for an error nothing in your code caught, and they
split the work. error fires for a script error thrown synchronously — during
load, or inside an event handler. A promise that is rejected with no handler
attached fires unhandledrejection instead of error, never both. Both are
dispatched on window, and neither bubbles: they exist so that something
outside the stack can listen, which is exactly what a reporter is.
That is the mechanism the console buffer is built on, and this page is mostly about what the two events do not tell you, because that is where a captured error stops being useful.
Two signatures, and one of them is a trap #
window.addEventListener("error", (event) => {
// one ErrorEvent: message, filename, lineno, colno, error
});
window.onerror = (message, source, lineno, colno, error) => {
// five arguments, for historical reasons
return true; // stop the browser printing it — the script still stops
};The handler property is the only event handler on window that receives more
than one argument, and that asymmetry is thirty years old: addEventListener
gives you an ErrorEvent, onerror gives you the same five fields loose. The
other trap is the return value — true cancels the console output, where every
other handler property cancels by returning false.
unhandledrejection arrives as a PromiseRejectionEvent whose reason is
whatever was rejected, and it is cancelable too: event.preventDefault() keeps
the browser from printing it. Both pages are on MDN, read 28 September 2026:
Window: error event
and
Window: unhandledrejection event.
Five things that decide whether what you captured is any use #
1. error on an element is a different event. A resource that fails to load
— an <img> that 404s, a <script> with a bad src — fires error on that
element, and it does not bubble, so a window listener without
capture: true never sees it. Nothing in it describes a script throwing, and
event.error is undefined, so a handler written for the window form reads
nothing at all.
2. A cross-origin script tells you nothing, and a cross-origin rejection
tells you nothing at all. For an error thrown in a script served from another
origin, the browser reports Script error. and withholds the message, the file
and the stack, because handing them to the page would leak that origin's
internals. The fix is on the serving side: crossorigin="anonymous" on the
<script> tag and a CORS header on the response. For rejections the browser
goes further — a rejection originating in a cross-origin script does not fire
unhandledrejection at all, because the event carries the reason. That one is
not fixable from the page, and it is the only place where an error is hidden on
purpose.
3. A Worker is a different global scope. A window listener sees nothing
from a web worker, a service worker or a worklet — they have their own global
with the same two events. initConsoleBuffer adds its two listeners only when
window is there, so a report carries no worker errors unless you pass them in
yourself; the same is true of onUncaughtError in
bugbottle/triggers, which listens on whatever
host you hand it.
4. event.error is whatever was thrown. throw "no seat" and
throw { code: 409 } reach the listener as a string and as a plain object,
neither of which has a .stack. The console buffer reads e.error?.stack and
its parser answers "no frames" for anything that is not a stack string, so the
entry still lands — with the message and no frames, which is the correct answer
and not a bug.
5. The console line may never have existed. An error hook that replaces
console.error takes the entry the ring buffer would have recorded, so a report
built around a global handler can come out with nothing in the console section.
This is the sixth place we have had to write this down, and it is the first
thing to check when a report arrives with an empty console: Vue, Nuxt, React
Router and Hono all do it, and the
React page, the Nuxt page and the
Hono page each say so where it happens.
What an uncaught error puts in a report #
One line for the reader — Uncaught: <message> (<file>:<line>) — and up to ten
frames parsed out of e.error.stack, in the V8, Firefox and Safari shapes. A
frame is file, line, col and an optional function name: a position and
nothing else, because no line of source is ever read, and resolving a frame
against your own source maps is your side of the deal. An unhandled rejection
contributes the serialised reason the same way, from reason.stack.
Those entries sit in the same ring buffer as console.error and console.warn,
which is the point of a report: the ten lines before the crash are usually what
makes it reproducible.
The short version #
The whole browser API, if you are wiring it up yourself:
window.addEventListener("error", (event) => {
console.warn(`Uncaught: ${event.message} (${event.filename}:${event.lineno})`, event.error);
});
window.addEventListener("unhandledrejection", (event) => {
console.warn(`Unhandled rejection: ${event.reason}`);
});Or the one line that also keeps the ring buffer, the frames and the position:
import { initConsoleBuffer } from "bugbottle";
initConsoleBuffer(); // returns the stop, as every recorder in the package doesHave the panel open by itself, and queue the report while the network is down,
with openOnError and the queue — both are
described under Opening it without a button.