Use window.addEventListener('error', handler, true) together with an unhandledrejection listener — that pair is the closest the browser gets to "catch all JavaScript errors." window.onerror isn't deprecated and still works, but it's the strictly weaker option: it sees the same uncaught runtime exceptions with clunkier ergonomics, and it can never see a failed <img> or <script> load. And neither API sees rejected promises — that's a third, separate event. Here's the comparison up front, then the details most articles skip.
window.onerror | addEventListener('error', fn, true) | |
|---|---|---|
| Handler input | Five arguments: message, source, lineno, colno, error | One ErrorEvent object |
| Uncaught runtime exceptions | Yes | Yes |
Resource load failures (<img>, <script>, <link>) | No | Yes — capture phase only |
| Unhandled promise rejections | No | No — you need unhandledrejection |
| Suppress the console message | return true | event.preventDefault() |
| Multiple handlers | No — assignment overwrites | Yes — listeners stack |
Three rows in that table cause most of the real-world pain: resource errors are only reachable through the capture phase, promise rejections need a separate event entirely, and cross-origin scripts arrive masked as "Script error." unless you fix CORS. Let's take them in order.
How does window.onerror work?
window.onerror is a property you assign a function to. It receives five positional arguments, and returning true suppresses the browser's default console output. Here's the full signature:
window.onerror = (message, source, lineno, colno, error) => {
console.log(message); // "Uncaught TypeError: x is not a function"
console.log(source); // "https://app.example.com/main.js"
console.log(lineno, colno); // 14, 27
console.log(error?.stack); // the full stack trace, when available
return true; // suppress the default "Uncaught ..." console entry
};
That five-argument shape is unique to the property form. MDN is explicit about the asymmetry: a handler registered with addEventListener "receives a single ErrorEvent object, while the onerror handler receives five arguments." Returning true cancels the console message, but "the current script will still stop executing" — suppression is cosmetic, not a catch.
What it catches: uncaught exceptions thrown synchronously — during initial script execution, inside event handlers, inside setTimeout and setInterval callbacks (each timer callback is its own synchronous run, so a throw there is still "synchronous" in this sense).
What it misses:
- Resource load failures. A 404'd image or a script tag pointing at a dead URL fires an
errorevent on the element, andwindow.onerrornever hears about it. - Promise rejections — which, since
async/awaittook over, is most of the errors in a modern app. An error thrown inside anasyncfunction rejects the returned promise; it does not hitwindow.onerror. - Useful detail from cross-origin scripts. Without CORS you get the string
"Script error."and nothing else (more below).
There's also a structural problem: onerror is a single slot. If a tag manager, an A/B testing snippet, or any third-party script assigns window.onerror after you do, your handler is silently gone. Listeners added with addEventListener stack — nobody can clobber yours.
What does addEventListener('error') catch that window.onerror misses?
Registered with the capture flag set to true, an error listener on window catches everything window.onerror catches plus every element-level resource failure — failed images, scripts, stylesheets, video/audio sources (a failed media src fires error on the media element; with <source> children, on the <source> element — both reach a capture-phase window listener). This is the single most under-documented detail in JavaScript error handling. The "element-level" qualifier matters: CSS background-images and failed fetch() calls fire no element error event, so this net doesn't see them.
Here's why the flag matters. When an <img> or <script> fails to load, the browser fires an error event at that element, and per MDN's element error event docs, that event "is not cancelable and does not bubble." A non-bubbling event never reaches a normal listener on window. But event dispatch has two trips: the capture phase walks down from window to the target before the bubble phase walks back up, and the capture trip happens for every event — bubbling or not. So a window listener registered with useCapture: true sees resource errors on the way down:
window.addEventListener(
'error',
(event) => {
if (event.target !== window) {
// A resource failed to load. event is a plain Event, not an ErrorEvent.
const el = event.target;
console.log('resource failed:', el.tagName, el.src || el.href);
return;
}
// A runtime error. event is an ErrorEvent with the same data
// window.onerror gets, just as named properties.
console.log(event.message, event.filename, event.lineno, event.colno);
console.log(event.error?.stack);
},
true, // capture — without this, resource errors are invisible
);
The event.target !== window check is how you tell the two apart: runtime errors dispatch at window itself, resource errors dispatch at the failing element. Resource events carry no message, line, or stack — the element and its src/href are all you get.
Two smaller notes. First, to suppress the default console entry here you call event.preventDefault() — the return true convention belongs to onerror only. Second, decide whether you want resource errors before you ship this: a flaky ad pixel or a user on hotel Wi-Fi will flood you with them, so most teams report resource failures only for their own origin and sample the rest.
Why do you need an unhandledrejection handler too?
Because neither error API fires for a rejected promise — and in 2026, most of your failures are rejected promises. A failed fetch, a throw inside any async function, a JSON.parse blowing up inside a .then() — all of it bypasses both window.onerror and the error event. The browser routes it to a different global event: unhandledrejection, which fires "when a JavaScript Promise that has no rejection handler is rejected."
window.addEventListener('unhandledrejection', (event) => {
// event.reason is whatever the promise rejected with.
// It's often an Error — but it can be any value, including undefined.
const reason = event.reason;
console.log(reason instanceof Error ? reason.stack : String(reason));
event.preventDefault(); // suppress the default console message
});
Treat event.reason as untyped. Promise.reject('nope') and Promise.reject() are both legal, so a reporter that assumes reason.stack exists will itself throw — inside your error handler, which is the worst place to throw.
This is also where deployment-time chunk failures land. A dynamic import() that can't fetch its file returns a rejected promise, so the classic post-deploy breakage shows up in unhandledrejection, not error — we wrote up the full mechanics in Fixing "Failed to fetch dynamically imported module" in Vite.
There's a companion event worth wiring: rejectionhandled fires when a rejected promise "is handled late, i.e., when a handler is attached to the promise after its rejection had caused an unhandledrejection event." That happens in legitimate code — a .catch() attached one tick later, a stored promise handled on demand. If you reported the rejection as an error, rejectionhandled is your chance to mark it a false alarm instead of paging someone.
window.addEventListener('rejectionhandled', (event) => {
console.log('handled late, downgrading:', event.reason);
});
So the minimum viable global net is three listeners: error (capture), unhandledrejection, and optionally rejectionhandled for noise control.
Why does window.onerror only say "Script error."?
Because the failing script came from another origin without CORS, and the browser deliberately masked it. When the browser fetches a classic script cross-origin without CORS approval, the HTML spec flags it with muted errors — "true if response was CORS-cross-origin" — and error reporting then hands your handler the literal string "Script error." with no source URL, line 0, column 0, and a null error object. The masking exists for a reason: error messages can leak data (think a script behind cookies on another domain), so the browser hides details from origins that haven't opted in.
In practice this hits every app that serves its bundle from a CDN domain or loads vendor scripts from one. The fix has two halves, and you need both:
<!-- 1. Opt the script tag into CORS -->
<script
src="https://cdn.example.com/vendor.js"
crossorigin="anonymous"></script>
# 2. The CDN must approve the request
Access-Control-Allow-Origin: *
With crossorigin="anonymous", the browser fetches the script in CORS mode without credentials; if the response carries a matching Access-Control-Allow-Origin header, the script is no longer "CORS-cross-origin," muting is off, and your handler gets real messages and stacks. MDN's crossorigin reference confirms the default behavior: without the attribute, "access to error logging via window.onerror will be limited."
One warning before you paste that attribute everywhere: if the server does not send the header, a crossorigin script fails CORS and doesn't execute at all. Masked errors become no script. Major public CDNs (unpkg, jsDelivr, cdnjs) send Access-Control-Allow-Origin: *; verify before flipping it on a private CDN.
And once errors arrive unmasked, production stacks still point at minified code — vendor.js:1:48213 is barely better than "Script error." That's a separate fix: JavaScript source maps in production.
What still escapes a global error handler?
Even with all three listeners wired, a JavaScript global error handler has edges worth knowing:
- Errors thrown before your handler installs. Global handlers only see the future. If your reporter loads as the fourth script tag, errors in the first three are gone. Install order matters: the handler should be the first script on the page, inline if possible, before your bundle and before any third-party tag.
- Anything caught upstream. A
try/catchthat swallows, a library's internal handler, a framework boundary — once something catches an error, it never reacheswindow. Frameworks make this genuinely confusing: React error boundaries catch render errors but don't catch async errors, so render failures go to the boundary while a failedfetchin an effect falls through to yourunhandledrejectionlistener. You need both nets, and you need to know which catches what. - Cross-origin iframes. Each browsing context has its own
window. An embedded widget's errors fire on its global, not yours, and the same-origin policy keeps you from reaching in. - Browser extensions. Content scripts run against your page, and their failures sometimes surface in your handler with
chrome-extension://source URLs — noise you didn't ship and can't fix. Filter on the source URL. - Your handler itself. An exception inside an error handler can loop or kill reporting entirely. Wrap the reporter so it can never throw, and never
awaitinside it.
Wire it all up: a complete global error handler
Here's the whole thing — runtime errors, resource failures, promise rejections, dedupe, and a reporter that can't throw. Load it as the first script on the page:
// install-error-handlers.js — load before any other script
const seen = new Set();
function report(payload) {
// Dedupe: an error in a render loop can fire hundreds of times a second.
const key = [payload.type, payload.message, payload.source, payload.lineno].join('|');
if (seen.has(key)) return;
seen.add(key);
fetch('https://errors.example.com/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, url: location.href, time: Date.now() }),
keepalive: true, // lets the request outlive a page unload
}).catch(() => {}); // a reporter must never throw
}
// 1. Runtime exceptions AND resource load failures (note the capture flag).
window.addEventListener('error', (event) => {
if (event.target !== window) {
const el = event.target;
report({
type: 'resource',
message: `<${el.tagName?.toLowerCase()}> failed to load`,
source: el.src || el.href || '',
});
return;
}
report({
type: 'error',
message: event.message,
source: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
});
}, true);
// 2. Promise rejections — including every throw inside an async function.
window.addEventListener('unhandledrejection', (event) => {
const r = event.reason;
report({
type: 'unhandledrejection',
message: r instanceof Error ? r.message : String(r),
stack: r instanceof Error ? r.stack : undefined,
});
});
Swap the fetch URL for your own endpoint and this is a working error pipeline: every uncaught exception, failed resource, and unhandled rejection on the page, deduped and delivered. The keepalive flag matters more than it looks — errors love to happen right as the user navigates away, and the navigation cancels a normal fetch in flight.
When a snippet stops being enough
That snippet is a faithful intake. What it isn't is a system: the hundredth duplicate across page loads still arrives, stacks still point at minified bundles, nothing tells you whether an error hit one user or every user, and nobody gets alerted at 2 a.m. That layer — grouping errors into issues, counting affected users, symbolicating with source maps, alerting Slack or email — is what an error-tracking SDK is for. Ours, @catch.dev/browser-script, installs global error and unhandled-rejection handlers for you, with zero runtime dependencies; the getting-started guide goes from install to first captured error in under five minutes, and the free tier needs no credit card.
Whether you use an SDK or the thirty-line version, you need the same three pieces: addEventListener('error') with the capture flag, unhandledrejection, and crossorigin="anonymous" on cross-origin scripts. That's the complete net — everything else builds on top of it.