ObservabilityGENERALBROWSER-SPECIFICSPEC-EVOLVING

Frontend Error Tracking

Capturing exceptions and unhandled rejections with enough context to act on — the route, the release, the breadcrumbs — and separating real signal from extensions, bots and opaque cross-origin noise.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

An exception was thrown in a browser I will never see, on a device I do not own — what has to reach me for it to be fixable?

The user intent

Someone clicks Save. Nothing happens. They do not file a bug report; they click once more, shrug, and leave. The only way anyone ever learns this happened is if the browser says so.

The obvious build

Wrap the risky calls in try/catch, log to the console, and add a global window.onerror that posts the message to an endpoint. Errors show up in a list, and we work through the list.

Why it breaks

The console is on the user's machine. Nobody reads it. A caught error that logs and returns is indistinguishable, from the outside, from a feature that silently does nothing.

How it breaks in a real browser
  • The console is on the user's machine. Nobody reads it. A caught error that logs and returns is indistinguishable, from the outside, from a feature that silently does nothing.
  • window.onerror never fires for a rejected promise with no handler, and most of a modern application's async work is promises. The most common shape of frontend failure — an await that throws inside an event handler — is exactly the shape this misses.
  • For any script served from another origin the report arrives as the literal string Script error. with no filename, no line, no column and no stack. That is most of a real application, because most of a real application ships from a CDN.
  • The stack you do get points at main.4f2c1a.js:1:284915, which names a column in a minified bundle and nothing a human can act on (Source Maps).
  • One bad deploy produces forty thousand rows of the same bug, and grouping by message text splits it into forty thousand groups because the message interpolates an order id.
  • The report says what threw. It does not say what the person was doing, which route they were on, which release they were running, or whether the app was already broken three interactions earlier.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The browser fires an error event on window for uncaught exceptions, carrying message, filename, lineno, colno and — crucially — an error property holding the actual Error object with its stack. Listening for the event rather than assigning window.onerror lets several reporters coexist.
  • A promise that rejects with no rejection handler fires a separate unhandledrejection event on window, on its own path, at the point the microtask queue drains and the engine concludes nobody is going to handle it. There is also rejectionhandled for the case where a handler is attached late.
  • Cross-origin redaction. When a script comes from a different origin, the browser deliberately strips the error down to Script error. with zero detail. This is the same-origin policy doing its job: error text can leak the contents of a script the current origin is not allowed to read, so a page could otherwise probe a third party's code by triggering exceptions in it and reading the messages (The Same-Origin Policy).
  • The redaction lifts only if both halves are done: the script is served with an Access-Control-Allow-Origin header, and the <script> tag carries a crossorigin attribute so the browser makes a CORS-mode request in the first place. One without the other silently changes nothing (CORS).
  • Source maps translate a minified frame back to a file, line and symbol you wrote. The translation belongs on the ingest side, keyed by the release, not in the browser (Source Maps).
  • Grouping is a fingerprint function over the report — typically normalised stack frames plus error type — that decides which reports are "the same bug". It is the difference between a triage queue and a wall of noise.
  • Breadcrumbs are a bounded ring buffer of recent events — route changes, clicks, network calls, console entries — attached to the report so the exception arrives with the fifteen seconds of history that produced it.

What this makes the browser do

And which of it is avoidable.

  • The listeners themselves cost essentially nothing until something throws. The cost is in what you do next: serialising an error, capturing and normalising a stack, and walking the DOM to describe the element a breadcrumb refers to.
  • The monitoring bundle must be fetched, parsed and executed before it can catch anything. A reporter loaded at the bottom of the bundle graph misses every error thrown during startup — which is the class of error most likely to be fatal (The Real Cost of JavaScript).
  • Breadcrumb capture usually means wrapping addEventListener, fetch, XMLHttpRequest and the history API. Every wrapped call now runs a little extra code on the main thread, on every call, forever.
  • Source-map resolution is expensive and must not happen in the browser. Shipping maps to the client to symbolicate there costs bandwidth, memory and main thread for a benefit that belongs on a server.

The envelope, not just the exception

A useful error report is an envelope. The exception is one field in it, and on its own it is close to useless: TypeError: Cannot read properties of undefined (reading 'id') tells you the shape of the mistake and nothing about how anyone got there. The fields around it are what turn a report into a reproduction.

Note the two listeners. They are not alternatives — an application built on promises will produce most of its failures through the second one, and a reporter that only installs the first will show a suspiciously quiet dashboard while users are stuck on a spinner.

