Front End Observability: Why Your window.onerror Reports Say "Script error." (and How to Fix Them)
DEV Community

Front End Observability: Why Your window.onerror Reports Say "Script error." (and How to Fix Them)

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 <img>/<script>/<link> "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:

<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Cross-origin error demo</title>
  </head>
  <body>
    <script>
      window.addEventListener('error', (event) => {
        console.log(
          JSON.stringify({
            message: event.message,
            filename: event.filename,
            lineno: event.lineno,
            colno: event.colno,
            hasError: event.error !== null,
          })
        );
      });
    </script>
    <script src="http://localhost:4000/widget.js"></script>
  </body>
</html>

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.

<!-- index.html (changed line only matters: crossorigin) -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Cross-origin error demo</title>
  </head>
  <body>
    <script>
      window.addEventListener('error', (event) => {
        console.log(
          JSON.stringify({
            message: event.message,
            filename: event.filename,
            lineno: event.lineno,
            colno: event.colno,
            hasError: event.error !== null,
          })
        );
      });
    </script>
    <script src="http://localhost:4000/widget.js" crossorigin="anonymous"></script>
  </body>
</html>

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<string, unknown>): 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:

<img src="/missing.png" alt="" />
<script type="module">
  import './src/monitor-v2.ts';
  Promise.reject(new Error('payment API timed out'));
</script>

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 <img>, 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<ErrorReport, 'kind' | 'message'> & Partial<Omit<ErrorReport, 'kind' | 'message'>>;
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<string>();
  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,
    });
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.