Back to blog
August 13, 2026Sergei Solod18 min read

I Was Alerting on Everything: How I Turned Browser Error Spam into Useful Production Monitoring

My frontend reporter treated ad blockers, GTM failures, AbortError, opaque Script error events, and real Next.js chunk failures alike. I rebuilt the signal around ownership, user impact, evidence, correlation, and recovery.

Browser Error MonitoringFrontend ObservabilityJavaScript ErrorsProduction MonitoringNext.jsWeb Performance

I built client-side error reporting because I wanted to know when real users hit problems that I could not reproduce locally. The reporter did exactly what I asked: it caught failures and sent them to me.

The problem was that it treated almost everything as the same thing.

A third-party analytics script failed to load? Red alert. An advertising script was blocked? Red alert. A crawler could not fetch Google Analytics? Red alert. A video preview called play() and was paused before the returned promise settled? Red alert. An opaque Script error. arrived without a useful source or stack? Red alert.

Buried in the same stream were failures that actually deserved attention: a malformed first-party URL that had accidentally become https://example.comhttps://example.com/..., and a first-party Next.js file under /_next/static/chunks/... that a browser could not load.

My collection layer was working. My monitoring layer was not.

That distinction changed how I think about frontend observability. An error event is evidence that something happened. It is not yet a diagnosis, a severity level, or an incident.

The first bug was the word “error”

My early reporter had a simple mental model:

browser reports error
        ↓
send CLIENT ERROR
        ↓
developer should care

That model collapses several different questions into one. Did something fail? Was it my code? Did it affect the current page? Was the failure expected cancellation? Did the browser have enough information to identify the source? Did the application recover? Did ten messages come from one root incident?

Until those questions are answered, an event should not automatically become an alert.

In one early review I was looking at roughly eighteen messages. Most were third-party or lifecycle noise. Two stood out: the malformed first-party URL was clearly wrong, and the failed first-party JavaScript chunk was potentially serious because application code may not have been available to the page. The reporter presented all of them with essentially the same visual urgency.

That is when I stopped treating “collect every browser error” as the same problem as “build production monitoring.” Collection maximizes evidence. Monitoring has to compress that evidence into decisions.

The browser does not have one universal error channel

The second mistake was assuming that all client failures arrive with comparable semantics. They do not.

The window error event is used for synchronous script errors and is also involved in resource failures. An unhandled rejected promise is a different path: the browser emits unhandledrejection. Resource-owning elements such as scripts, images and media can emit their own error events. React or Next.js error boundaries add another application-level signal on top.

Those channels answer different questions.

window.error
→ a synchronous script failure may have escaped

unhandledrejection
→ a Promise rejection was unhandled at that moment

element error
→ a resource could not be loaded or used

framework boundary
→ rendering or application execution crossed a framework failure boundary

The important phrase is “may have.” A global browser hook observes symptoms at a boundary. It often cannot tell you the complete causal chain.

Once I accepted that, I stopped trying to normalize every event immediately into one generic Error and one generic severity.

Ownership is the first useful filter, but not the last one

The most productive first split was ownership.

A failure of /_next/static/chunks/app/... is different from a failure of an advertising SDK on another origin. A malformed URL produced by my own URL builder is different from a blocked analytics request. A browser-extension URL is different from all three.

I now think in at least four ownership buckets:

  • first-party application: my JavaScript, CSS, API calls, media and generated URLs;
  • framework/runtime: Next.js or React paths that are part of the application execution chain;
  • third-party integration: analytics, advertising, widgets and external SDKs;
  • environment: browser extensions, crawlers, network state, privacy tools and user-agent-specific behavior.

This does not mean “ignore third parties.” If an analytics integration fails for every user after a release, that may matter. If advertising pays the hosting bill, ad delivery health may matter economically. But those are integration-health incidents, not automatically application crashes.

Routing them to the same urgent channel destroys the meaning of the channel.

Two first-party failures taught me what “actionable” means

The malformed URL was the easy case. A browser attempted to load something shaped like:

https://example.comhttps://example.com/resource

No amount of ad-blocking, browser privacy logic or network interpretation was needed. The URL itself was invalid. Somewhere, code was effectively concatenating an origin with a value that was already absolute.

That event was actionable because the evidence was specific, the resource was first-party, and the failure pointed to a code path I controlled.

The Next.js chunk failure was different:

Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js

That was also first-party, and it could break the page. But the event did not prove why the chunk failed. A stale client could request an asset from an older deployment. A request could time out. A reverse proxy or CDN could fail. A connection could disappear. The file could actually be missing.

