Guides
Detection guide

JavaScript Error Tracking: How to Detect Client-Side Errors in Production

By Sazabi Team

Last verified on Nov 8, 2026.

Your server logs do not see what happens in the browser. A TypeError on one page, a fetch that fails on a flaky connection or a hydration mismatch after a release all happen on the user's device, and unless you collect them there, they are invisible. Users rarely report them. They leave.

Client error tracking is mostly four collectors and one grouping step. This guide shows each collector, what the data should look like and how to alert on new errors without drowning in noise.

What the evidence looks like

A useful report carries enough context to group and reproduce it. This example is a payload from a browser:

{
  "type": "exception",
  "message": "Cannot read properties of undefined (reading 'total')",
  "stack": "TypeError: Cannot read properties of undefined (reading 'total')\n    at renderSummary (checkout.4f2a1c.js:1:20431)",
  "page": "/checkout",
  "release": "2026.11.3",
  "browser": "Safari 18",
  "ts": "2026-11-08T15:42:09Z"
}

Release and page are the two fields that make a spike explainable. An error that appears right after a release, on one page, is almost always from that release.

How to detect it

  1. Collect uncaught exceptions with window.addEventListener('error', ...). The event reports the message, source URL, line, column and error object. It fires for script errors thrown synchronously, and also for resources that fail to load, according to MDN.
  2. Collect unhandled rejections with the unhandledrejection event. MDN notes that a rejected promise with no handler goes there and not to the error event. The event carries a reason.
  3. Wrap fetch to record failures. A fetch promise rejects only when the request itself fails. A 404 or 500 resolves normally, so record non-ok responses explicitly.
  4. Capture framework errors. React's hydrateRoot treats server and client mismatches as bugs, and accepts onUncaughtError, onCaughtError and onRecoverableError options for your own reporting.
  5. Send reports with navigator.sendBeacon, which posts asynchronously without delaying page unload, so errors during navigation are not lost.
const report = (e) =>
  navigator.sendBeacon(
    '/telemetry/errors',
    JSON.stringify({
      ...e,
      page: location.pathname,
      release: window.__RELEASE__,
      ts: new Date().toISOString(),
    })
  )
addEventListener('error', (ev) =>
  report({ type: 'exception', message: ev.message, source: ev.filename, line: ev.lineno, col: ev.colno })
)
addEventListener('unhandledrejection', (ev) =>
  report({ type: 'rejection', message: String(ev.reason?.message ?? ev.reason) })
)
  1. Group before you alert. Normalize the message by removing numbers and IDs, and key on message plus top stack frame. Then ask two questions: is this a group I have never seen, and is its rate rising?
SELECT message_key, release,
       count(*) AS events, count(DISTINCT session_id) AS sessions,
       min(ts) AS first_seen
FROM client_errors
WHERE ts > now() - interval '1 hour'
GROUP BY message_key, release
HAVING min(ts) > now() - interval '1 hour'
   AND NOT EXISTS (
     SELECT 1 FROM client_errors old
     WHERE old.message_key = client_errors.message_key
       AND old.ts <= now() - interval '1 hour')
ORDER BY sessions DESC;

Thresholds and pitfalls

Alert on new groups with at least a few sessions, and on a rate per release that rises against the previous release. Count sessions, not events, since one broken loop can produce thousands of events from one user.

Typical sources of noise:

  • Cross-origin scripts. The HTML standard reports errors from scripts that fail the cross-origin checks as muted errors with the message Script error. and no detail. Serve your own bundles with CORS headers and a crossorigin attribute so you get real stacks.
  • Extensions and injected code. Drop errors whose source is not your origin.
  • Minified stacks. Upload source maps at build time or you will group by meaningless frame names.
  • Retries and flaky networks. A failed fetch on a train is not a regression. Look at the failure rate per endpoint and release.
  • Sampling. If you sample, keep the first occurrence of every new group so a rare error is not sampled away.

What to do when it fires

Check the release and page first. If the group starts at a deploy, roll back or hotfix. Read the stack, reproduce with the same browser and look at what the request returned if the error follows a fetch. Hydration errors often point to rendering that depends on time, locale or browser-only values.

Sazabi detects client errors, such as JavaScript exceptions, failed fetches and hydration errors, for every production project with no setup. See client error detection for what it covers. The Sazabi Browser SDK captures browser sessions, including errors and network calls. Server logs from your host, such as Vercel, belong beside them, and OpenTelemetry covers sending your own. For other tools that collect the same data, the Sentry page lists what Sazabi can connect to.

Related guides: dead clicks covers failures with no error at all, and frontend breakage covers the wider set of client-side failures after a release.

With Sazabi

Sazabi detects this for every production project today. Client Error Detection: Browser Exceptions, Failed Fetches and Hydration Errors covers what it catches.

Frequently Asked Questions

What client errors should I track?

Track uncaught exceptions, unhandled promise rejections, failed network requests and hydration errors. Add the page, release and a stack trace to each report.

Why does my error report only say Script error?

Browsers hide details of errors from scripts loaded from another origin unless the script is served with the right CORS headers and a crossorigin attribute. The HTML standard calls these muted errors.

Does fetch throw on a 500 response?

No. A fetch promise rejects only when the request fails, such as a network error. A 500 resolves normally, so your code must check the status and report it.

How do I avoid alert noise from browser extensions and bots?

Filter by script origin so errors from extension or injected code are dropped, and alert on new error messages or on rates per release instead of raw counts.

Sources

  1. The window error event fires for script errors thrown synchronously and for resources that fail to load; onerror receives message, source, line, column and error; rejected promises with no handler are reported through unhandledrejection instead (accessed Nov 8, 2026)
  2. The unhandledrejection event is sent when a promise with no rejection handler is rejected, and the event carries a reason (accessed Nov 8, 2026)
  3. A fetch promise only rejects when the request fails, for example a network error; HTTP error statuses still fulfill with a Response (accessed Nov 8, 2026)
  4. sendBeacon sends data asynchronously as an HTTP POST without delaying page unload (accessed Nov 8, 2026)
  5. Scripts that do not pass the CORS checks pass minimal information to window.onerror; the crossorigin attribute allows error logging for scripts on a separate domain (accessed Nov 8, 2026)
  6. Muted errors, such as errors from scripts that fail the cross-origin checks, are reported with the message Script error. and no further detail (accessed Nov 8, 2026)
  7. hydrateRoot expects server-rendered and client content to match and treats mismatches as bugs; the onUncaughtError, onCaughtError and onRecoverableError options support custom error reporting (accessed Nov 8, 2026)
  8. Hydration errors are a React-specific problem that happen when the initial UI does not match what was rendered on the server (accessed Nov 8, 2026)