GAP·MAP

Frontend monitoring and error tracking

frontend · Web Performancemiddlesenior~8 min read

covers error capture (onerror, rejections) · source maps in production · RUM vs synthetic · field Web Vitals & attribution · session replay · alerting & release health

[ middle depth ]

What window.onerror catches, and the errors it never sees

window.onerror (or addEventListener("error", ...)) fires for errors thrown synchronously: a bad property access during render, a throw inside a click handler that runs to completion. It hands you a message, the source URL, line, column, and the Error object. On its own it is blind to two whole categories of failure.

A rejected promise with no .catch never reaches onerror. It fires the unhandledrejection event on window instead, carrying event.reason. An await that throws inside an async function is the same thing under the hood: a rejected promise. So if your app is mostly async (data fetching, dynamic imports), a lone onerror handler misses most of what breaks.

A resource that fails to load (an <img>, <script>, or stylesheet that 404s) dispatches an error event on the element, and that event does not bubble. A window-level onerror never sees it. You catch it with a capture-phase listener, which is what the third argument does:

window.addEventListener("error", onError);                  // synchronous JS errors
window.addEventListener("unhandledrejection", onRejection); // rejected promises
window.addEventListener("error", onResourceError, true);    // true = capture phase; catches <img>/<script> load failures

React adds a fourth channel. An error thrown while rendering a component is caught by the nearest error boundary above it, not by onerror. A boundary is a class component with getDerivedStateFromError (swap in fallback UI) and componentDidCatch(error, info) (log it, with info.componentStack). Wrap real UI regions so one broken widget cannot blank the whole page, and log from componentDidCatch so those errors reach your tracker too.

sync throwwindow.onerrorpromise rejectunhandledrejectionresource 404capture listenerrender errorerror boundaryerror tracker
fig · One tracker, four capture channels

Wrong "We set window.onerror, so every crash is reported." It reports synchronous script errors only. Promise rejections, failed resource loads, and React render errors each need their own capture path, or they vanish and the dashboard looks reassuringly quiet.

Why production stacks are gibberish, and the safe fix

Your production bundle is minified: variable names collapse to single letters and everything sits on one line, so a reported frame reads t.default is not a function at main.9f2a.js:1:24503. Useless for debugging. Source maps translate that back to your real file, function name, and line.

The instinct is to deploy the .map files next to the bundle. Do not. A public source map hands your original source to anyone who opens devtools. Instead, upload the maps to your error tracker at build time and delete them from what you deploy, so the tracker un-minifies the stack on its side and nothing leaks:

// Sentry build plugin example: upload during the build, then strip the maps from the deploy
sourcemaps: {
  filesToDeleteAfterUpload: ["**/*.js.map"],
}

Wrong "You have to ship the source maps to production to get readable stacks." The tracker only needs its own copy. Uploading at build time and deleting the maps from the deploy gives you readable frames with zero public exposure.

Field data vs synthetic checks

Two kinds of monitoring answer different questions. Synthetic (lab) monitoring runs a scripted test on a schedule from one controlled machine: Lighthouse CI against a preview deploy, an uptime bot hitting a URL. It is reproducible and runs before real users do, which makes it good for catching regressions in CI. Real user monitoring (RUM) records what actual visitors experienced across their own devices, networks, and locations.

The two disagree often, because a lab run uses a single device on one network with a cold cache while your users are spread across everything. Trust the field for "are users ok right now," and use synthetic to reproduce a problem or to test a page that has no traffic yet. The catalog of lab metrics and the fixes for them live in the loading-performance and rendering-performance modules; this module is about capturing the field signal in the first place.

Do not page on every error

A raw error-count alarm will page you at 3am for a noisy browser extension nobody can fix. Alert on the shape that means users are hurting: the error rate climbing after a deploy, or the share of sessions that ended cleanly dropping. Tag every event with the release version so a regression points at the commit that shipped it. The alerting math (thresholds, burn rate) is covered in the observability module; reuse it rather than inventing your own.

[ senior depth ]

The capture gaps, and the cross-origin one that wastes days

The four channels (synchronous onerror, unhandledrejection, capture-phase resource errors, React error boundaries) each miss what the others catch, so a real setup wires all four. The one that quietly ruins a dashboard is cross-origin opacity. When the throwing script came from a different origin (a CDN bundle, a chat widget, an analytics tag), the browser strips the detail before it reaches onerror: you get the literal string "Script error.", a null error object, and no stack, so cross-origin source cannot leak into your handler. You get the detail back by serving that script with an Access-Control-Allow-Origin header and loading it with crossorigin="anonymous". Miss either half and every third-party error lands as an unreadable "Script error.".

