JavaScript Error Handling & Boundaries: A Runtime Capture Architecture

A production JavaScript application throws errors in places you do not control: a third-party script that fails to parse, a promise that rejects three microtasks deep, a React render that crashes mid-commit, a Node worker that exits on an uncaught exception. The job of an error-handling architecture is to make every one of those events catchable, attributable, and symbolicated before it reaches a user or disappears from a log. That requires a layered pipeline rather than a single global handler — synchronous interception at the runtime edge, boundary isolation inside each framework, deterministic symbolication of minified stack frames, and a privacy-controlled SDK that ships structured payloads to an observability backend tagged with the exact release that produced them. This page is the index for that pipeline. It connects the guides on mastering window.onerror and global event listeners for browser-edge capture, handling unhandled promise rejections in modern JS for the async failure path, and Node.js uncaughtException vs unhandledRejection for server-process stability. The goal throughout is the same: reduce Mean Time to Resolution (MTTR) by guaranteeing that an alert carries a readable stack trace, a release identifier, and enough context to reproduce the failure.

JavaScript error capture pipeline Six stacked layers: raw runtime events flow into global interception, then framework boundaries, then source-map symbolication, then the observability SDK, then CI/CD release correlation. Raw runtime events: throw, reject, resource error Global interception (onerror, process hooks) Framework boundaries (React, Vue, Node) Source-map symbolication Observability SDK CI/CD release correlation (commit, build ID)

Core Runtime Patterns & Interception Layer

The interception layer is the foundation every other layer stands on. If a runtime event is never observed, no boundary, symbolicator, or SDK can act on it. The defining property of this layer is that it must be installed first — before framework bootstrap, before route hydration, before any application module that might throw during evaluation. A synchronous error thrown while parsing your main bundle will never reach a React boundary because React has not mounted yet; only a global handler registered at the top of the entry point can catch it.

In the browser, two distinct event channels matter. The first is window.onerror / addEventListener('error'), which fires for uncaught synchronous exceptions and — in the capture phase — for resource load failures such as a broken <img> or a script that 404s. The second is addEventListener('unhandledrejection'), which fires when a promise rejects with no attached .catch(). These are different code paths in the engine, and a handler for one does not observe the other. A complete edge install registers both:

// Install at the very top of the entry module, before any framework import.
window.addEventListener('error', (event) => {
  // event.error is the thrown value; filename/lineno/colno locate the raw frame.
  telemetry.capture(event.error ?? new Error(event.message), {
    source: event.filename, line: event.lineno, col: event.colno,
  });
}, true); // capture phase = also catches non-bubbling resource errors

window.addEventListener('unhandledrejection', (event) => {
  // event.reason is whatever the promise rejected with — often not an Error.
  telemetry.capture(normalizeReason(event.reason), { kind: 'unhandledrejection' });
});

Two failure modes dominate this layer. The first is the opaque "Script error." message returned for exceptions thrown by cross-origin scripts without the right CORS headers — the engine withholds the message, file, and stack as a security measure. The fix is to serve the script with Access-Control-Allow-Origin and add crossorigin="anonymous" to the tag; the details are in the deep dive on why window.onerror misses cross-origin script errors. The second is that the main thread’s window handlers never see exceptions thrown inside a Web Worker — those have their own global scope and require a dedicated worker.onerror bridge, covered in how to capture uncaught exceptions in web workers.

There is a third reason this layer must come first: timing relative to the framework. Frameworks frequently install their own global handlers and swallow errors they consider handled, re-throwing only a sanitized version or nothing at all. If your handler registers after theirs, you observe the framework’s filtered view rather than the raw event. Registering at the top of the entry module — and, where the framework allows it, also subscribing through the framework’s own error hook — gives you both the raw runtime signal and the enriched framework context. The two are complementary, not redundant: the raw signal tells you what threw, the framework hook tells you where in the component tree it threw.

