Front End Observability: Why Your window.onerror Reports Say "Script error." (and How to Fix Them)
A practical guide to browser error monitoring with window.onerror, covering the cross-origin blind spot, resource failures that never bubble, and error reports that die when the tab closes. Includes tests and Angular wiring. It's late on a Friday. Support forwards a screenshot: a customer's checkout page is blank. You open your error dashboard, and there it is, forty times since lunch: Script error. at (line 0, column 0) No file, no stack, no line. Just a message that tells you nothing. You have window.onerror wired up. You followed a tutorial. So why is the one thing you needed missing? If you want more write-ups on browser error monitoring and front end observability, follow me here on Medium. I publish practical, copy-pasteable pieces like this one every week. By the end of this article you will know exactly what window.onerror hands you and what it hides. You will build a small, tested error monitor in TypeScript that catches runtime errors, failed resources, and rejected promises, and delivers them reliably. And you will find out why that dashboard shows line 0. Keep that line 0 question in mind. We'll answer it near the end. Why does window.onerror give you so little? Before touching code, let's set up the problem. Browsers expose three separate failure channels, and most tutorials only cover one. Per MDN's documentation for the Window error event, the error event fires on window when a resource fails to load or can't be used, for example when a script throws. MDN also notes that it only fires for script errors thrown synchronously, such as during initial load or inside event handlers. A rejected promise with no handler fires a different event, unhandledrejection. Here's the map: Failure What you listen for ------------------------------- ------------------------------------- Sync exception (script/handler) window "error" (ErrorEvent) Failed // "error" in CAPTURE phase (plain Event) Unhandled promise rejection window "unhandledrejection" If you only set window.onerror, you cover the first row and nothing else. And as you'll see, even that row has traps. Before we get into the examples, a quick note: The code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular/React versions. Always refer to the official documentation for the most current API and syntax. Next up: the version of this that almost every project starts with, and why it quietly fails. What does a basic window.onerror handler actually receive? Quick version (beginner) window.onerror takes a function with five arguments. Here is the classic setup: // src/naive-monitor.ts window.onerror = (message, source, lineno, colno, error) => { void fetch('/api/errors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, source, lineno, colno }), }); }; export {}; Trigger an error from your app: // src/main.ts import './naive-monitor'; const user = JSON.parse('{}') as { profile?: { name: string } }; console.log(user.profile!.name); In Chrome, your endpoint receives something like this: { "message": "Uncaught TypeError: Cannot read properties of undefined (reading 'name')", "source": "http://localhost:5173/src/main.ts", "lineno": 4, "colno": 22 } Message text varies by browser, and so does the exact line and column in bundled code. That works. It runs. It also has four problems. Real-world version (pitfalls) It's a single slot. Assigning window.onerror replaces whatever was there. If a third-party widget or another monitoring script installed a handler first, you just deleted it. If one installs after you, it deletes yours. No stack. You sent the first four arguments and dropped error, which usually holds the Error object and its stack. fetch during page teardown can be cancelled. Errors that happen as the user leaves are exactly the ones you lose. Resource failures and promise rejections are invisible. A 404 on a script or an unhandled await never reaches this handler. There's a fifth trap that bites people who copy handlers from old snippets. MDN documents that for onerror on Window, returning true cancels the default behavior, so the error won't appear in the console, though the current script still stops executing. A handler that ends with return true makes development miserable because your own errors vanish from DevTools. Under the hood (internals, trade-offs) MDN notes that onerror on Window and WorkerGlobalScope is the only event handler property that receives more than one argument, for historical reasons. That asymmetry only applies to the property. If you use addEventListener('error', ...), you get a single ErrorEvent object whose properties (message, filename, lineno, colno, error) match those five arguments. The return-true rule does not apply there either. That's the first reason I'd pick addEventListener every time: it composes with other code, and it has one argument shape. Have you ever shipped a global handler and discovered months later that another script had overwritten it? Tell me what happened in the comments, I'm collecting war stories. So why was your dashboard stuck on "Script error."? That's the failed first attempt, and it's next. Why does the message say "Script error." with line 0? Quick version (beginner) When a script from a different origin throws, the browser deliberately hides the details from your handler. It does this so a page can't peek at error text from another site's code. You get a generic message and zeroed location. Here is a page that reproduces it. Save it as index.html: Cross-origin error demo window.addEventListener('error', (event) => { console.log(JSON.stringify({ message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, hasError: event.error !== null, })); }); Serve widget.js from a second origin (a different port counts) with a line like null.boom; in it. The console prints: {"message":"Script error.","filename":"","lineno":0,"colno":0,"hasError":false} That's your line 0. Real-world version (pitfalls) The fix needs both sides to cooperate. The script tag needs the crossorigin attribute, and the server hosting the script must answer with a matching CORS header. Cross-origin error demo window.addEventListener('error', (event) => { console.log(JSON.stringify({ message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, hasError: event.error !== null, })); }); And the response header on the CDN or asset server: Access-Control-Allow-Origin: * Now the output becomes: {"message":"Uncaught TypeError: Cannot read properties of null (reading 'boom')","filename":"http://localhost:4000/widget.js","lineno":1,"colno":6,"hasError":true} The classic pitfall: you add crossorigin but the CDN doesn't send the header. In that case the browser refuses to load the script at all, which is worse than a vague error. Roll it out with your CDN config first, then the attribute. The Sentry write-up on reporting window.onerror covers this same cross-origin behavior and is a good companion read. Under the hood (internals, trade-offs) The browser treats the error as "muted" when the script isn't same-origin or CORS-approved. It's a privacy rule, not a bug, so there's nothing a handler can do after the fact. The only fix is at the delivery layer: serve your own bundles from the same origin, or serve them with CORS enabled and mark the tags with crossorigin. Third-party scripts you don't control (ad tags, chat widgets) will stay muted unless their vendor sends CORS headers. Decide whether you want to filter those out of your dashboards, because "Script error." with no location is unactionable noise. I would drop them or tag them as low priority rather than alert on them. That explains the screenshot. But fixing it leaves a lot of failures still unseen. Let's rebuild the monitor. How do you catch resource errors and rejected promises too? Quick version (beginner) Use addEventListener for runtime errors, pass true as the third argument to catch resource errors, and add a separate unhandledrejection listener. This version is small and complete: // src/monitor-v2.ts const post = (data: Record): void => { navigator.sendBeacon( '/api/errors', JSON.stringify({ ...data, pageUrl: location.href }), ); }; window.addEventListener( 'error', (event) => { if (event instanceof ErrorEvent) { post({ kind: 'runtime', message: event.message, source: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack, }); } else if (event.target instanceof HTMLElement) { post({ kind: 'resource', tag: event.target.tagName, src: event.target.getAttribute('src'), }); } }, true, ); window.addEventListener('unhandledrejection', (event) => { post({ kind: 'promise', message: String(event.reason) }); }); export {}; Add a broken image and a floating promise to a test page: import './src/monitor-v2.ts'; Promise.reject(new Error('payment API timed out')); Your endpoint receives, among others: {"kind":"resource","tag":"IMG","src":"/missing.png","pageUrl":"http://localhost:5173/"} {"kind":"promise","message":"Error: payment API timed out","pageUrl":"http://localhost:5173/"} Real-world version (pitfalls) Why the true? Resource load failures fire an error event on the element itself, and that event doesn't bubble. Your window listener would never see it in the bubbling phase. The capture phase runs on the way down, before the target, so a capturing listener on window sees every element's error event. MDN also says the event is an ErrorEvent for script errors and a plain Event otherwise, which is why the code branches on instanceof ErrorEvent. Here's what most developers get wrong: they log event.reason as-is. A rejection reason can be anything, a string, an object, undefined. Always check instanceof Error before reading .stack. Under the hood (internals, trade-offs) Capture-phase listeners on window also see errors that other code already handled with try/catch? No. A caught exception never becomes an error event, so you only see what the page failed to handle. Resource errors are different: there's no try/catch around an , so every failed image, script, and stylesheet reports. That can be loud. A single bad CDN path across a list of 200 thumbnails produces 200 events, which is why the next step matters. Notice the trade-off: catching more means collecting more, and more means cost and noise. Next we add guard rails. How do you make window.onerror monitoring safe for production? Quick version (beginner) Three things turn a toy into something you can ship: deduplicate repeats, cap reports per session, and never let the reporter itself throw. Here's the full module: // src/error-monitor.ts export type ReportKind = 'runtime' | 'resource' | 'promise'; export interface ErrorReport { kind: ReportKind; message: string; source?: string; lineno?: number; colno?: number; stack?: string; pageUrl: string; userAgent: string; release: string; timestamp: number; } type PartialReport = Pick & Partial; export interface MonitorOptions { endpoint: string; release: string; maxReportsPerSession?: number; send?: (endpoint: string, body: string) => void; target?: EventTarget; } export interface ErrorMonitor { reportError: (error: unknown) => void; uninstall: () => void; } export function defaultSend(endpoint: string, body: string): void { const blob = new Blob([body], { type: 'application/json' }); if (typeof navigator.sendBeacon === 'function' && navigator.sendBeacon(endpoint, blob)) { return; } void fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true, }).catch(() => undefined); } export function installErrorMonitor(options: MonitorOptions): ErrorMonitor { const send = options.send ?? defaultSend; const limit = options.maxReportsPerSession ?? 20; const target: EventTarget = options.target ?? window; const seen = new Set(); let count = 0; let reporting = false; const report = (partial: PartialReport): void => { if (reporting || count >= limit) return; const key = [ partial.kind, partial.message, partial.source ?? '', partial.lineno ?? 0, partial.colno ?? 0, ].join('|'); if (seen.has(key)) return; seen.add(key); reporting = true; try { count += 1; const payload: ErrorReport = { ...partial, pageUrl: location.href, userAgent: navigator.userAgent, release: options.release, timestamp: Date.now(), }; send(options.endpoint, JSON.stringify(payload)); } catch { // The reporter must never throw, or it would report itself forever. } finally { reporting = false; } }; const reportError = (error: unknown): void => { report({ kind: 'runtime', message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); }; const onError = (event: Event): void => { if (event instanceof ErrorEvent) { const error: unknown = event.error; report({ kind: 'runtime', message: event.message, source: event.filename, lineno: event.lineno, colno: event.colno, stack: error instanceof Error ? error.stack : undefined, }); return; } const el = event.target; if (el instanceof HTMLElement) { report({ kind: 'resource', message: `Failed to load `, source: el.getAttribute('src') ?? el.getAttribute('href') ?? undefined, }); } }; const onRejection = (event: PromiseRejectionEvent): void => { const reason: unknown = event.reason; report({ kind: 'promise', message: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : undefined, }); }; target.addEventListener('error', onError as EventListener, true); target.addEventListener('unhandledrejection', onRejection as EventListener); return { reportError, uninstall: () => { target.removeEventListener('error', onError as EventListener, true); target.removeEventListener('unhandledrejection', onRejection as EventListener); }, }; } Use it from your entry file: // src/main.ts import { installErrorMonitor } from './error-monitor'; installErrorMonitor({ endpoint: '/api/errors', release: '1.0.0' }); Real-world version (pitfalls) Each guard rail earns its place: The reporting flag stops recursion. If building or sending a report throws, that exception would fire another error event, which calls the reporter again. Without the flag you can melt a tab. The dedupe key collapses the 200 broken thumbnails into one report. The trade-off is that you lose counts. If you need "how many users hit this", count on the server by key, or send the count with the first report. The session cap protects your endpoint from a render loop that throws 60 times a second. release is the field you'll thank yourself for. Without it you can't tell whether yesterday's fix worked. Under the hood (internals, trade-offs) Why sendBeacon first? The browser queues a beacon so it survives page unload, which is when fetch often gets cancelled. When sendBeacon returns false (queue full) or doesn't exist, the fallback is fetch with keepalive: true, which has the same survive-unload intent. One caveat: a beacon with a JSON Blob type sent to another origin triggers a CORS preflight. Keep your error endpoint same-origin, or set up CORS on it. The target option exists for one reason: testing. We'll use it in a moment. Here's a quick pause: have you ever been unable to reproduce a production error because the report arrived without a stack or release tag? What was the one missing field that would have saved you? Reply in the comments, I read them all. Next teaser: how do you prove this thing works without breaking your own test runner? How do you unit test window error handlers? Quick version (beginner) Install Vitest and jsdom: npm install --save-dev vitest jsdom // vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', }, }); Now the tests. They dispatch real ErrorEvent and Event objects at document, so we exercise the same code path the browser uses: // src/error-monitor.test.ts import { afterEach, describe, expect, it, vi } from 'vitest'; import { installErrorMonitor, type ErrorMonitor, type ErrorReport } from './error-monitor'; describe('installErrorMonitor', () => { let monitor: ErrorMonitor | undefined; const send = vi.fn void>(); const install = (maxReportsPerSession?: number): void => { send.mockClear(); monitor = installErrorMonitor({ endpoint: '/api/errors', release: '1.4.2', send, target: document, maxReportsPerSession, }); }; const lastReport = (): ErrorReport => { const call = send.mock.calls[send.mock.calls.length - 1]; return JSON.parse(call[1]) as ErrorReport; }; afterEach(() => { monitor?.uninstall(); document.body.innerHTML = ''; }); it('reports runtime errors with location and release', () => { install(); document.dispatchEvent( new ErrorEvent('error', { message: 'boom', filename: 'app.js', lineno: 10, colno: 5, error: new Error('boom'), }), ); expect(send).toHaveBeenCalledTimes(1); const report = lastReport(); expect(report.kind).toBe('runtime'); expect(report.lineno).toBe(10); expect(report.release).toBe('1.4.2'); expect(report.stack).toContain('boom'); }); it('reports failed resources caught in the capture phase', () => { install(); const img = document.createElement('img'); img.setAttribute('src', '/missing.png'); document.body.appendChild(img); img.dispatchEvent(new Event('error')); const report = lastReport(); expect(report.kind).toBe('resource'); expect(report.message).toBe('Failed to load '); expect(report.source).toBe('/missing.png'); }); it('reports unhandled promise rejections', () => { install(); const event = Object.assign(new Event('unhandledrejection'), { reason: new Error('nope'), }); document.dispatchEvent(event); expect(lastReport().kind).toBe('promise'); expect(lastReport().message).toBe('nope'); }); it('deduplicates identical errors', () => { install(); const make = (): ErrorEvent => new ErrorEvent('error', { message: 'same', filename: 'a.js', lineno: 1, colno: 1 }); document.dispatchEvent(make()); document.dispatchEvent(make()); expect(send).toHaveBeenCalledTimes(1); }); it('stops after the per-session limit', () => { install(2); for (let i = 0; i < 5; i += 1) { document.dispatchEvent(new ErrorEvent('error', { message: `err ${i}` })); } expect(send).toHaveBeenCalledTimes(2); }); }); Run them: npx vitest run Expected output (timings will differ): ✓ src/error-monitor.test.ts (5 tests) Test Files 1 passed (1) Tests 5 passed (5) Real-world version (pitfalls) Why dispatch on document instead of window? Test runners commonly watch window for uncaught errors and fail the run when one appears. Dispatching a synthetic error at the real window can look like a genuine crash to the runner. A stand-in target keeps the test about your logic. The capture-phase test works because document sits above the img in the event path. Under the hood (internals, trade-offs) These tests don't prove the browser delivers events the way jsdom does. They prove your branching, dedupe, and limits. For the delivery side, add one end-to-end check with a real browser tool against a page that really throws. Cheap tests for logic, one expensive test for wiring: that's the split I'd keep. Now the part most Angular readers are waiting for. How do you connect this to Angular's ErrorHandler? Quick version (beginner) Angular routes errors from templates, lifecycle hooks, and event handlers it manages to its own ErrorHandler, and by default that logs to the console. Because Angular catches those errors first, they often never reach window.onerror. So wire your monitor into ErrorHandler: // src/app/monitoring-error-handler.ts import { ErrorHandler, Injectable, InjectionToken, inject } from '@angular/core'; export const REPORT_ERROR = new InjectionToken void>('REPORT_ERROR'); @Injectable() export class MonitoringErrorHandler implements ErrorHandler { private readonly report = inject(REPORT_ERROR); handleError(error: unknown): void { this.report(error); console.error(error); } } // src/main.ts import { ErrorHandler } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { App } from './app/app'; import { REPORT_ERROR, MonitoringErrorHandler } from './app/monitoring-error-handler'; import { installErrorMonitor } from './error-monitor'; const monitor = installErrorMonitor({ endpoint: '/api/errors', release: '1.0.0' }); bootstrapApplication(App, { providers: [ { provide: REPORT_ERROR, useValue: (error: unknown) => monitor.reportError(error) }, { provide: ErrorHandler, useClass: MonitoringErrorHandler }, ], }).catch((error: unknown) => monitor.reportError(error)); // src/app/app.ts import { Component, signal } from '@angular/core'; @Component({ selector: 'app-root', template: ` Break something @if (clicks() > 0) { Clicked {{ clicks() }} times } `, }) export class App { protected readonly clicks = signal(0); protected explode(): void { this.clicks.update((n) => n + 1); const user = JSON.parse('{}') as { profile?: { name: string } }; console.log(user.profile!.name); } } Click the button and your network tab shows a POST to /api/errors: {"kind":"runtime","message":"Cannot read properties of undefined (reading 'name')","stack":"TypeError: Cannot read properties of undefined (reading 'name')\n at App.explode ...","pageUrl":"http://localhost:4200/","userAgent":"Mozilla/5.0 ...","release":"1.0.0","timestamp":1790000000000} (The stack is truncated here for readability, yours will be full.) Real-world version (pitfalls) Notice the module doesn't import Angular, and the handler doesn't import the monitor. The token is the seam. That keeps the monitor reusable in React or plain JavaScript, and it keeps the Angular piece trivially testable: // src/app/monitoring-error-handler.spec.ts import { ErrorHandler } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MonitoringErrorHandler, REPORT_ERROR } from './monitoring-error-handler'; describe('MonitoringErrorHandler', () => { it('forwards errors to the reporter', () => { const captured: unknown[] = []; TestBed.configureTestingModule({ providers: [ { provide: REPORT_ERROR, useValue: (error: unknown) => captured.push(error) }, { provide: ErrorHandler, useClass: MonitoringErrorHandler }, ], }); const handler = TestBed.inject(ErrorHandler); const error = new Error('template blew up'); handler.handleError(error); expect(captured).toEqual([error]); }); }); ng test Executed 1 of 1 SUCCESS (The exact summary line depends on the runner your CLI version generates. The test itself uses only describe, it, and expect, so it works with either of the common runners. The console.error call will print the test error to the log, which is expected.) For React, the same idea applies. Error boundaries catch render-phase errors, and those need a class component for the boundary itself, so check react.dev for the current boundary and root error callback options, then call monitor.reportError from there. Under the hood (internals, trade-offs) Do you need both layers? Yes. The framework handler sees what the framework swallows. The window listeners see everything else: third-party scripts, failed assets, code outside the framework, and the bootstrap itself. Dedupe makes it safe if the same error reaches both. One last gap. Reports now arrive, but the stacks point into minified bundles. How do you read minified stack traces from window.onerror? Your lineno and colno describe the file the browser ran, which is the bundle. Without source maps, "line 1, column 48213" is useless. The usual setup: generate source maps at build time, upload them to your error backend (or keep them private on your own server), and don't serve them publicly if your code is sensitive. In Vite, hidden generates maps without adding the reference comment to the bundle: // vite.config.ts import { defineConfig } from 'vite'; export default defineConfig({ build: { sourcemap: 'hidden', }, }); For Angular, the builder has a matching hidden option under sourceMap in angular.json, so confirm the exact shape on angular.dev for your CLI version. The release field from our payload is what ties a report to the right map. A map from build 1.0.0 will mislabel frames from 1.0.1. When shouldn't you build this yourself? If you need grouping across thousands of error variants, alerting, release health, and session replay, use a hosted tool. This module is the right size for small apps, internal tools, and for understanding what those tools are doing under the hood. It's also a good way to learn the browser's failure channels so you can configure any vendor SDK properly. What was behind line 0 all along? Back to your Friday screenshot. The counter said "Script error." because a script from another origin threw, and the browser muted it. Your handler was fine. The delivery was the problem: the script tag lacked crossorigin, and the host lacked the CORS header. Add both, and the next report carries a file, a line, a column, and a stack. Add the capture listener and the promise listener, and failed images and floating promises show up as well. Add the beacon and the limits, and you stop losing the last error before a tab closes. The lesson I'd take from this: window.onerror isn't wrong, it's just one of three channels, and the rest of the work is delivery and noise control. If this changed how you think about error reporting, share it with the teammate who owns your monitoring setup. They'll thank you after the next Friday incident. Bonus tips Beginner: Search your codebase for window.onerror = and return true. Both are worth a second look. Intermediate: Use window.reportError(error) inside a catch block when you want to log a handled error through the same global channel. Check the MDN compatibility table before relying on it in older browsers. Advanced: Track a "same error, different release" counter server-side using the dedupe key plus release. A fix that doesn't move that number didn't fix anything. Recap window.onerror passes five arguments and is a single slot. addEventListener('error', ...) composes and gives you one ErrorEvent. Returning true from onerror hides the error in the console. "Script error." with line 0 means a muted cross-origin script. Fix it with the crossorigin attribute plus a CORS header. Resource errors don't bubble, so listen in the capture phase. Rejected promises need their own unhandledrejection listener. Dedupe, cap, and guard against recursion in the reporter. Send with sendBeacon, fall back to fetch with keepalive. Wire your framework's error handler into the same reporter, and test the logic with synthetic events. Further reading: the MDN page on the Window error event, an archived copy of the older onerror documentation, the Sentry post on reporting window.onerror, the Fiverr engineering guide to front end error monitoring, and this LinkedIn write-up on capturing and reporting JavaScript errors with window.onerror. What did you think? Did this approach match how you are solving it, or do you have a different take? Drop a comment. I genuinely read every single one. Found this helpful? If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference. Want more tips like this? I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one. Let us connect. Find me on LinkedIn or GitHub and let us keep the conversation going. Follow Me for More Angular & Frontend Goodness: I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides. 💼 LinkedIn — Let’s connect professionally 🎥 Threads — Short-form frontend insights 🐦 X (Twitter) — Developer banter + code snippets 👥 BlueSky — Stay up to date on frontend trends 🌟 GitHub Projects — Explore code in action 🌐 Website — Everything in one place 📚 Medium Blog — Long-form content and deep-dives 💬 Dev Blog — Free Long-form content and deep-dives ✉️ Substack — Weekly frontend stories & curated resources 🧩 Portfolio — Projects, talks, and recognitions ✍️ Hashnode — Developer blog posts & tech discussions ✍️ Reddit — Developer blog posts & tech discussions
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to