cf.completefrontend
My progress
FUNDAMENTALS · MODULE 15

Error tracking

Catching the errors that happen in users' browsers, decoding minified stacks, grouping thousands of reports into a few issues, and alerting when a release makes things worse: how you learn the app is broken before support does.

The problem: errors you never see

A team ships a new checkout page on a Tuesday afternoon. Every test passes. The page works on every laptop in the office.

On Thursday, the support inbox has 40 tickets that all say the same thing: "the Pay button does nothing". Revenue from iPhones is down 30 percent since Tuesday.

The cause is one line. The new code calls Array.prototype.findLast, which an older Safari version on some phones does not have.

On those phones the click handler throws a TypeError and stops. The user sees nothing: no message, no spinner, just a button that does nothing. Nobody on the team could see it either, because the error happened in the user's browser, not on a server.

That is the core problem. Backend errors land in server logs you own. Frontend errors happen on millions of devices you don't own, in browsers, versions, extensions, and networks you never tested. Without a way to collect them, the first sign of a broken release is an angry customer, often days late.

What an error tracker does

An error tracker is a small script in your page plus a service behind it. Sentry, Bugsnag, Rollbar, Datadog RUM, and New Relic Browser are well-known products; some large companies build their own. They all do the same five jobs, and the rest of this lesson takes them one at a time. Between enrich and symbolicate, the SDK also scrubs and sends each event, which the frontend logging lesson covers.

  1. Capture. Hook into the browser so that every uncaught error and every rejected promise nobody handled is noticed, plus the errors your framework catches for you.
  2. Enrich. Attach what you need to debug: the stack trace, the release version, the browser, the page URL, and a short trail of what the user did just before (the breadcrumbs).
  3. Symbolicate. Turn a stack trace that points at app.3f9c.js:1:48211 back into Checkout.tsx:88 using source maps, without publishing your source to the world.
  4. Group. Fold 50,000 reports of the same bug into one issue with a count, so a human can read the list. This step decides whether the tool is useful or noise.
  5. Alert. Tell the right person when something new appears, when a fixed bug comes back, or when a release makes the error rate jump.
The error tracking pipeline. In the browser, the SDK does three jobs: capture with onerror and error boundaries, enrich with breadcrumbs and the release, then scrub and send. The tracker service does three more: symbolicate with source maps that CI uploaded, group events into issues by fingerprint, and alert the owning team when an issue is new, regressed, or spiking.Browser (SDK)Tracker service1. captureonerror, boundaries2. enrichbreadcrumbs, release3. scrub, sendbeforeSend, POST4. symbolicatemaps by debug ID5. groupfingerprint to issue6. alertnew, regressed, spikeCI builduploads mapsowning teamchat, pager
The browser half must be small and fast because it runs on every page. The heavy work, decoding stacks and grouping, happens on the tracker, fed by source maps from your build.

With a tracker in place, the Tuesday story changes. Within minutes of the release, a new issue appears: a TypeError saying findLast is not a function, thrown in lastPaidItem. It has 1,200 events from 900 users, all on older Safari, first seen in release checkout@2.14.0.

An alert goes to the checkout team's channel. They ship a fix the same afternoon, and the 40 support tickets never happen.

Error tracking, logging, and RUM

Three tools collect data from the browser, and people mix them up. The [frontend logging lesson](/system-design/concepts/frontend-logging) covers how any event gets from the browser to a server: batching, sendBeacon, sampling, scrubbing personal data. We won't repeat that here. This lesson is about what is special about errors: catching them, decoding them, grouping them, and acting on them.

QuestionLoggingError trackingReal user monitoring
AnswersWhat happened, in order?What is broken, for whom, since when?How fast does it feel for real users?
Unit of dataA log line or eventAn error event, grouped into issuesA page view with timings
Typical volumeHigh, sampledLow most days, spikes on bad releasesOne per page view, sampled
Who reads itEngineers debugging a flowThe on-call engineer, the owning teamPerformance and product teams

Capturing errors in the browser

Before you can group or alert on an error, something has to notice it. The browser gives you a few global hooks, and your framework gives you a few more. Each one sees a different slice of the failures, so a tracker installs all of them. Knowing which hook sees what is the most common interview follow-up on this topic.

window.onerror: uncaught exceptions

When JavaScript throws and nothing catches it, the browser fires an error event on window. The oldest way to listen is to assign window.onerror. It is called with five arguments: the message, the script URL, the line, the column, and the Error object itself. The fifth argument is the important one, because it carries the stack trace.