The async channel deserves its own discipline. Most “lost” production errors are rejected promises that nobody awaited — a fetch whose .then() chain throws, an async function called without await, a Promise.all where one member rejects. The unhandledrejection event is your safety net, but it is a last resort, not a strategy. Errors should be caught at the call site wherever the async boundary is meaningful, especially inside loops where a single rejection can silently abort an iteration; the patterns are detailed in best practices for try/catch in async loops. A particular hazard is forEach with an async callback: the array method does not await the returned promises, so a rejection in any iteration becomes unhandled and the loop “finishes” before the work does. Replace it with a for...of loop that awaits each iteration, or with Promise.allSettled when you genuinely want every item to run and need to inspect each outcome. When you do capture, keep the payload lean — attaching deep object graphs or full request bodies to every error inflates ingestion cost and risks leaking data, a trap examined in how to log custom error properties without blooming payloads.

One more property defines a robust interception layer: it must normalize whatever it receives into a real Error. The error event’s event.error can be undefined for cross-origin failures, and unhandledrejection’s event.reason is frequently a string, a number, or a plain object — anything the code passed to reject(). Downstream grouping, symbolication, and display all assume a name, message, and stack. A small normalization function that wraps non-Error values in a synthetic Error (preserving the original value as context) keeps the rest of the pipeline from special-casing malformed input at every stage.

A subtle ordering hazard appears when global handlers themselves run asynchronously. If your error handler awaits a network flush and another error fires before it resolves, you can interleave state or double-report. Treat the interception layer as synchronous-first: capture the structured payload immediately, enqueue it, and flush on a separate cadence. Race conditions between concurrent async handlers are common enough to warrant the dedicated treatment in debugging race conditions in async error handlers.

Framework-Specific Isolation

Global interception catches errors that escape your application; framework boundaries catch errors inside it and contain the blast radius. Without isolation, a single component that throws during render takes down the entire UI tree — React, since version 16, unmounts the whole tree when a render error is uncaught, leaving a blank page. Boundaries convert that catastrophe into a localized fallback: the failing subtree renders an error state while the rest of the app stays interactive.

In React, the unit of isolation is the error boundary — a class component implementing getDerivedStateFromError (to flip into a fallback render) and componentDidCatch (to report the error and its component stack). The complete production pattern, including where to place boundaries in the tree and how to scope them per route or per widget, lives in implementing React error boundaries for production:

class Boundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error, info) {
    // info.componentStack tells you which subtree failed — keep it on the payload.
    telemetry.capture(error, { componentStack: info.componentStack });
  }
  render() { return this.state.hasError ? <Fallback /> : this.props.children; }
}

Two refinements matter in practice. A boundary should degrade gracefully rather than hide content the user still needs — render a retry affordance or a reduced view, as described in how to gracefully degrade UI on component failure. And a boundary that has tripped stays tripped until its state resets; if the user navigates to a new route the stale fallback can persist, so you reset boundary state on route change, covered in resetting React error boundaries on route change. One hard limitation: React error boundaries only catch errors thrown during render, lifecycle, and constructors — not inside event handlers, async callbacks, or setTimeout. Those still need the global interception layer or a local try/catch.

Vue 3 takes a different shape. Application-wide errors route through app.config.errorHandler, while component-scoped capture uses the onErrorCaptured lifecycle hook, which can stop propagation by returning false. The full strategy for fallback rendering and where each hook fits is in Vue 3 error capturing and fallback strategies. Two Vue-specific wrinkles recur often enough to have their own deep dives: errors thrown inside an async setup() in the Composition API are not caught by errorCaptured the way synchronous ones are — see handling async errors in Vue 3 Composition API setup — and render-time failures are best contained with the errorCaptured hook itself, walked through in catching render errors with the errorCaptured hook.

On the server, the “boundary” is the process itself. Node distinguishes a synchronous uncaughtException (the event loop hit a throw with no try/catch) from an unhandledRejection (a promise rejected with no handler). The correct response to uncaughtException is almost always to log, flush telemetry, and exit — the process is in an undefined state and must not keep serving requests. The full decision matrix and the reasons Node 15+ crashes on unhandled rejections by default are covered in the uncaughtException versus unhandledRejection deep dive:

let shuttingDown = false;
process.on('uncaughtException', (err) => {
  if (shuttingDown) return;          // guard against re-entry while draining
  shuttingDown = true;
  logger.fatal(err);
  server.close(() => process.exit(1));
  setTimeout(() => process.exit(1), 5000).unref(); // hard cap if close() hangs
});