The right reaction was therefore not “I know the cause.” It was “this class deserves high priority and more evidence.”

That difference became central to the monitoring design: severity can be high even when causal certainty is low.

Script error. is a clue, not a stack trace

Another recurring event was the famous:

Error: Script error.
filename: unknown
line: 0
column: 0

It looks dramatic and contains almost nothing.

Cross-origin script error reporting is deliberately restricted. MDN notes that normal cross-origin scripts expose limited information through window.onerror unless the script is fetched with the appropriate CORS settings; the crossorigin behavior of <script> directly affects whether full error information is available.

So an opaque Script error. should not be silently reinterpreted as “my application crashed.” It may be first-party, third-party, injected code, or simply an error whose details the browser will not expose.

My policy became: retain it as telemetry, correlate it with page, build, browser and nearby events, but do not page solely on one opaque 0:0 event. If the same signature clusters around one release or route, its priority changes.

Unknown does not mean harmless. It also does not mean critical.

An AbortError can be completely real and still be expected

The clearest example of “real error does not equal real incident” came from media previews.

I saw unhandled rejections similar to:

AbortError:
The play() request was interrupted by a call to pause()

HTMLMediaElement.play() returns a Promise. That promise can reject. Media lifecycle operations can also intentionally abort pending playback; MDN explicitly documents that load() aborts pending play promises with AbortError.

In a preview grid, this is easy to produce without a broken product. An item enters the viewport, code calls play(), the user scrolls, the element leaves the viewport, code pauses or replaces the media before playback has fully started.

The rejection is real. The user-visible incident may be nonexistent.

The correct fix is normally local: handle the promise where playback is initiated and distinguish expected cancellation from genuine playback failure. A global unhandledrejection handler is useful as a safety net, but it should not be the first place where normal media lifecycle is understood.

Third-party failures need their own health model

Several early alerts were failures from analytics and advertising domains. Some were attached to privacy-focused browsers. Some came from crawlers. One especially useless pattern was a crawler failing to load Google Analytics.

That tells me something about a network request. It tells me almost nothing about whether a human user could use the application.

The mistake was not collecting those events. The mistake was putting them in the same incident stream as a failed first-party JavaScript chunk.

I now separate at least two questions:

  • Is the application broken for the user?
  • Is an external integration healthy?

A blocked ad script may belong in a metric about ad delivery. A failed analytics script may belong in telemetry about analytics coverage. Neither should wake me up as “frontend crashed” unless there is evidence that application functionality depends on it.

This separation also makes third-party problems easier to see. Once they stop drowning application alerts, they can be aggregated by provider, browser and region instead of appearing as random red noise.

navigator.onLine is context, not proof of connectivity

I also started logging whether the browser considered itself online. That was useful, but only after I stopped treating it as ground truth.

I had events classified as network failures while the log still said:

Online: true

That is not a contradiction. MDN explicitly warns that navigator.onLine is based on browser and operating-system heuristics. A machine can be connected to a LAN and still be unable to reach my origin. VPNs, firewalls, DNS failures and partial network outages complicate the picture further.

So I use online state as a hint:

online === false
→ strong evidence that a resource failure may be environmental

online === true
→ does NOT prove that the origin or resource was reachable

This is a small distinction, but it prevents a monitoring system from confidently producing the wrong diagnosis.

One root failure can become several browser events

After improving collection, another problem became obvious: a single incident could generate several messages.

A JavaScript chunk may first produce an element-level resource.error. The module loader may then throw ChunkLoadError. React or Next.js may cross an error boundary. Recovery logic may schedule a reload. If every layer sends an independent alert, the same user action can look like several unrelated production failures.

That creates two problems. It inflates incident volume, and it changes how the developer perceives severity. Five messages feel like five users even when they came from one session and one missing resource.

The fix is not aggressive deduplication by message text. It is incident correlation.

I want to preserve the raw events but group them by evidence such as:

session
+ short time window
+ normalized error class
+ first-party resource
+ client build
+ route

Then I can prefer the highest-signal representation of the incident. If I already have a framework boundary with a first-party stack and the exact chunk URL, a preceding generic resource error does not need a second urgent notification.

Alert on incidents. Store events.

The fields around the error became more valuable than the error string

My later telemetry became much more structured. Instead of only sending message, URL and user agent, the logs began carrying context such as:

clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack

For media I also collected details such as the media error code, an HTTP status when independently observable, the returned content type, and whether the failure looked like HTTP or network delivery.