js
window.addEventListener("error", (event) => {
  if (event instanceof ErrorEvent) {
    // A script threw and nobody caught it.
    report({ kind: "exception", error: event.error, message: event.message,
             file: event.filename, line: event.lineno, col: event.colno });
  } else {
    // A resource failed: img, script, link. No message, no stack.
    const el = event.target;
    report({ kind: "resource", tag: el.tagName, url: el.src || el.href });
  }
}, true); // capture phase: resource errors do not bubble

window.addEventListener("unhandledrejection", (event) => {
  report({ kind: "rejection", error: event.reason });
});
The two global hooks every tracker installs.

window.onerror and addEventListener("error", ...) see the same script errors. The difference is resource errors.

When an img, script, or link fails to load, the browser fires a plain error event on that element, and it does not bubble up to window. Only a listener in the capture phase (the third argument true) sees it on its way down. window.onerror never does.

A failed resource tells you less than a script error: there is no message and no stack, only the element and its URL. That is still worth knowing.

Take a code-split chunk that fails to load after a deploy because the old file was deleted. If the bundler loads chunks through script tags, as webpack does, the failure shows up here, just before webpack's ChunkLoadError. A native import() that fails only rejects its promise.

unhandledrejection: promises nobody handled

Most modern code is asynchronous. An error thrown inside an async function does not reach window.onerror: it rejects the promise instead. If no .catch() or try/await handles that rejection by the end of the current task, the browser fires unhandledrejection on window. Its reason property holds whatever was rejected.

Two traps hide here. First, reason is not always an Error. Code can reject with a string, a number, an API response object, or undefined, and then there is no stack at all. Good trackers record the type and a short preview of the value, and teams add a lint rule that only Error objects may be thrown or rejected.

Second, a rejection can be handled late: the browser then fires rejectionhandled, and a careful tracker can drop the earlier report.

Framework hooks and error boundaries

UI frameworks catch errors during rendering so one broken component doesn't take down the whole page in a confusing way. Once a framework catches an error, the global hooks may never see it. So every framework has its own hook, and a tracker plugs into it.

  • React has error boundaries: a class component with static getDerivedStateFromError (to show a fallback UI) and componentDidCatch(error, info) (to report it, with info.componentStack naming the components above the one that broke). React 19 adds two root options on createRoot and hydrateRoot: onCaughtError for errors a boundary caught and onUncaughtError for errors no boundary caught. They sit next to onRecoverableError (from React 18), for errors React recovered from by itself, such as a hydration mismatch. By default, React 19 passes uncaught errors to window.reportError, so the global error listeners see them too, and logs caught ones with console.error.
  • Vue calls app.config.errorHandler(err, instance, info) for errors in components, watchers, and lifecycle hooks.
  • Angular routes errors to its ErrorHandler class; you provide your own subclass that reports and then logs.
  • Next.js turns an error.js file in a route folder into an error boundary for that route, and global-error.js into one for the root layout. SvelteKit has a handleError hook in hooks.client.js.

Error boundaries are easy to over-trust. A boundary only catches errors thrown while React renders: in render functions, lifecycle methods, and constructors of components below it. It does not catch errors in event handlers, in setTimeout callbacks, in promises, in server rendering, or inside the boundary itself. A click handler that throws goes straight to window.onerror, boundary or not.

A React component tree. App has three children: Header, an ErrorBoundary, and Sidebar. Inside the ErrorBoundary, Cart throws while rendering, so the boundary renders a Fallback and reports the error. Header and Sidebar keep working. A note lists what boundaries miss: event handlers, timers and promises, and server rendering.AppHeaderstill worksSidebarstill worksErrorBoundaryCartFallbackrender throwsshown, reportedboundary misses:event handlers,timers, promises,server rendering
The boundary contains a render crash to its own part of the page and reports it. Errors from event handlers, timers, and promises skip the boundary and go to the global listeners.

Where you place boundaries is a product decision. One boundary at the root turns any render error into a blank page with an apology. Boundaries around each independent widget (the sidebar, the comments, the chart) let the rest of the page keep working while one panel shows "This part failed to load. Retry".

Every boundary should report what it caught, otherwise you have hidden the error from users and from yourself.

