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.
- 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.
- 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).
- Symbolicate. Turn a stack trace that points at
app.3f9c.js:1:48211back intoCheckout.tsx:88using source maps, without publishing your source to the world. - 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.
- Alert. Tell the right person when something new appears, when a fixed bug comes back, or when a release makes the error rate jump.
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.
| Question | Logging | Error tracking | Real user monitoring |
|---|---|---|---|
| Answers | What happened, in order? | What is broken, for whom, since when? | How fast does it feel for real users? |
| Unit of data | A log line or event | An error event, grouped into issues | A page view with timings |
| Typical volume | High, sampled | Low most days, spikes on bad releases | One per page view, sampled |
| Who reads it | Engineers debugging a flow | The on-call engineer, the owning team | Performance 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.
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 });
});
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) andcomponentDidCatch(error, info)(to report it, withinfo.componentStacknaming the components above the one that broke). React 19 adds two root options oncreateRootandhydrateRoot:onCaughtErrorfor errors a boundary caught andonUncaughtErrorfor errors no boundary caught. They sit next toonRecoverableError(from React 18), for errors React recovered from by itself, such as a hydration mismatch. By default, React 19 passes uncaught errors towindow.reportError, so the globalerrorlisteners see them too, and logs caught ones withconsole.error. - Vue calls
app.config.errorHandler(err, instance, info)for errors in components, watchers, and lifecycle hooks. - Angular routes errors to its
ErrorHandlerclass; you provide your own subclass that reports and then logs. - Next.js turns an
error.jsfile in a route folder into an error boundary for that route, andglobal-error.jsinto one for the root layout. SvelteKit has ahandleErrorhook inhooks.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.
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
erroron the worker'sselfand then on theWorkerobject in the page, not onwindow. Install a handler in the worker or listen on theWorkerobject. - 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
crashreport with a reason such asoom(out of memory) to an endpoint you name in theReporting-Endpointsheader. 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.
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.
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.
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)
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.
Decision How should production source maps be handled?
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.
- Symbolicate the stack, so frames name real functions and files instead of
ninapp.3f9c1a.js. Without this, every deploy changes the bundle name and every bug becomes a new issue. - 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.
- 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.
- 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 engine | Message | Stack 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 undefined | cartTotal@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.
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.
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.13had 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.
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
| Source | What it looks like | What to do |
|---|---|---|
| Browser extensions | Frames from chrome-extension:// or moz-extension:// URLs | Drop events whose top frames are not on your domains |
| Third-party scripts | "Script error." with line 0, or frames on ad or chat domains | Drop, or route to the vendor's owner; you can't fix their code |
| ResizeObserver | ResizeObserver loop completed with undelivered notifications | Usually ignore: the browser delivers the skipped notifications in the next frame. Older Chrome said ResizeObserver loop limit exceeded |
| Network drops | Failed 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 requests | AbortError when the user navigates away | Ignore when your code aborted on purpose |
| Bots and old browsers | Headless user agents, browsers far below your support line | Filter by user agent or by the browsers you support |
| Page translation | NotFoundError from removeChild after a translate tool rewrote the DOM | Group 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.
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.
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
beforeSendandbeforeBreadcrumb): 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?
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
- Capture: global
error(capture phase) andunhandledrejectionlisteners, error boundaries around independent parts of the UI that report what they catch, and manual reports in catch blocks for unexpected failures. - Readable stacks:
crossoriginplus CORS on the CDN, hidden source maps uploaded from CI with a debug ID or release. - Context: release, route pattern, browser, opaque user ID, active feature flags, and a breadcrumb trail.
- Grouping and alerting: fingerprints on in-app frames, new-issue and regression alerts with user minimums, crash-free sessions per release gating the rollout.
- Privacy and cost: scrubbing in
beforeSendand 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
| Level | What a good answer includes |
|---|---|
| Junior | Use a tracker like Sentry; error boundaries for render errors; source maps so stacks are readable. |
| Mid | Which hook catches what (boundaries miss event handlers and promises); "Script error." and CORS; hidden source maps; breadcrumbs and release tags. |
| Senior | Grouping 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. |
| Staff | Ownership 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:
errorin 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
crossoriginand theAccess-Control-Allow-Originheader, 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.
The SDK catches it through a global error or unhandledrejection listener, or a framework hook such as an error boundary. It adds context: release, browser, route pattern, opaque user ID, flags, and the breadcrumb trail.
It scrubs personal data and sends the event, through a tunnel on our domain if ad blockers are a concern.
The tracker symbolicates the stack with the source map uploaded from CI for that release or debug ID, computes a fingerprint from the in-app frames, and adds the event to an issue.
If the issue is new and reaches the user minimum, or a resolved issue regresses in a newer release, an alert goes to the team that owns the file or route, found through ownership rules.
Question 2
Your error boundary is in place, but a broken button still produced no report. Why?
Error boundaries only catch errors thrown during rendering, lifecycle methods, and constructors. A click handler runs outside rendering, so its error goes to window.onerror, not to the boundary.
If no global listener is installed, or the handler wrapped the code in a try/catch that only showed a toast, nothing was reported. The fix is global listeners plus manual reporting in catch blocks for unexpected errors.
It is also possible the handler failed silently, for example a condition that returns early. That throws nothing and needs logs or product metrics instead.
Question 3
Why do you see "Script error." and how do you fix it?
The bundle comes from another origin, such as a CDN, and was loaded without CORS. The browser hides the message, file, position, and error object from the page to avoid leaking data across origins.
Fix it with crossorigin="anonymous" on the script tag and Access-Control-Allow-Origin on the CDN response. Deploy the header first: the attribute without the header blocks the script.
For third-party scripts that can't send the header, filter the events out; you can't fix their code anyway.
Question 4
How would you keep source maps private and still get readable stacks?
Build with hidden source maps, which omit the sourceMappingURL comment, upload them to the tracker from CI, and don't deploy them to the CDN.
Match bundles to maps with a debug ID injected into both, or with the release name plus file path. Fail the deploy if the upload fails.
Keep maps for every release still in use, because old tabs send errors from old bundles for days.
Question 5
How do you decide whether a new release is healthy?
Compare crash-free sessions and errors per session for the new release against the previous one over the same early window, split by release rather than time.
Look for new issues first seen in that release, ranked by users affected, and for regressions of issues resolved in it.
Automate it in a staged rollout: if the 5 percent cohort drops below the threshold, stop the rollout or turn the flag off before more users see it.
Practice it
These design problems lean on error tracking.
References
- Window: error event (MDN) Script errors vs resource errors and the event properties.
- Window: unhandledrejection event (MDN) PromiseRejectionEvent and rejectionhandled.
- Catching rendering errors with an error boundary (React docs) getDerivedStateFromError, componentDidCatch, and what boundaries don't catch.
- createRoot root options (React docs) onCaughtError, onUncaughtError, and onRecoverableError.
- React 19 upgrade guide: errors in render are not re-thrown Uncaught errors go to window.reportError, caught ones to console.error.
- HTML attribute: crossorigin (MDN) How the attribute switches a script request to CORS.
- Source map format specification (ECMA-426) The format of sources, names, mappings, and sourcesContent.
- Sentry docs: Source maps Uploading maps, debug IDs, and troubleshooting.
Read next
- Logging in the browserSending small, structured records of what happened in each user's browser to your own servers, in batches that survive the tab closing, sampled and scrubbed so they stay cheap and safe: the only way to see bugs that never happen on your machine.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Feature flagsSwitches in your code that turn features on or off for chosen users without a deploy: for gradual releases, experiments, emergency kill switches, and plan-based access, if you avoid flicker, leaked code, and a pile of dead flags.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.