From throw to triage
keyed by releaseUncaught throw / rejectionwindow error eventsAdd route, release, breadcrumbsScrub PII, rate-limit, de-dupesendBeacon / keepalive fetchIngestSymbolicate with source mapsFingerprint + groupTriage queue
UserLLMAgentToolDataDecisionHumanGuardrail
Installed before the app bundle, buffering until the reporter loads
1type Report = {
2 kind: 'error' | 'unhandledrejection'
3 message: string
4 stack?: string
5 // context — this is the half that makes it actionable
6 route: string // the PATTERN: '/orders/:id', never '/orders/8842'
7 release: string // injected at build time; ties the stack to a source map
8 deviceClass: string // 'mobile-low' | 'mobile-high' | 'desktop' — not a UA string
9 sessionId: string // random, per-tab, not a user identifier
10 breadcrumbs: Crumb[] // bounded ring buffer of recent activity
11}
12
13const pending: Report[] = []
14
15window.addEventListener('error', (e) => {
16 // e.error is the real Error when it is available; e.message is all you get
17 // for a cross-origin script without the crossorigin attribute + ACAO header.
18 enqueue('error', e.error?.message ?? e.message, e.error?.stack)
19})
20
21window.addEventListener('unhandledrejection', (e) => {
22 const r: unknown = e.reason
23 // A rejection can be anything at all — a string, an object, undefined.
24 enqueue(
25 'unhandledrejection',
26 r instanceof Error ? r.message : String(r),
27 r instanceof Error ? r.stack : undefined,
28 )
29})
30
31// The page may be gone before an ordinary request completes.
32addEventListener('pagehide', () => flush({ beacon: true }))

Two listeners, not one; addEventListener rather than assigning window.onerror, so a second reporter does not silently replace the first; and a flush on pagehide, because the errors that kill a page are the ones a normal request cannot outlive.

`Script error.` — the browser refusing to talk

GENERALThe redaction rule and the two-part fix are specified and identical across Chromium, Gecko and WebKit; what differs is only how much of the surrounding detail each engine chooses to populate on the ErrorEvent once the script is no longer opaque.

This is the single most common frustration in frontend error tracking, and almost everyone meets it as "our error tool is broken". It is not. The browser is withholding information on purpose.

The reasoning is worth understanding, because it explains why the fix is where it is. Error messages and stack traces are derived from the *contents* of a script. If a page could read them for a script it loaded from another origin, it could probe that origin's code — trigger exceptions deliberately, read the messages, and reconstruct behaviour it is not permitted to read directly. So the browser hands back a fixed, contentless string for any script it treats as opaque.

A cross-origin script is opaque unless the browser was told to fetch it in CORS mode and the server agreed. Both halves are required, and each one alone changes nothing at all, which is why this is so often half-fixed and still broken.

The same bundle, two error reports
Opaque cross-origin script
<!-- served from https://cdn.example.com, page is https://app.example.com -->
<script src="https://cdn.example.com/main.4f2c1a.js"></script>

<!-- what window.onerror receives:
     message:  "Script error."
     filename: ""
     lineno:   0
     colno:    0
     error:    null            <- no stack, no type, nothing
-->
CORS-mode fetch, and the server agrees
<script src="https://cdn.example.com/main.4f2c1a.js" crossorigin="anonymous"></script>

<!-- and the CDN response carries:
     Access-Control-Allow-Origin: https://app.example.com

     what window.onerror now receives:
     message:  "TypeError: order.total is not a function"
     filename: "https://cdn.example.com/main.4f2c1a.js"
     error:    Error { stack: "..." }   <- symbolicate this
-->

The attribute makes the browser issue a CORS-mode request; the header makes the response non-opaque to the loading origin. Only when both are true does the browser consider the script readable by this page, and only a readable script may surface its error text. Adding the attribute without the header additionally *fails the load*, so a half-fix is worse than no fix.

The noise floor

The second reason error dashboards go unread is that most of what is in them did not come from your code. A production frontend runs inside an environment full of other people's JavaScript, and all of it throws into your window.

The response is almost never "fix it" — you cannot fix a browser extension. It is to classify, so the groups that are yours stay visible. The discipline is to build the filters deliberately and to record how much you filtered, because a filter that silently swallows a real regression is worse than the noise it removed.