What all the hooks miss

  • Errors before the tracker loads. If the SDK is the last script on the page, a crash during startup is lost. Load it first, or inline a tiny stub that queues errors until the SDK arrives.
  • Web workers. A worker has its own global scope. An error inside it fires error on the worker's self and then on the Worker object in the page, not on window. Install a handler in the worker or listen on the Worker object.
  • Silent failures. A button that does nothing because a condition is wrong, or a blank list because the API returned [], throws nothing at all. Error tracking can't see logic bugs; product metrics and logs have to.
  • Browser crashes and freezes. If the tab runs out of memory or hangs, no JavaScript runs to report it. In Chromium browsers, the Reporting API covers part of this gap: after a crash, the browser itself sends a crash report with a reason such as oom (out of memory) to an endpoint you name in the Reporting-Endpoints header. A heartbeat in a worker can detect a frozen main thread.

Try it: pick a failure below and see which hooks notice it. The pattern to remember is that each hook covers a different kind of failure, and only the set of all of them gives good coverage.

Failure: click handler throws. window.onerror: sees it. error listener, capture: sees it. unhandledrejection: misses it. framework hook: misses it.click handler throwswindow.onerrorerror listener, captureunhandledrejectionframework hookreported
Green hooks report this failure. Each hook covers a different kind, so a tracker installs all four.

Event handlers run outside rendering, so error boundaries never see them. The window error hooks do, with the full Error and stack.

Check your understanding · Exercise 1

A React page wraps everything in an error boundary. Which of these failures will the boundary report? Select all that apply.

Getting a stack trace you can read

Capturing an error is half the job. The report also has to say where in your code it happened. Two things get in the way: the browser hides details of errors from other origins, and your shipped code is minified beyond recognition.

"Script error." and cross-origin scripts

Many sites serve their JavaScript from a CDN on another origin, such as cdn.shop.com for a page on www.shop.com. When a script from another origin throws, the browser protects that origin by default: your handler gets the message "Script error.", an empty file name, line 0, column 0, and error set to null. Without this rule, any page could load your script and read secrets out of its error messages.

The fix has two halves, and both are required. The script tag asks for a CORS request with crossorigin="anonymous", and the CDN answers with Access-Control-Allow-Origin: https://www.shop.com (or *).

Then the browser treats the script like your own and passes the full error through. Module scripts (type="module") are always fetched with CORS, so they need the header in any case. The [CORS lesson](/system-design/concepts/cors) explains the headers.

Before and after. Left, without CORS: the script tag has no crossorigin attribute and the CDN sends no Access-Control header, so the error handler gets Script error., an empty file, line 0, and error null. Right, with crossorigin anonymous and Access-Control-Allow-Origin star: the handler gets the real message, app.js line 1 column 48211, and the Error object with its stack.Without CORS<script src="cdn/app.js">no Access-Control headeryour error handler gets:Script error.file: (empty), line 0error: nullWith crossorigin and CORScrossorigin="anonymous"Access-Control-Allow-Origin: *your error handler gets:findLast is not a functionapp.js 1:48211error: Error with stack
The page and the CDN are on different origins in both cases. Only the pair of crossorigin on the tag and Access-Control-Allow-Origin on the response lets the details through.

Third-party scripts you don't control (ads, chat widgets, tag managers) often can't send the header. Their errors stay as "Script error.". Most teams filter them out entirely, since they can't fix them anyway; chapter 7 comes back to this.

Minified stacks are useless on their own