This changed debugging because it let me ask questions that a generic exception string cannot answer:

  • Did failures begin on one build?
  • Did the browser receive an HTTP response?
  • Was a service worker controlling the page?
  • Did recovery logic already run?
  • Did several events reference the same resource?
  • Was the current route actually affected?

The browser's Resource Timing API can provide resource duration, transfer information and, where supported and permitted, response status. Those fields have important caveats: cross-origin timing is restricted, cached resources can produce zero transfer size, and responseStatus is not universally available. That is exactly why “null” and “0” need to remain meaningful states rather than being converted into fake certainty.

A ChunkLoadError is a symptom, not a 404 detector

One later event changed how aggressively I interpret chunk failures.

The browser reported a Next.js ChunkLoadError for an application layout chunk. The enriched telemetry also contained:

resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true

That is roughly 170 seconds between the start and end points captured for the resource. Whatever the exact root cause of that incident was, the data was enough to reject the simplistic rule:

ChunkLoadError === file returned 404

Other chunk failures had no observable response status at all. Some were reported as timeouts. Some had transfer information. The error class was the same; the evidence around it was not.

This matters especially with Next.js because files under /_next/static/ are normally content-hashed and intended to be immutable. Current Next.js self-hosting documentation describes hashed immutable assets with long-lived cache headers. A chunk failure can therefore involve deployment skew, stale clients, network delivery, proxies, caches, service workers or missing artifacts. Monitoring should capture enough context to separate those hypotheses.

I do not want the alerting layer to invent the cause. I want it to preserve the evidence needed to investigate.

Media errors taught me the same lesson from another direction

I saw a similar trap in video telemetry. At the media-element level, a browser reported a format-style failure:

MEDIA_ELEMENT_ERROR: Format error

That sounds like codec incompatibility.

But additional delivery verification for some of those events showed:

HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http

A browser asked for video and received an HTTP error response containing HTML. The surface symptom was “format error” because the media element could not decode what it got. The useful diagnosis was delivery.

This is the same monitoring principle again: the layer that notices the failure is not necessarily the layer that caused it.

I now resist naming incidents from the outermost exception. “Video codec failure,” “network outage,” “cache bug” and “missing chunk” are conclusions. Telemetry should first describe observations.

I score browser failures on five dimensions

The classification model I find most useful is not one enormous blacklist. It is a small set of independent dimensions.

1. Ownership

Is the failing code or resource first-party, framework/runtime, third-party, or environmental?

2. User impact

Did it break the active route, core interaction, rendering, authentication, chat, checkout or another essential flow? Or did an optional ad, analytics beacon, preload or preview fail while the page remained usable?

3. Evidence quality

Do I have a first-party stack, resource URL, HTTP status, build ID and component stack? Or only Script error. at 0:0?

4. Recurrence and spread

Is it one event from one session, or the same signature across different users, routes and browsers after the same release?

5. Recovery

Did the application recover automatically? Was a chunk reload scheduled? Did a media fallback work? Is the user still blocked?

Severity then becomes a function of those dimensions instead of a synonym for the word “Error.”

high ownership confidence
+ high user impact
+ strong evidence
+ repeated across sessions
+ no recovery
= urgent incident

third-party ownership
+ optional functionality
+ weak/opaque evidence
+ isolated occurrence
+ user unaffected
= metric or low-priority investigation

The classifier should be conservative about suppression

Once noise becomes painful, the tempting solution is to add dozens of regular expressions and drop everything annoying. That is dangerous.

If I suppress every AbortError, I can hide real aborted application requests. If I suppress every Script error., I can miss a browser-specific cluster that becomes meaningful only after aggregation. If I ignore every third-party failure, I can miss a broken payment, authentication or consent provider.

So I prefer three outcomes rather than “alert” or “delete”:

ALERT
→ high-signal incident requiring attention

RETAIN / AGGREGATE
→ keep and count; alert only when clustered

METRIC / SAMPLE
→ expected or low-impact noise; preserve trends and examples

The system can become quieter without becoming blind.

An illustrative classifier is mostly policy, not clever code

The following is not code copied from my production project. It is a compact example of the policy I wish I had started with:

function classifyClientEvent(event) {
  const owner = classifyOwner(event);

  if (isExpectedMediaCancellation(event)) {
    return { severity: "metric", reason: "expected-cancellation" };
  }

  if (owner === "first-party" && breaksActiveRoute(event)) {
    return { severity: "alert", reason: "first-party-user-impact" };
  }

  if (isActiveFirstPartyChunkFailure(event)) {
    return { severity: "alert", reason: "application-chunk" };
  }

  if (owner === "third-party") {
    return { severity: "aggregate", reason: "integration-health" };
  }

  if (isOpaqueScriptError(event)) {
    return { severity: "aggregate", reason: "insufficient-evidence" };
  }

  return { severity: "aggregate", reason: "needs-correlation" };
}