What is actually in an unfiltered error feed
TriggerSymptomCauseResponse
Cross-origin bundle without crossorigin + Access-Control-Allow-OriginScript error. with an empty filename, line zero and a null errorSame-origin policy redacts error detail for opaque scriptsSet the attribute and the header together; track the redacted share as an instrumentation health metric.
Browser extension injecting a content scriptErrors referencing chrome-extension:// or moz-extension:// frames, or top frames from a file you never shippedExtension code runs in the page and throws into the same windowDrop reports whose top frame is not on a known asset origin; count them separately rather than deleting them blind.
Bots, crawlers and headless clientsA large group nobody can reproduce, often on ancient engines with no matching support ticketsAutomated traffic executing your bundle in environments no human usesClassify by device and browser class at capture time; segment the dashboard rather than trusting a global count.
Ad blocker or privacy extension blocking the ingest hostError volume implausibly low for a known-broken release; whole user segments absentThe reporting request never left the browserServe ingest from a first-party path, and treat absolute error counts as a lower bound, never a measurement.
Third-party tag failing on its own originRecurring errors from an analytics or chat vendor's scriptAnything in a <script> tag runs with your page's full authority and reports as your page (Third-Party Scripts and the Supply Chain)Group by asset origin; route vendor groups to whoever owns the vendor relationship.
Stale bundle in a tab open for daysErrors citing removed endpoints or a release that is no longer deployedWeb clients never update atomically (Long-Lived Clients and Version Skew)Break every error metric down by release before reacting to it.
ResizeObserver loop and other benign engine warningsA high-volume group with no user-visible effectThe engine reports a condition it recovered fromAllow-list known benign messages explicitly, with a comment saying why, so the list is reviewable.

How to build it

Most important first.

  • Install the handlers first — a tiny inline script in the document head that registers error and unhandledrejection listeners and buffers what it catches until the real reporter loads. Errors during startup are the ones you most need and the ones a late reporter cannot see.
  • Listen for both events. Treat an unhandled rejection as a first-class error, not a lesser one; in an application built on async/await it is the majority case.
  • Serve your own scripts with crossorigin and the matching Access-Control-Allow-Origin header, so that a cross-origin bundle still produces a real stack rather than Script error..
  • Attach the context that makes a report actionable: the route pattern (not the filled-in URL), the release identifier, a device and browser class, a session id, and the breadcrumb trail (Release Health).
  • Upload source maps at build time, keyed to the same content hash as the bundle, and keep them off the public origin unless you have decided to publish your source deliberately (Content-Hashed Assets).
  • Fingerprint on the normalised stack, not the message string. Strip ids, hostnames and query strings out of frames before hashing so the same bug groups regardless of which order it happened to.
  • Rate-limit per session and de-duplicate identical reports client-side. One user in a render loop can otherwise emit more events in a minute than your entire population does in a day.
  • Scrub before sending, not after storing. Once a token or an email address is in the ingest pipeline it is in backups, in logs and in a vendor's systems (What You Just Wrote Into a Log Half the Company Can Read in Observability & Performance).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • An error a user needs to know about must be announced, not merely reported. Sending telemetry and returning silently leaves a screen-reader user with no evidence anything happened at all; the visible failure state and the telemetry are two separate obligations (Errors People Can Actually Perceive).
  • Surface recoverable errors through a live region or by moving focus to the message, so the failure reaches someone who is not watching the pixels (Live Regions and Announcement).
  • The monitoring bundle is a self-inflicted wound if it blocks the main thread. A reporter that parses and executes ahead of your application delays interactivity for every user in order to observe a few of them; load it early but do not let it be render-blocking (Render-Blocking Resources).
  • Breadcrumb instrumentation wraps event listeners. A wrapper that swallows an exception, returns the wrong value, or calls preventDefault on the way through can break a default action, a keyboard shortcut or a form submission — instrumentation must be transparent to the event, including for keyboard and assistive-technology-generated events (Keyboard Operability).

What can go wrong

Failure modes
  • The reporter throws. An exception inside an error handler either disappears or, worse, re-enters the handler and produces an unbounded loop of network requests from a page that has already failed.
  • The quota burns in the first ten minutes after a bad deploy, and the reports that would have told you what the *second* problem was are dropped.
  • Source maps go stale: the bundle is rebuilt and re-hashed but the maps are not re-uploaded, so every frame symbolicates to the wrong line and triage quietly stops trusting the tool.
  • Grouping too aggressively hides a new, serious bug inside an old, tolerated group. Grouping too loosely makes the queue unusable and everyone stops looking.
  • The ingest endpoint is on a hostname that ad blockers and privacy extensions block by default. Your error rate is then systematically under-reported in exactly the population most likely to have things break.
  • Personal data arrives inside the error itself: a message that interpolates an email address, a breadcrumb recording the value of an input, a URL carrying a reset token (Session Replay and the Privacy It Costs).
What can arrive out of order
  • An error thrown during unload or navigation may never be transmitted at all unless the reporter uses sendBeacon or a fetch with keepalive; the page is gone before an ordinary request completes.
  • Breadcrumbs recorded from async callbacks land in completion order, not initiation order. A trail that reads as cause-then-effect may be effect-then-cause.
  • Reports arrive after the deploy that fixed them, because the client that produced them was still running the old bundle. Time of arrival is not time of occurrence.
  • Two reporters both wrapping addEventListener can wrap each other, and the order of installation decides whose breadcrumbs contain whose.