Exiting cleanly under load is its own problem — you want to drain in-flight HTTP and WebSocket connections rather than dropping them mid-response, the subject of implementing graceful shutdown on uncaughtException. The reason uncaughtException is fatal and a render error is not comes down to scope: a crashed component invalidates a subtree, but a thrown exception that unwound the entire call stack of a single-threaded event loop can leave shared state — open transactions, half-written buffers, module-level singletons — in an inconsistent condition that the next request would silently inherit. Restarting is cheaper and safer than reasoning about every such corruption. The standard production posture is therefore a process manager or orchestrator (PM2, systemd, Kubernetes) that restarts the worker, paired with a handler that does nothing but flush and exit. Async work inside that handler is the classic mistake: the event loop is already unwinding, and an await there can lose the race against process.exit, dropping the telemetry for the very crash you most need to see.

Cutting across all three runtimes is the question of what you throw. Typed, structured errors make grouping and handling deterministic; a bag of bare Error objects forces string-matching downstream. Defining custom error classes with stable name properties, discriminated code fields, and preserved prototype chains is the foundation, covered in TypeScript typed errors and custom error classes. Two recurring snags live underneath it: a catch clause binding is typed unknown in modern TypeScript and must be narrowed before use — see narrowing unknown in TypeScript catch clauses — and a naïve class AppError extends Error can lose its stack trace and instanceof behavior when transpiled, which preserving stack traces in custom error subclasses shows how to fix.

Finally, none of these boundaries are useful in isolation — they all need to report to a common pipeline. Wiring React, Vue, and Node boundaries into a single backend, with consistent context and release tags, is the role of an observability SDK; the integration patterns are in integrating observability SDKs (Sentry, Datadog, OpenTelemetry).

Source Map Architecture & Stack Trace Resolution

Every layer above produces a stack trace, and in production every one of those traces points into minified, bundled code: main.4f2a.js:1:88213. That frame is worthless to a human. Symbolication is the process of mapping it back to src/checkout/PaymentForm.tsx:142:8 using a source map — a JSON artifact, conforming to the Source Map v3 spec, that records the position translation between generated and original code. An error pipeline without working symbolication produces alerts nobody can act on; this is the single most common reason teams “have error tracking” but still cannot debug production.

Source map generation, hosting, and the stack-trace mechanics are deep enough to warrant their own reference, the source map generation and stack-trace debugging pillar, which covers bundler configuration, the v3 format, cross-browser frame normalization, and secure hosting. From the error-handling side, three architectural decisions matter most.

First, where the map lives. You almost never want to ship //# sourceMappingURL pointing at a public .map file — that hands your original source to anyone who opens DevTools. Production builds should emit hidden source maps (generated but not referenced from the bundle) and upload them privately to the symbolication backend keyed by release. The maps are resolved server-side at ingestion, never by the browser.

Second, fidelity. A map is only as accurate as the build that produced it. Transpilation chains (TypeScript → Babel → minifier) each rewrite positions, and a misconfigured stage produces maps that are off by a few lines or point at the wrong file entirely. The browser also varies: V8, SpiderMonkey, and WebKit format stack traces differently, so a symbolicator must normalize frames before applying the map. These cross-engine and build-fidelity concerns are handled across the source map generation and stack trace debugging guides.

Third, correlation. The map for main.4f2a.js is only the right map if it came from the exact same build. Symbolication therefore depends on a stable release identifier — a commit SHA or build ID — attached both to the uploaded map and to every error payload. Mismatch the two and you symbolicate against the wrong source, producing confidently incorrect line numbers. This is the hinge between this section and the CI/CD layer below.

It helps to understand what the map actually encodes, because that explains most failure modes. A v3 source map is a JSON document whose mappings field is a string of Base64 VLQ-encoded segments; each segment is a delta describing a generated-column position and the original file, line, and column it corresponds to. Because the encoding is purely positional, any transform that shifts character positions after the map is generated — a post-minifier that mangles names, a CDN that rewrites the bundle, a stray transpilation pass — invalidates the offsets without producing any error. The map still parses; it just points at the wrong place. This is why fidelity is verified empirically, by symbolicating a known error and checking the resolved frame, rather than assumed from a successful build. The browser dimension compounds it: V8 emits frames as at fn (file:line:col), while WebKit and SpiderMonkey use fn@file:line:col and omit the leading at, so the symbolicator must parse each engine’s format into a common shape before it can even look up a frame in the map. Anonymous and eval-generated frames have no stable file reference at all and frequently need a heuristic fallback.