The hard work is hidden in functions such as breaksActiveRoute(). That cannot be solved reliably with the exception name alone. It needs route context, resource ownership, framework boundary data and sometimes product-specific knowledge.

This is why monitoring architecture is mostly policy encoded in software.

Deduplication needs a fingerprint that follows the incident

Message equality is a poor deduplication strategy. Minified stack offsets can change by build. Chunk hashes change. URLs contain dynamic identifiers. Browser wording differs.

I prefer a normalized incident fingerprint built from stable concepts:

{
  errorClass,
  normalizedFirstPartyResource,
  routeFamily,
  clientBuild,
  sessionId,
  shortTimeBucket
}

For a global application crash, the top first-party stack frame may be useful. For a chunk failure, the normalized chunk resource is more useful. For a media delivery incident, the HTTP failure class and media route may matter more than the browser's outer error message.

Then a stream like:

resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled

can become one incident with four attached observations.

That is a much better object to send to a human.

My urgent channel now has a much narrower job

If I were defining the policy from scratch today, I would reserve immediate alerts for cases like:

  • first-party runtime errors with a useful stack that break an active route;
  • React/Next.js error boundaries affecting real interaction;
  • active first-party JavaScript or stylesheet failures;
  • repeated ChunkLoadError incidents across sessions or builds;
  • core API/data failures that leave the user blocked;
  • known first-party invariants such as malformed generated URLs.

I would retain but not immediately page on:

  • one opaque Script error.;
  • one first-party resource failure with successful recovery;
  • media errors whose root layer is not yet known;
  • browser-specific anomalies that need clustering.

And I would normally route to metrics or sampled diagnostics:

  • known advertising or analytics resource failures;
  • expected media AbortError cancellation;
  • crawler-only third-party failures;
  • failures clearly associated with an offline browser hint;
  • optional speculative resources that do not affect the active route.

The lists are less important than the rule behind them: urgent alerts should represent actionable user impact, not the raw volume of browser complaints.

What I would measure instead of counting “errors”

A single global error count is almost useless once the application has real traffic and multiple integrations.

I would rather have dashboards for:

  • first-party incidents per 1,000 sessions;
  • affected sessions by build ID;
  • error-boundary incidents by route;
  • chunk failures by resource and deployment;
  • third-party integration failure rate by provider;
  • expected-cancellation volume, so a sudden change is still visible;
  • recovery success rate;
  • unique incidents versus raw event count.

This makes regressions visible without pretending that an ad blocker and a rendering crash have equal value.

It also makes the alert threshold easier to reason about. “One event happened” is rarely a useful production threshold. “This first-party incident now affects several independent sessions on the new build and recovery is failing” is.

Monitoring still cannot prove the root cause by itself

Even good client telemetry has hard limits.

A missing response status may mean no response was available to the API, unsupported browser data, a cross-origin restriction, cancellation or another gap in observability. A service worker being active does not prove it caused a stale response. A ChunkLoadError appearing after a deployment does not prove deployment skew. A browser saying it is online does not prove the origin was reachable.

Client monitoring narrows hypotheses. Server logs, reverse-proxy logs, deployment manifests, cache state and reproduction may still be required.

I also do not want observability to become unlimited data collection. Fields should exist because they help distinguish failure modes. The goal is not to capture everything a browser knows about a user.

Better telemetry is not more telemetry. It is more discriminating telemetry.

The rule I use now: collect events, investigate incidents, alert on impact

I originally wanted my browser reporter to answer a simple question: “Did anything go wrong?”

Production made that question useless. Something is always going wrong somewhere: a crawler cannot reach analytics, a privacy tool blocks an ad, a media promise is intentionally canceled, a user loses network connectivity, a third-party SDK misbehaves.

The useful questions are narrower:

Is it ours?
Did the user lose functionality?
How strong is the evidence?
Is it repeating?
Did the application recover?
Are these several events or one incident?

Once I started asking those questions, frontend monitoring stopped being a firehose of red messages and started becoming an engineering instrument.

The deepest change was not a filter rule. It was the mental model.

A browser error is an observation. An incident is a correlated explanation of user impact. An alert is a decision that a human should act.

I do not want those three things to be synonymous again.