Production bundles are minified: whitespace removed, names shortened to one letter, many files joined into one. The whole bundle is often a single line. In Chrome, a stack frame from it looks like at n (https://cdn.shop.com/assets/app.3f9c1a.js:1:48211); Firefox and Safari write n@ and the URL instead. The function is called n, the line is 1, and the only useful number is the column.

A source map is a JSON file the bundler writes next to each bundle. It lists the original files (sources), the original names (names), optionally the original code itself (sourcesContent), and a compact mappings string. For each mapped position in the minified file, mappings says which file, line, column, and name it came from. Turning a minified frame back into an original one is called symbolication.

The format was a de facto standard for over a decade. Since December 2024 it is an official standard, ECMA-426, maintained by a TC39 task group.

text
Minified (what an older Safari reports)
  TypeError: e.findLast is not a function
    n@https://cdn.shop.com/assets/app.3f9c1a.js:1:48211
    r@https://cdn.shop.com/assets/app.3f9c1a.js:1:51907

Symbolicated (what the tracker shows; the message is not rewritten)
  TypeError: e.findLast is not a function
    at lastPaidItem (src/checkout/totals.ts:41:22)
    at onPayClick (src/checkout/PayButton.tsx:88:9)
The same frame before and after symbolication.

Hidden source maps and debug IDs

Normally a bundle ends with a comment such as //# sourceMappingURL=app.3f9c1a.js.map, and anyone with DevTools open can download the map and read your original code. Many companies don't want that. The common answer is a hidden source map: the bundler still writes the .map file but leaves out the comment (devtool: "hidden-source-map" in webpack, build.sourcemap: "hidden" in Vite). The build uploads the maps to the error tracker and does not deploy them to the CDN.

The tracker then has to find the right map for each error. The old way matches on the release name plus the file URL, which breaks when the CDN path changes or a file is served from two URLs.

The newer way is a debug ID: the build tool writes the same unique ID into the bundle and its map, and a tiny snippet in the bundle exposes it at runtime. The SDK sends the ID with each error, and the tracker looks up the map by ID, with no URL guessing.

Debug IDs for JavaScript started in Sentry's build tooling. A proposal to add a debugId field to ECMA-426 is in progress at TC39, so for now the exact format depends on your tracker's build plugin.

A sequence diagram with four actors: CI build, CDN, Browser, and Tracker. At build time, CI deploys app.js to the CDN without its map, and uploads app.js.map with a debug ID to the tracker. Later, the CDN serves app.js to the browser. When it throws, the browser sends the error with position 1:48211 and the debug ID to the tracker, which finds the map by ID and shows totals.ts line 41 in lastPaidItem.CI buildCDNBrowserTrackerdeploy app.js onlymap + debug IDapp.jserror + debug IDfinds the map by debug IDtotals.ts:41 lastPaidItembuild time, then error time
The map never reaches the CDN or the browser. The debug ID in the bundle is the only link the tracker needs to find it.

Decision How should production source maps be handled?

Public source maps

Deploy .map files next to the bundles

Keep the sourceMappingURL comment

Pros

  • Zero setup
  • DevTools in production shows real code
  • Any tracker can fetch them

Cons

  • Anyone can read your original source and comments
  • The tracker must fetch maps at error time, which can fail or be slow

Hidden maps, uploaded to the tracker

Generate maps without the comment

Upload them in CI with the release or debug ID

Don't deploy them

Pros

  • Readable stacks in the tracker
  • Source stays private
  • Works offline from the CDN

Cons

  • One more CI step that can silently fail
  • Engineers can't use maps in production DevTools without extra steps

No source maps

Ship minified code only

Pros

  • Nothing to configure or leak

Cons

  • Every stack is n at line 1; bugs take hours longer to find

What we pick: Hidden maps uploaded from CI is the default for most product teams. Add a CI check that fails the deploy if the upload fails, and keep maps for every release still in use, since users with old tabs open keep sending errors from old bundles for days.

Check your understanding · Exercise 2

Every error from your CDN-hosted bundle arrives as "Script error." with line 0. What is true? Select all that apply.

Grouping: from 50,000 events to 12 issues

A busy app sends thousands of error events an hour. Nobody can read them one by one. The tracker folds events that come from the same bug into one issue, with a count, a first-seen time, a last-seen time, and the number of users affected. Grouping is the step that decides whether the tool is useful: group too little and one bug floods the list, group too much and a new bug hides inside an old one.

Fingerprints

Each event gets a fingerprint: a short hash computed from the parts of the event that identify the bug. Events with the same fingerprint join the same issue. The whole art is choosing which parts go into the hash and how to clean them first.

  1. Symbolicate the stack, so frames name real functions and files instead of n in app.3f9c1a.js. Without this, every deploy changes the bundle name and every bug becomes a new issue.
  2. Keep only your own frames ("in-app" frames). Frames inside React, the bundler runtime, or a vendor library are the same for thousands of different bugs.
  3. Drop line and column numbers, or at least don't rely on them alone. Adding one line above a function moves every frame below it, and the same bug would become a new issue after an unrelated edit.
  4. Hash the error type plus the function and file names of those frames. If there is no usable stack, fall back to the type plus a normalized message: the message with numbers, IDs, URLs, and quoted values replaced by placeholders.

Why the same bug looks different in each browser

Grouping by message sounds simple, but browsers word the same error differently and format stacks differently. The same line of code, item.price with item undefined, produces three different reports:

Browser engineMessageStack frame format
Chrome, Edge (V8)Cannot read properties of undefined (reading 'price')at cartTotal (app.js:1:4821)
Firefox (SpiderMonkey)can't access property "price", item is undefinedcartTotal@app.js:1:4821
Safari (JavaScriptCore)undefined is not an object (evaluating 'item.price')cartTotal@app.js:1:4821

So a tracker parses each format into the same structure (function, file, line, column) and groups on the frames, not the text. After symbolication, all three point at cartTotal in totals.ts, and they land in one issue. The wording in the table is from recent versions; engines reword messages from time to time, which is another reason not to group on them.

When the default is wrong

Stack-based grouping fails in a predictable case: shared helpers. If every API call goes through fetchJson, and fetchJson throws on any non-2xx status, then a 500 from the orders API and a 422 from the coupon API have the same stack. They merge into one issue, and fixing one looks like it didn't help. The fix is a custom fingerprint for those errors, such as ["api", "POST /api/cart/apply-coupon", "422"]: the route pattern and the status.

The opposite failure is a message with an ID in it, like Loading chunk 7412 failed. If the tracker groups on message, every chunk number is a new issue. Normalizing numbers away, or a custom fingerprint ["chunk-load-failed"], folds them together.

The lab below plays a stream of 59 events from two releases through four grouping rules. Watch two things: how many issues you get, and whether the new bug in release 2.14 (a findLast call that breaks older Safari) shows up as its own new issue.

Group by
59 error events from releases 2.13 and 2.14, 5 real bugs, grouped into 17 issues. TypeError: items.findLast is not a function: 9 events, new in 2.14. ApiError: POST /api/cart/apply-coupon failed: 422: 9 events. TypeError: Cannot read properties of undefined (reading 'price'): 8 events. TypeError: can't access property "price", item is undefined: 4 events. ApiError: GET /api/orders/88231 failed: 500: 4 events. TypeError: undefined is not an object (evaluating 'item.price'): 3 events. ChunkLoadError: Loading chunk 7412 failed.: 3 events. ChunkLoadError: Loading chunk 3301 failed.: 3 events. ChunkLoadError: Loading chunk 9025 failed.: 2 events, new in 2.14. ApiError: GET /api/orders/10422 failed: 500: 2 events. ApiError: GET /api/orders/55190 failed: 500: 2 events, new in 2.14. ApiError: GET /api/orders/7311 failed: 500: 2 events. ApiError: GET /api/orders/40017 failed: 500: 2 events, new in 2.14. ApiError: GET /api/orders/92650 failed: 500: 2 events. ApiError: GET /api/orders/31108 failed: 500: 2 events. ChunkLoadError: Loading chunk 1188 failed.: 1 events, new in 2.14. ChunkLoadError: Loading chunk 5560 failed.: 1 events, new in 2.14. The findLast regression stands out as its own new issue in 2.14, but 5 old bugs also look new because their messages changed.17 issues for 5 bugseventsTypeError: items.findLast is not a f...9newApiError: POST /api/cart/apply-coupo...9TypeError: Cannot read properties of...8TypeError: can't access property "pr...4ApiError: GET /api/orders/88231 fail...4TypeError: undefined is not an objec...3ChunkLoadError: Loading chunk 7412 f...3ChunkLoadError: Loading chunk 3301 f...3ChunkLoadError: Loading chunk 9025 f...2newApiError: GET /api/orders/10422 fail...2ApiError: GET /api/orders/55190 fail...2newApiError: GET /api/orders/7311 faile...2ApiError: GET /api/orders/40017 fail...2newApiError: GET /api/orders/92650 fail...2ApiError: GET /api/orders/31108 fail...2ChunkLoadError: Loading chunk 1188 f...1newChunkLoadError: Loading chunk 5560 f...1new
Green is the real regression. Red issues merge different bugs; amber issues are one bug split into several. Bars show events per issue; new means every event came from 2.14.

17 issues. 0 merge different bugs, 3 bugs are split across issues. The findLast regression stands out as its own new issue in 2.14, but 5 old bugs also look new because their messages changed.

Check your understanding · Exercise 3

After a deploy, your tracker shows 40 new issues that all look like old bugs you already knew about. What is the likely cause? Select all that apply.

Releases, regressions, and user impact

Most new errors arrive with a deploy. So the most useful question a tracker answers is not "what is broken?" but "what did this release break?". That needs every event tagged with the release that produced it, and a few numbers that compare releases fairly.

Release tracking

The build picks a release name, often the app name plus a version or the git commit (checkout@2.14.0 or web@8f3c2d1). The same string goes to three places: into the bundle so the SDK sends it with every event, onto the uploaded source maps, and to the tracker as a deploy marker with the list of commits. With those three, the tracker can say "first seen in 2.14.0" and even point at the commit that last touched the file in the top frame (a suspect commit).

Remember that releases overlap. A single-page app keeps running the old bundle in tabs that were opened before the deploy, sometimes for days. For a while, events arrive from two or three releases at once. Every chart that compares releases has to split by release, not by time.

The life of an issue

Each issue has a state that a human or the tracker changes. The states matter because alerts hang off the transitions: you want to hear about an issue when it is new and when it comes back, not every time it fires.

A state machine for an issue. A new issue alerts, then becomes ongoing once triaged. Ongoing goes to resolved in 2.14 when the fix ships, or to ignored when marked as noise. From resolved, an event from release 2.14 or later moves it to regressed, which alerts; events from old 2.13 tabs keep it resolved. Regressed goes back to ongoing when triaged again.newongoingresolved in 2.14regressedignoredtriagedfixedevent from2.14 or latertriaged againmark as noisealertalert2.13 events fromold tabs: no change
Alerts hang off two transitions: a new issue, and a resolved issue that comes back in a release that should contain the fix. Events from old tabs on the old release don't count.

The key state is resolved in a release. If you mark an issue resolved in 2.14.0, the tracker ignores later events from 2.13.x (old tabs still hit the old bug) and only treats events from 2.14.0 or newer as a regression. Without that rule, every fixed bug "regresses" for days after the fix, and people learn to ignore regression alerts.

Measuring user impact

Event counts mislead. One user stuck in a loop that retries every second can send 10,000 events in three hours, while a crash on the Pay button that hits 2,000 people once each sends 2,000. Rank issues by users affected first, and weigh by where it happens: an error on checkout matters more than one on the settings page.

To judge a whole release, trackers use session-based numbers. A session is one visit to the app. Each tracker defines where a session starts and ends, for example a new one after a period without activity.

  • Crash-free sessions: the share of sessions with no unhandled error. If release 2.13 had 200,000 sessions and 1,200 had an unhandled error, it is 99.4 percent crash-free.
  • Crash-free users: the same, counted per user over a day or a week. It is lower than the session number, because a user with five sessions has five chances to crash.
  • Errors per session: total error events divided by sessions. Good for spotting a release that throws more handled errors without crashing.
A bar chart of sessions with an unhandled error per 1,000 sessions, by release. 2.11.0: 5, so 99.5 percent crash-free. 2.12.0: 7, 99.3 percent. 2.13.0: 6, 99.4 percent. 2.14.0 in its first hour: 22, 97.8 percent, far past the 99 percent gate at 10 per 1,000.crashed sessions per 1,000, and crash-free rate2.11.099.5%2.12.099.3%2.13.099.4%2.14.0, 1st hour97.8%99% gate
Each bar is crashed sessions per 1,000. The new release crosses the 99 percent gate in its first hour: stop the rollout first, read stack traces second.

In the chart, 2.14.0 drops from 99.4 to 97.8 percent crash-free in its first hour. That is about 1 in 45 sessions failing, almost four times worse than the release before. This is the signal to roll back or turn off a flag, before reading a single stack trace. Compare a new release in its first hours against the old one over the same hours, not against a week of history: early adopters are a different mix of devices.

Check your understanding · Exercise 4

An issue was marked "resolved in 3.2.0" yesterday. Today it has 300 new events. Which facts suggest it really regressed? Select all that apply.

Noise, filtering, and alerting

Turn on an error tracker for a large site and the first day is humbling: hundreds of issues, most of them not your bugs. If you alert on all of them, people mute the channel within a week, and then the real regression goes unseen. Cutting noise comes before alerting.

Where the noise comes from

SourceWhat it looks likeWhat to do
Browser extensionsFrames from chrome-extension:// or moz-extension:// URLsDrop events whose top frames are not on your domains
Third-party scripts"Script error." with line 0, or frames on ad or chat domainsDrop, or route to the vendor's owner; you can't fix their code
ResizeObserverResizeObserver loop completed with undelivered notificationsUsually ignore: the browser delivers the skipped notifications in the next frame. Older Chrome said ResizeObserver loop limit exceeded
Network dropsFailed to fetch (Chrome), NetworkError when attempting to fetch resource. (Firefox), Load failed (Safari)Don't alert per event; track the rate, and alert if it jumps
Cancelled requestsAbortError when the user navigates awayIgnore when your code aborted on purpose
Bots and old browsersHeadless user agents, browsers far below your support lineFilter by user agent or by the browsers you support
Page translationNotFoundError from removeChild after a translate tool rewrote the DOMGroup and tag it; consider translate="no" on fragile parts

Filter in two places. In the SDK, an allow list of your own script URLs and a short list of ignored messages stop junk before it costs the user any bandwidth. On the server, inbound filters catch what the SDK missed, and you can change them without a deploy. Keep the client list short: every rule there ships in the bundle and only changes with a release.

Volume needs limits too. A bug inside a requestAnimationFrame loop can throw 60 times a second per user. Good SDKs send the same error at most a few times per page, and trackers apply spike protection and per-project quotas so one bad release doesn't use up the month's budget in an hour.

A flowchart for each incoming error event. Are its frames on our domains? If no, drop it as extension or vendor noise. Is it a known noise message? If yes, drop it or only count it. Is it a new or regressed issue? If no, add it to the existing issue for the daily digest. Does it affect 10 or more users in an hour? If yes, alert the owning team; if no, keep counting.error event arrivesframes on our domains?nodrop: extension, vendoryesknown noise message?yesdrop, or count onlynonew or regressed issue?noadd to issue, digestyes10+ users in an hour?yesalert the owning teamnokeep counting
Most events end in the first three exits. Only a new or regressed issue with real reach pages anyone.

Alert rules that people trust

An alert is worth sending only if someone should act now. Four kinds cover most needs:

  • New issue with real reach: first seen in production and affecting at least 10 users within an hour. The user minimum filters out one-off weirdness from a single odd device.
  • Regression: a resolved issue has events from a release that should contain the fix.
  • Spike: an issue's rate in the last 15 minutes is several times its usual rate at this hour, and above a minimum count. Comparing with the same hour last week handles the normal daily traffic curve.
  • Release health: crash-free sessions for the newest release fall below a line, such as 99 percent, once it has enough sessions to judge.

Fixed thresholds are the trap. "Alert when an issue has more than 500 events an hour" fires every evening at peak traffic and stays quiet at 3 a.m., when a real outage only produces about 200. Relative rules with a floor, like "3 times the usual rate and at least 50 events", follow the traffic curve. Try both in the simulator below.

Hourly error counts for one day, with the alert line for a fixed rule of 500 per hour. Counts peak at 800 around 7 p.m. A real incident at 3 a.m. produces 205 errors against a usual 38. The 3 a.m. incident is missed, with 5 false alarms during the day.The 3 a.m. incident is missed, with 5 false alarms during the day.0h6h12h18h23hincidentevening peak
Green is an alert on the real incident, red a false alarm, amber a missed incident. A fixed line can't fit both the quiet night and the busy evening; a line that follows last week's curve can.

The 3 a.m. incident is missed, with 5 false alarms during the day.

Then route alerts to owners. Map file paths or routes to teams (src/checkout/** goes to the payments team), the way a CODEOWNERS file maps code to reviewers. Page a person at night only for release health and checkout-level crashes; send the rest to the team's channel or a daily digest.

Check your understanding · Exercise 5

Which of these should you filter out or ignore rather than alert on? Select all that apply.

Privacy, performance, and cost

An error tracker sends data from users' devices to a third party, runs code on every page, and bills per event. Each of those needs a deliberate choice. The [frontend logging lesson](/system-design/concepts/frontend-logging) covers the general rules for scrubbing and sending; here are the parts that are special to errors.

Errors leak personal data

Error reports pick up personal data in places people don't expect. A validation error says Invalid email: jane@example.com. A password reset page throws with ?token=9f2c... in the URL.

A breadcrumb records GET /api/users/jane@example.com. Session replay sees everything on screen.

  • Scrub in the browser with the SDK's hooks (Sentry calls them beforeSend and beforeBreadcrumb): strip query strings, replace emails and long digit runs, drop request and response bodies.
  • Scrub again on the server with pattern rules, as a safety net for what the client missed, including events from old releases.
  • Identify users with an opaque ID, not an email or name. Support can still look a user up in your own systems.
  • Limit storage: turn off IP address storage if you don't need it, pick the tracker's region that matches your users (such as an EU region for EU data), and keep events for 30 to 90 days, not forever.

Keeping the SDK light

A full-featured SDK with tracing and replay can add tens of KB of compressed JavaScript, which competes with your own code on a slow phone. But loading it late means missing errors during startup, which are often the worst ones. The usual answer is a split:

Decision How should the error SDK be loaded?

Full SDK first, blocking

A normal script tag before the app bundle

Pros

  • Catches every error from the first line
  • Simplest setup

Cons

  • Delays the app's start on every visit for code that is idle most of the time

Tiny inline stub, full SDK later

An inline script of about 1 KB installs error and unhandledrejection listeners and queues events

The full SDK loads after the page is interactive, or at once when the first error arrives, then sends the queue

Pros

  • Startup errors are kept
  • Almost no cost on the critical path

Cons

  • Two pieces to keep in sync
  • Breadcrumbs before the SDK loads are thin

SDK bundled with the app

Imported at the top of the entry file

Pros

  • One file, no extra request
  • Versions match the app

Cons

  • If the bundle fails to load or parse, nothing reports it
  • Adds to the main bundle size

What we pick: For most product sites the stub plus lazy SDK gives the best balance. If the app is small or errors during startup are rare, bundling the SDK is fine, as long as a failed bundle load is caught some other way, such as a resource error listener in the HTML.

Getting events through, and paying for them

Ad blockers block many tracker domains, which hides a share of your errors, and a bigger share on sites with tech-savvy users. A common fix is a tunnel: the SDK posts to a path on your own domain, such as /monitoring, and your server forwards the event to the tracker. Your server should check the event points at your project, so the tunnel can't be used to send arbitrary data.

Trackers bill per event, so cost follows volume. Random sampling of errors is a poor way to save money: it hides rare bugs, the ones you most need to see. Better levers are the ones from the noise chapter: filter junk at the source, send each repeated error once per page with a count, and set quotas per project. Sample performance traces and replays heavily; keep errors at 100 percent if you can.

Using it in a system design interview

Error tracking rarely is the question. It is part of a good answer to almost any app question (a checkout, a document editor, a dashboard) in the last ten minutes, when the interviewer asks "how would you know it works in production?" or "what happens when this fails?". A short, concrete answer there is a strong senior signal.

What to say and draw

  1. Capture: global error (capture phase) and unhandledrejection listeners, error boundaries around independent parts of the UI that report what they catch, and manual reports in catch blocks for unexpected failures.
  2. Readable stacks: crossorigin plus CORS on the CDN, hidden source maps uploaded from CI with a debug ID or release.
  3. Context: release, route pattern, browser, opaque user ID, active feature flags, and a breadcrumb trail.
  4. Grouping and alerting: fingerprints on in-app frames, new-issue and regression alerts with user minimums, crash-free sessions per release gating the rollout.
  5. Privacy and cost: scrubbing in beforeSend and on the server, filtering noise at the source, keeping errors unsampled.

On the whiteboard, a small box in the browser labelled "error SDK" with an arrow to an "error tracker" box is enough, plus a line from CI to the tracker for source maps. Draw the CI line: it shows you know symbolication is a build-time job.

How deep to go by level

LevelWhat a good answer includes
JuniorUse a tracker like Sentry; error boundaries for render errors; source maps so stacks are readable.
MidWhich hook catches what (boundaries miss event handlers and promises); "Script error." and CORS; hidden source maps; breadcrumbs and release tags.
SeniorGrouping and custom fingerprints; regressions per release; crash-free sessions as a rollout gate; noise filters and alert rules with floors; PII scrubbing; stub-then-lazy SDK loading.
StaffOwnership routing and on-call policy; error budgets tied to release process; tunnels and quotas; build-vs-buy and data residency for a company-wide platform.

Check your understanding · Exercise 6

An interviewer asks how you would find out that the new checkout breaks on one browser. Which points belong in a strong answer? Select all that apply.

Summary

  • Frontend errors happen on devices you don't own; without a tracker, users and support are your monitoring.
  • Install every hook: error in the capture phase (for resource errors too), unhandledrejection, framework hooks such as error boundaries, and manual reports in catch blocks. Each sees a different slice.
  • Error boundaries catch render errors only, not event handlers, timers, promises, or server rendering.
  • "Script error." means a cross-origin script without CORS: add crossorigin and the Access-Control-Allow-Origin header, header first.
  • Upload hidden source maps from CI, matched by debug ID or release, and keep them for every release still running.
  • Group on symbolicated in-app frames, not messages; use custom fingerprints for errors from shared helpers.
  • Judge releases by crash-free sessions and users affected, and alert on new issues, regressions, and relative spikes with floors.
  • Scrub personal data in the SDK and again on the server; filter noise instead of sampling errors.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Walk me through how an error in a user's browser ends up as an alert for the right team.

Question 2

Your error boundary is in place, but a broken button still produced no report. Why?

Question 3

Why do you see "Script error." and how do you fix it?

Question 4

How would you keep source maps private and still get readable stacks?

Question 5

How do you decide whether a new release is healthy?

Practice it

These design problems lean on error tracking.

References

Read next