Two mechanical quirks come up. window.onerror cancels the default console logging only when it returns true, the opposite of most handlers; the addEventListener("error") form uses event.preventDefault(). And on the Window object, onerror is the one handler that receives five positional arguments (message, source, line, column, error) rather than a single event.

Source maps: Debug IDs and not leaking source

There are two separate problems: matching the right map to the right minified file, and keeping the source off the public server.

Older setups matched by file path, via the //# sourceMappingURL comment plus a release name. That is fragile. Change where files are served (a new CDN subpath, a rehashed filename) and the tracker can no longer find the map, so stacks stay minified with no obvious error. Debug IDs fix the matching: the build stamps one globally unique id into both the minified file and its map, and the tracker joins them by that id regardless of path, filename, or release. Sentry documents this as the recommended and most reliable method, and it lets you symbolicate without even creating a release.

For exposure, keep the maps off the public server. Upload them to the tracker during the build, then delete them from the deploy (filesToDeleteAfterUpload) or block .js.map requests at the edge. The tracker symbolicates on its side; users download only the minified bundle.

Wrong "A Debug ID and a release are the same thing." A release groups events for health tracking and regression detection; a Debug ID solves file-to-map matching. They are orthogonal, and Debug IDs work even with no release created.

CrUX vs your own RUM, and debugging vitals in the field

CrUX (the Chrome UX Report) is Google's field dataset, and its debugging limits (Chrome only, origin-level, 28-day-windowed, too coarse to surface the slow session itself) are laid out in loading-performance. Those limits are what your own RUM removes: every browser, your own segments, and per-session detail you can drill into. The piece that turns RUM from a scoreboard into a debugging tool is the web-vitals attribution build:

import { onINP, onLCP } from "web-vitals/attribution";

onINP(({ value, attribution }) => {
  send("inp", {
    value,
    target: attribution.interactionTarget,       // selector for the element hit
    inputDelay: attribution.inputDelay,           // main thread was busy before handlers ran
    processing: attribution.processingDuration,   // your event handlers running
    presentation: attribution.presentationDelay,  // waiting for the next frame to paint
  });
});

Now a bad INP in the field arrives already attributed: the selector that was slow and which subpart dominated, with the LCP breakdown available the same way. What each subpart means, and what you do about it, is the rendering-performance and loading-performance material; this module gets you the attributed field number to act on.

Session replay: what you trade for it

Session replay reconstructs what the user saw. It is not video: it snapshots the DOM and streams subsequent mutations, and the player rebuilds the page from those. Getting the privacy right comes first, because the DOM contains everything the user typed and saw, so a naive recorder exfiltrates PII straight into your vendor. Sane defaults mask all text (rendered as asterisks) and block all media, and you selectively unmask the nodes you know are safe. Masking replaces the text but preserves layout; blocking removes the element and leaves a placeholder box of the same size.

Recording every session is then a cost problem, since full capture is expensive to ingest and store. The usual pattern uses two sample rates. Record a small fraction of all sessions (replaysSessionSampleRate, often 0.01 to 0.1), and separately keep a rolling in-memory buffer that flushes the last ~60 seconds only when an error fires (replaysOnErrorSampleRate at 1.0). You pay full price for the error sessions, which are the ones worth watching, and sample the rest cheaply.

The recorder also runs inside the page, so DOM-heavy apps and canvas capture add runtime overhead. That is one more reason to sample instead of recording everyone.

Alerting on frontend signals without paging on noise

Frontend telemetry is noisy in ways backend telemetry is not: extensions inject errors into your page, a flaky third-party tag throws on every load, one user's ad blocker breaks a script. Paging on a raw error count guarantees fatigue, and a fatigued team mutes the pager, which is worse than no alerting.

Anchor everything to releases. Tag every error and every session with the deploy version so the tracker can tell a long-standing background error from one the latest deploy introduced, and attach the suspect commit. Then alert on rates that mean users are hurting: the crash-free session rate (the share of sessions that did not end in an unhandled error) and crash-free user rate falling below a threshold, or the new release's error rate spiking against the previous one. Route a fast regression to a page and a slow drift to a ticket.

The threshold math, multi-window burn-rate alerting, and error-budget theory are the observability module's ground, and they transfer without change. A frontend crash-free rate is an SLI like any other, so borrow that machinery rather than hand-rolling a static count threshold that fires on the next noisy extension.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the Frontend monitoring and error tracking notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

builds on

more Web Performance

was this useful?