← Blog
29 June 2026 · Jack Williams

How to Add JavaScript Error Tracking (with Source Maps)

Capture uncaught errors from real users, send them somewhere, and turn minified stack traces back into your real code with source maps. The build-it-yourself version, and the shortcut.

Errors thrown in a visitor's browser never show up in your server logs. A null reference on a Safari version you don't test, a failed fetch on a flaky train connection, a third-party widget that breaks: you only hear about these if someone bothers to report them, and almost nobody does. Error tracking makes that invisible failure visible. This guide shows you how, in plain JavaScript that works in any framework, and explains the part most people get wrong: source maps.

The three pieces

Client-side error tracking is always the same three steps:

  1. Capture the error in the browser.
  2. Send it to an endpoint.
  3. Make the stack trace readable with source maps.

1. Capture

JavaScript fails in two ways, and you need a listener for each: uncaught exceptions, and promises that reject with no .catch.

window.addEventListener('error', (event) => {
  report({
    type: event.error?.name || 'Error',
    message: event.message,
    stack: event.error?.stack,
    file: event.filename,
    line: event.lineno,
    col: event.colno,
  })
})

window.addEventListener('unhandledrejection', (event) => {
  const reason = event.reason
  report({
    type: 'UnhandledRejection',
    message: String(reason?.message ?? reason),
    stack: reason?.stack,
  })
})

Those two cover the vast majority of real-world breakage. Drop them in as early as possible so you catch errors during load.

2. Send

Ship each error to your own endpoint. Use sendBeacon so the request survives the page unloading, which matters because errors often fire right as the user navigates away.

function report(err) {
  const body = JSON.stringify({ ...err, url: location.href, ua: navigator.userAgent })
  if (navigator.sendBeacon) navigator.sendBeacon('/api/errors', body)
  else fetch('/api/errors', { method: 'POST', body, keepalive: true })
}

On the server, accept the beacon and store it somewhere you can query. Errors are high-volume and repetitive, so plan to group them rather than keep every raw row forever (more on that below).

3. The minified-stack problem, and source maps

Here is the part that trips people up. In production your JavaScript is minified and bundled, so the stack you just captured looks like this:

TypeError: undefined is not a function
  at a (main.4f2c9b.js:1:88123)
  at o (main.4f2c9b.js:1:90211)

That is useless. You can't fix main.4f2c9b.js:1:88123. Source maps are the fix: they map a position in the minified file back to the original, so that frame becomes:

at handleCheckout (Checkout.tsx:88:14)
at CheckoutPage (checkout/page.tsx:24:9)

Every bundler can emit them: Vite, webpack, esbuild, Rollup and Next.js all produce .map files. The workflow is:

  1. Build with source maps on, producing a .map for each bundle.
  2. Upload the maps to your error tracker, keyed by a release identifier (a git SHA or a version string).
  3. Tag every error with that same release so the tracker knows which maps to use, then it symbolicates the minified frames back to your real code.

Two things to get right:

  • Do not ship source maps publicly. They contain your original source. Upload them privately to your error tracker and strip them from the deployed assets, or host them somewhere only the tracker can read.
  • Tie everything to a release. Setting the same release on your error events and your map uploads is what makes symbolication line up. As a bonus, it tells you exactly which deploy introduced a regression.

Group the noise

Raw errors are repetitive. One bug can fire ten thousand times. Group occurrences by a fingerprint, usually the error type plus a normalized message plus the top stack frame, so that bug is a single row with a count and a "first seen" rather than ten thousand identical lines. Grouping is what turns a firehose into a to-do list.

The shortcut

The do-it-yourself version above is worth understanding, but maintaining the capture script, the ingestion endpoint, storage, source-map symbolication, grouping, and a UI to read it all is a project in itself.

This is what JAMP does with a single roughly 1.2 KB script. It captures errors and unhandled rejections, groups them by fingerprint, and symbolicates against source maps you upload from CI keyed by release, so you see the original file, line and function instead of minified soup. It also keeps a breadcrumb trail of what the visitor did right before the crash, and tags each error with the release it first appeared in, so a regression points straight at the deploy that caused it. Your backend errors land in the same dashboard too: point your server's error hook at the JAMP server endpoint and 500s, database failures and unhandled exceptions show up next to the client ones, tagged as server and grouped the same way. You can see it on live data in the demo, and the error-tracking docs, server errors and source-map setup cover installation.

However you do it, the principle is the same: capture both error types, tie everything to a release, and symbolicate with source maps. That is what turns "it works on my machine" into a stack trace pointing at the exact line that broke. Pair it with real-user Core Web Vitals and you can see both what broke and what felt slow for real visitors.