DEV Community

evanshepherd5623
evanshepherd5623

Posted on

React frontend errors: backend controls for stack, release, environment, and PII

Short answer: treat window.onerror and unhandledrejection as two browser inputs to a small backend collector, then normalize the stack, attach release and environment, and remove PII before storage or alerting. That split keeps the React frontend cheap to change while giving the backend one stable event shape.

The important trade-off is fidelity versus privacy. A raw browser error can contain a URL, a user-entered string, or a stack frame with query parameters. A heavily scrubbed event is safer, but harder to debug. The collector is where that decision belongs.

A field guide to the collection choices

Choice Pick this when The catch
Browser listeners plus your own collector You need a small React frontend footprint and control over retention You must build grouping, rate limits, and an operator view
A hosted error-tracking service You need dashboards, release workflow, and alert routing immediately Data residency, contract review, and pricing become part of the architecture
An OpenTelemetry-based pipeline Logs, metrics, and traces already share a collector and resource model Browser exception details need a deliberate mapping; a metrics backend alone is not an error inbox

There is no universal winner.

Start with ownership.

The first option is a good fit for a team that can own a thin ingestion endpoint. The hosted option fits a team that values a ready-made workflow more than control of every byte. The OpenTelemetry route fits organizations standardizing signals across services; it is a poor fit if the only requirement is a searchable exception list and nobody owns the collector.

How should a React frontend send stack, release, environment, and privacy fields?

Start with a contract, not a dashboard.

Do not log blindly. Each event needs a stable name, a timestamp, a release identifier, an environment, and a bounded error payload. window.onerror receives message, source, line, column, and an Error object in modern browsers. The unhandledrejection event carries a reason that may be an Error, a string, or an arbitrary object. Normalize both into the same record.

Here is a deliberately boring TypeScript client. It redacts obvious email-shaped text and query strings, caps the stack, and sends JSON to an endpoint owned by your application. It uses sendBeacon during page teardown and fetch otherwise. The endpoint should authenticate the session or use a narrowly scoped ingestion token; never put a privileged API key in the bundle.

type BrowserErrorEvent = {
  type: "browser.error" | "browser.unhandledrejection";
  message: string;
  stack?: string;
  source?: string;
  line?: number;
  column?: number;
  release: string;
  environment: string;
  occurredAt: string;
};

const collectorUrl = "/telemetry/browser-errors";
const release = "web-2026.08.06";
const environment = "production";

function scrub(value: unknown): string {
  const text = typeof value === "string" ? value : JSON.stringify(value);
  return (text || "Unknown browser failure")
    .replace(/[?&][^\s#]+/g, "?redacted")
    .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
    .slice(0, 4000);
}

function post(event: BrowserErrorEvent): void {
  const body = JSON.stringify(event);
  if (navigator.sendBeacon && body.length < 64000) {
    navigator.sendBeacon(collectorUrl, new Blob([body], { type: "application/json" }));
    return;
  }
  void fetch(collectorUrl, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body,
    keepalive: true
  }).catch(() => undefined);
}

window.addEventListener("error", (event) => {
  post({
    type: "browser.error",
    message: scrub(event.error?.message ?? event.message),
    stack: scrub(event.error?.stack),
    source: scrub(event.filename),
    line: event.lineno || undefined,
    column: event.colno || undefined,
    release,
    environment,
    occurredAt: new Date().toISOString()
  });
});

window.addEventListener("unhandledrejection", (event) => {
  const reason = event.reason instanceof Error ? event.reason : event.reason;
  post({
    type: "browser.unhandledrejection",
    message: scrub(reason instanceof Error ? reason.message : reason),
    stack: scrub(reason instanceof Error ? reason.stack : undefined),
    release,
    environment,
    occurredAt: new Date().toISOString()
  });
});
Enter fullscreen mode Exit fullscreen mode

The error listener also sees resource-loading failures, where event.error can be absent. That is why the fallback to event.message matters. Promise rejections are even less predictable: libraries sometimes reject with a plain object. Serializing and truncating at the edge prevents a single oversized value from turning telemetry into a second incident. A 413 from the collector should be observable in the collector's own logs, but it should never break the page.

What belongs in the backend collector?

The collector should be a narrow boundary: validate, scrub again, enrich, sample, and enqueue. Client-side filtering is useful for volume, not trust. A server-side pass catches a new field added by a frontend release, and it gives the privacy review one place to inspect. Keep the ingestion response boring: a quick 202-style acknowledgement is enough; the browser does not need a rendered error page. Keep it boring.

I've found one rule holds across teams: the collector should accept a deliberately small schema and reject unknown high-risk fields. That makes a new frontend field a review event instead of a silent privacy change. The longer path is intentional—validate the content type, enforce a maximum body size, parse JSON with a timeout, normalize the two event types, and only then enqueue. If parsing fails, return a generic client-safe status and record a counter without copying the malformed body into logs. When the queue is unavailable, shed load at the boundary and preserve the page experience; losing one sampled exception is preferable to blocking a user action.

The response can stay boring. A quick 202-style acknowledgement is enough; the browser does not need a rendered error page.

Store a fingerprint derived from normalized message plus the top useful stack frames. Keep the original stack only as long as your retention policy permits, and separate it from user-facing metadata. Release and environment turn a pile of exceptions into a deploy comparison. They also let an alert say “new in production” instead of paging on a known development failure.

Metrics provide the other half of the picture. Count accepted events, rejected events, sampled events, and queue lag. OpenTelemetry describes metrics as a signal for measuring a service over time; use that signal to watch the collector itself, while the event records carry the debugging detail. Logs from the collector should include a request or trace identifier, never the raw event body by default.

A test plan that catches quiet failures

Test the browser contract with four fixtures: a thrown Error, an Error with a query string in its stack, a rejected string, and a rejected object containing an email-shaped value. Assert that release and environment are present, the two event types stay distinct, and PII is absent after both client and server scrubbing.

Then test delivery conditions. Simulate offline mode, page teardown, a payload above the 64 KB beacon limit, a 429 response, and a collector timeout. Retry only bounded, idempotent writes; an error listener must not create a retry storm. I'm not sure which browser mix your users have, so test the browsers you actually support instead of assuming sendBeacon behaves identically everywhere.

Alert on rates and novelty, not every event. A practical starting point is a rate threshold per release and environment, with a separate alert for collector rejection or queue lag. The team should be able to answer three questions from one event: what failed, which release introduced it, and whether the payload was safe to retain.

Limits and decisions to revisit

This pattern does not capture failures that never reach the browser runtime, such as a blocked script before listeners install. It also cannot reconstruct a minified stack without source maps, and source maps themselves require access controls. For regulated data, replace heuristic redaction with an allow-list schema and a documented retention window.

The catch is ownership — and it arrives sooner than most teams expect. A custom collector is not suitable when nobody can patch its validation path, rotate its token, or operate its queue; choose a managed workflow in that case. Stick with a shared OpenTelemetry pipeline when cross-service correlation is the priority. Whichever route you choose, keep the browser contract small and make privacy a testable property, not a promise in a README.

References

Top comments (0)