# Upload hidden maps to the backend, keyed by the same release the SDK reports.
sentry-cli sourcemaps upload --release "$GIT_SHA" ./dist/assets/

Observability SDK Setup & Privacy Controls

The SDK is where every captured payload converges, gets enriched, gets scrubbed, and gets shipped. It is also the most dangerous layer for compliance: by default an error tracker will happily transmit a stack trace containing a user’s email in a URL, a session token in a header, or a credit-card number in a form value. Privacy control is not a downstream cleanup task — it must happen at the SDK boundary, in the beforeSend hook, before the payload ever leaves the process. The patterns for Sentry, Datadog RUM, and OpenTelemetry web each expose a redaction hook of this kind, and choosing where to install it is the first decision of any SDK integration.

Three responsibilities define a well-configured SDK. Redaction strips PII from messages, breadcrumbs, request data, and stack frames. A scrubbing pass that misses one field is a breach; the specifics of doing this for Datadog RUM, where the payload shape differs from the browser console, are in scrubbing PII from Datadog RUM error payloads.

Sentry.init({
  release: import.meta.env.VITE_GIT_SHA, // ties every event to one build/map
  beforeSend(event) {
    // Redact at the boundary — never trust downstream to scrub.
    if (event.request?.url) event.request.url = event.request.url.replace(/(token|email)=[^&]+/gi, '$1=[redacted]');
    return event;
  },
});

Sampling controls cost and noise. At scale you cannot afford to ingest 100% of every event, but you also cannot blindly drop critical failures. A tiered strategy — full capture for crashes, fractional for high-frequency warnings — keeps the signal while bounding spend; tuning the Sentry browser SDK’s rates is covered in configuring error sampling rates in Sentry browser SDK. Correlation ties an error to the wider request lifecycle by propagating W3C Trace Context, so a frontend exception links to the exact backend span that served it. Instrumenting that linkage with vendor-neutral OpenTelemetry is the subject of instrumenting browser errors with OpenTelemetry web.

A fourth, quieter responsibility is grouping. The SDK collapses many occurrences of the same defect into one issue using a fingerprint derived from the normalized stack and error type — which is precisely why the typed-error discipline from the previous section pays off: stable error names and codes produce stable fingerprints, while ad-hoc string messages fragment one bug into dozens of issues. The opposite failure is over-grouping, where a generic wrapper error (everything thrown as AppError: request failed) collapses unrelated defects into one issue that nobody can triage. The balance is a fingerprint specific enough to separate distinct root causes but stable enough to survive cosmetic message changes — most SDKs let you supply an explicit fingerprint array to override the default heuristic when the automatic grouping misjudges a particular error class.

A practical ordering concern ties the SDK back to the interception layer: the SDK’s beforeSend (or its equivalent) runs synchronously in the path of the captured event, so heavy work there — synchronous JSON traversal over a large payload, expensive regex over every stack frame — adds latency to error handling exactly when the application is already in a degraded state. Keep the scrub deterministic and bounded, scrub the fields you know carry user data rather than walking the entire object graph, and let the backend handle anything that genuinely needs deep inspection. The SDK boundary is for guaranteed redaction of known-sensitive fields, not for exhaustive discovery.

CI/CD Integration & Release Correlation

The final layer closes the loop between the code you deployed and the errors it produces. Every preceding layer assumes a release identifier exists and is consistent; the CI/CD layer is what guarantees that assumption. Three things must happen on every deploy, automatically, with the build failing if any of them is missing.

Tag the release. Stamp the same commit SHA or build ID into the bundle (as a build-time constant the SDK reads), into the uploaded source maps, and into the deployment record. This single identifier is the join key across symbolication, grouping, and dashboards. If the SDK reports release: abc123 and the maps were uploaded under release: abc124, symbolication silently fails. The most reliable source for that identifier is the CI environment itself — $GITHUB_SHA, $CI_COMMIT_SHA, or the equivalent — injected as a build-time define so the value is frozen into the bundle and cannot drift from the maps generated in the same job. Avoid deriving it at runtime (for example, reading a git command on the server), because the running process and the build artifact may not agree.

Upload and verify maps. The build must push hidden source maps to the backend and then confirm they arrived and resolve — not assume it. The end-to-end GitHub Actions flow for this lives in the source-map reference’s CI/CD upload-and-validation work, including how to fail the build when maps are absent so you never ship a release that produces unreadable traces.