Security
  • Error tracking is a data-collection system pointed at your users, and inherits every obligation that implies. Treat report bodies as personal data until proven otherwise.
  • URLs are the most common leak. A route like /reset?token=… or /invoices/8842 ends up in filename, in breadcrumbs and in the referrer. Report route patterns and strip query strings before they leave the page (URL Parameters).
  • The ingest endpoint is unauthenticated by construction — it must accept reports from a broken page — so anyone can post fabricated errors to it. Rate-limit by origin and key, and never treat report content as trusted input on the server.
  • Publishing source maps on the public origin makes your unminified source, including comments and dead code paths, readable by anyone. That may be fine; it should be a decision rather than an accident (Source Maps).
  • CSP violation reports are a separate channel with its own endpoint and its own report shape. They tell you about injected and blocked resources that never surface as JavaScript errors at all (Content Security Policy).
  • Nothing in a frontend error report can be trusted for authorization decisions. It is client-supplied text from an environment the user fully controls (What the Frontend Is Responsible For in Auth).
Misreads
  • "No errors reported means no errors." It means no errors *reached you* — which is also what a blocked endpoint, a reporter that failed to load, and a crash before instrumentation look like.
  • "window.onerror catches everything." It catches uncaught exceptions on the main thread of the top-level document. Not rejections, not worker errors, not resource load failures, not errors inside another frame.
  • "Script error. is a browser bug." It is the same-origin policy working correctly. The fix is on your side: a crossorigin attribute and a response header.
  • "The stack trace is the bug." The stack tells you where it surfaced. The breadcrumbs, the route and the release tell you what caused it, and those are the fields people skip adding.
  • "Errors are the whole picture." A page that never throws can still be failing every user — a request that silently returned an empty list throws nothing at all (Loading, Error, Empty — The States You Did Not Render).

Measuring it, and what changes in the field

How you would see this
  • The share of reports arriving as Script error. with no stack. That percentage measures your instrumentation, not your application, and it should be near zero once crossorigin and the header are both in place.
  • Crash-free sessions and crash-free users, rather than raw error counts. A count moves when traffic moves; a rate moves when quality moves (Release Health).
  • Error rate broken down by release, so a regression has a suspect ("What Changed?" — Deploy Markers and the Invisible Deploys in Observability & Performance).
  • The proportion of groups with a symbolicated top frame — the health check for your source-map pipeline.
  • The console and the Sources panel with source maps loaded, for reproducing a symbolicated stack locally (A Mental Model of the Devtools).
Slow device, slow network, large data, old tab
  • On a slow device, timeouts and aborted requests surface as errors that a fast machine never produces. A spike in one error group can be a device-population story rather than a code story (Network Failures Only the Client Can See).
  • In a long-lived tab, the loaded bundle can be several releases old and calling endpoints that no longer exist. Errors from a release you already replaced are normal and need to be read as such (Long-Lived Clients and Version Skew).
  • On a locked-down corporate browser, extensions inject scripts into your page and produce errors with your page's stack. They look exactly like your bugs until you look at the frames.
  • Under heavy bot traffic, headless and scripted clients generate errors no human ever saw. They pollute counts unless filtered.
What this costs
  • Breadcrumbs make triage dramatically faster and make privacy review dramatically harder. Every breadcrumb category is a decision about what you are willing to store about someone.
  • Installing handlers before the app bundle costs bytes in the critical path — a small inline script on every response, uncacheable, in exchange for visibility into startup failures.
  • Wrapping platform APIs for breadcrumbs adds a permanent overhead on hot paths and a permanent risk of subtly changing their behaviour.
  • Aggressive client-side de-duplication saves quota and can hide a genuine change in frequency, because you no longer have the raw counts to compare.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe error and unhandledrejection events on window, and the cross-origin redaction to Script error., are specified behaviour and behave the same in Chromium, Gecko and WebKit. The crossorigin attribute plus Access-Control-Allow-Origin is the specified way to lift the redaction in all three.
  • BROWSER-SPECIFICThe text of Error.stack is not a shared format: V8 emits frames as at fn (url:line:col) after a first line repeating the message, SpiderMonkey emits fn@url:line:col with no header line, and JavaScriptCore differs again in how it names anonymous and native frames — so a fingerprinting parser written against one engine silently produces one giant "unknown" group on the others.
  • SPEC-EVOLVINGStack representation is being aligned across engines, and newer surfaces — error.cause, error reporting via the Reporting API, and worker and module error propagation — are still filling in unevenly, so treat any stack parser as engine-dependent code with a fallback rather than a stable contract.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designerror-model
Domains that do not exist yet
  • Testing & Reliability Engineering — an error budget turns "how many errors is too many" from a debate into a policy, and decides whether this release keeps shipping.