# A deploy step that ties the SDK release to the uploaded maps.
- name: Upload source maps
  run: sentry-cli sourcemaps upload --release "${{ github.sha }}" ./dist
- name: Verify symbolication
  run: sentry-cli releases finalize "${{ github.sha }}" # fails if maps missing

Validate the pipeline before promoting. Inject a synthetic error in staging and assert that it arrives at the backend, fully symbolicated, tagged with the right release, and routed to the correct alert. This converts “we think error tracking works” into a deterministic gate. A release that cannot symbolicate its own canary error does not get promoted.

Release correlation also unlocks regression detection, which is the highest-leverage signal an error pipeline produces. Because every event carries a release, the backend can attribute a sudden spike in a given issue to the exact deploy that introduced it and surface “new in this release” versus “regressed from a prior release” automatically. That turns the deploy itself into the prime suspect — instead of bisecting commits by hand, you read the first release in which an issue appears and look at that diff. It is also what makes safe rollouts measurable: gate a canary or progressive rollout on the symbolicated error rate for the new release, and roll back automatically when a boundary-captured crash class crosses a threshold.

With these layers in place, an alert becomes actionable end to end: a user hits a bug, the boundary captures it with component context, the SDK scrubs and ships it under release abc123, the backend symbolicates it against the matching maps, and the issue links directly to the commit range that introduced the regression. That is the entire pipeline working as one system — and the measurable payoff is a sharp drop in MTTR.

Common Mistakes

Issue Impact
Installing global handlers after framework bootstrap Errors thrown during bundle evaluation or early hydration are never captured, leaving a silent blank-page failure class.
Handling error but not unhandledrejection The entire async failure path — rejected fetches, un-awaited promises — bypasses telemetry, hiding the most common production errors.
Shipping public source maps via sourceMappingURL Original source is exposed to anyone with DevTools; use hidden maps uploaded privately to the backend instead.
Release tag on the SDK differs from the uploaded maps Symbolication resolves against the wrong build, producing confidently incorrect line numbers that mislead debugging.
Redacting PII downstream instead of in beforeSend Sensitive data has already crossed the network boundary to a third party, which is a compliance breach regardless of later cleanup.
Running async work inside an uncaughtException handler The process can exit before the flush completes, dropping the very telemetry for the fatal error you most need.
Relying on React error boundaries for event-handler errors Boundaries only catch render/lifecycle errors; handler and async failures escape to the global layer or vanish.
Ad-hoc string error messages instead of typed error classes Identical defects fragment into many issues because the grouping fingerprint changes with every message variation.

FAQ

Should I use window.onerror or addEventListener('error')? Use addEventListener('error', handler, true) as the primary handler — the capture phase also catches non-bubbling resource failures like broken images and failed script loads. Reserve window.onerror as a legacy synchronous fallback. Whichever you choose, pair it with a separate unhandledrejection listener, because neither one observes rejected promises.

Why do my production stack traces only show minified line numbers? The traces point into bundled code and have not been symbolicated. You need to generate source maps at build time, upload them privately to your observability backend keyed by the release ID, and ensure the SDK reports that same release so the backend can map frames back to original source.

How do I correlate a frontend error with the backend request that caused it? Propagate W3C Trace Context (traceparent) across your network calls and attach the trace ID to the error payload in the SDK. The backend span and the frontend error then share an identifier, letting your observability tool link them in a single distributed trace.

What is the right error sampling strategy at high traffic? Tier it by severity. Capture 100% of crashes and unhandled exceptions, and sample high-frequency, low-severity events (such as recurring warnings) at a small fraction. This preserves the signal you must not lose while keeping ingestion cost and noise bounded.

Why doesn’t my React error boundary catch errors from a button click? React boundaries only catch errors thrown during rendering, lifecycle methods, and constructors. Errors in event handlers, setTimeout, and async callbacks run outside that flow, so wrap them in a local try/catch or let them surface to the global interception layer.

How do I stop source maps from leaking my source code? Configure the build to emit hidden source maps — generated but not referenced by any sourceMappingURL comment in the shipped bundle. Upload them to your error backend privately during CI so they exist only where symbolication happens, never on the public CDN.