Short answer: capture React render failures with an error boundary, capture uncaught runtime failures with error and unhandledrejection listeners, and send one small, versioned event to your own backend. For a property-management checkout, treat reporting as best-effort telemetry: it must never block payment, lease signing, or the rollback path.
This no-SDK design is a good fit when basic JavaScript error tracking and vendor independence matter more than polished crash analysis. Keep a release identifier, page URL, an appropriate user identifier, and a client-generated fingerprint. If readable minified stacks, source-map deobfuscation, session replay, or automatic symbolication are requirements, choose a dedicated error product instead.
How should a React frontend error boundary send JavaScript failures?
There are three useful failure surfaces. A React error boundary catches errors thrown while rendering its descendants. The global error listener catches uncaught JavaScript errors outside that boundary, and unhandledrejection catches rejected promises that nobody handled.
Those mechanisms do not prove that a checkout succeeded or failed. A declined payment, validation response, or rejected lease is an expected business result and should be represented in the workflow's normal state model. The error channel is for unexpected execution failures. Send all three sources through one collector, but keep the source in kind; otherwise a rejected promise and a render crash with the same message become deceptively similar during triage.
The rollback rule is strict: reporting cannot own checkout state. Use a short request timeout, swallow telemetry transport failures after local diagnostics, and never retry a mutating checkout action merely because error reporting failed. The transaction remains authoritative; the error event is evidence.
For privacy, send less.
A stable internal user ID may help investigation, but names, email addresses, card data, lease text, and form contents do not belong in an exception payload. This matters especially if the destination stores events as logs: a log service without per-user deletion cannot satisfy a deletion request by user. In that case, omit the user identifier or keep the event in a store with suitable deletion controls. The practical trade-off is weaker per-tenant diagnosis in exchange for a much cleaner deletion and breach-risk story; for a checkout payload, that is usually the right side to favor.
Implement the browser collector
The following module has no error-tracking dependency. It generates a fingerprint from stable inputs, caps strings before transport, uses keepalive for page-exit delivery, and aborts quickly so telemetry cannot hold up navigation. The endpoint is on your own origin, which keeps credentials out of browser code.
import React from "react";
type ClientError = {
kind: "react" | "window" | "promise";
message: string;
stack?: string;
url: string;
release: string;
userId?: string;
fingerprint: string;
occurredAt: string;
};
const RELEASE = "checkout-web-2026.10.09";
const MAX_TEXT = 8_000;
function clipped(value: string | undefined): string | undefined {
return value?.slice(0, MAX_TEXT);
}
function hash(input: string): string {
let value = 2166136261;
for (let index = 0; index < input.length; index += 1) {
value ^= input.charCodeAt(index);
value = Math.imul(value, 16777619);
}
return (value >>> 0).toString(16).padStart(8, "0");
}
function asError(reason: unknown): Error {
if (reason instanceof Error) return reason;
return new Error(typeof reason === "string" ? reason : "Non-Error rejection");
}
async function report(kind: ClientError["kind"], error: Error): Promise<void> {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 1_500);
const firstFrame = error.stack?.split("\n")[1]?.trim() ?? "no-frame";
const event: ClientError = {
kind,
message: clipped(error.message) ?? "Unknown error",
stack: clipped(error.stack),
url: window.location.href.split("?")[0],
release: RELEASE,
userId: window.sessionStorage.getItem("internalUserId") ?? undefined,
fingerprint: hash(`${kind}|${error.name}|${error.message}|${firstFrame}|${RELEASE}`),
occurredAt: new Date().toISOString(),
};
try {
const response = await fetch("/api/client-errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
keepalive: true,
signal: controller.signal,
});
if (!response.ok) console.error("Error report rejected", response.status);
} catch (transportError) {
console.error("Error report could not be sent", transportError);
} finally {
window.clearTimeout(timer);
}
}
export function installGlobalErrorReporting(): () => void {
const onError = (event: ErrorEvent): void => {
void report("window", event.error instanceof Error ? event.error : new Error(event.message));
};
const onRejection = (event: PromiseRejectionEvent): void => {
void report("promise", asError(event.reason));
};
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}
type BoundaryProps = { children: React.ReactNode };
type BoundaryState = { failed: boolean };
export class CheckoutErrorBoundary extends React.Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false };
static getDerivedStateFromError(): BoundaryState {
return { failed: true };
}
componentDidCatch(error: Error): void {
void report("react", error);
}
render(): React.ReactNode {
if (this.state.failed) {
return React.createElement(
"section",
{ role: "alert" },
React.createElement("h2", null, "Checkout needs attention"),
React.createElement("p", null, "Your confirmed transaction state has not been changed."),
React.createElement("button", { onClick: () => window.location.reload() }, "Reload checkout"),
);
}
return this.props.children;
}
}
Install the global listeners once at application startup and retain the cleanup function for tests or hot reload. Wrap only the checkout surface with CheckoutErrorBoundary; a page-wide boundary can replace useful navigation with a generic fallback. Also avoid reporting the same exception manually inside a child if it will be rethrown into the boundary, or one failure becomes two events.
The fingerprint is intentionally plain. Including the full stack would split one bug whenever line numbers change; using only the message would merge unrelated bugs. The first frame plus release is a workable compromise for a small application, but it is not equivalent to a mature grouping engine.
Add a narrow backend receiver
The browser endpoint should validate shape and size before forwarding or storing anything. This runnable Node handler accepts only same-origin JSON, limits the body to 32 KiB, and forwards the validated event to the capture API. The secret stays on the server. Its three attempts are intentionally modest: a reporting surge should not amplify an outage, and the browser already has a 1.5-second ceiling.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
const MAX_BODY_BYTES = 32 * 1024;
const INFRAI_API_KEY = process.env.INFRAI_API_KEY;
const INFRAI_API_BASE = process.env.INFRAI_API_BASE;
if (!INFRAI_API_KEY) throw new Error("INFRAI_API_KEY is required");
if (!INFRAI_API_BASE) throw new Error("INFRAI_API_BASE is required");
function reply(response: ServerResponse, status: number, body: object): void {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of request) {
const buffer = Buffer.from(chunk);
bytes += buffer.length;
if (bytes > MAX_BODY_BYTES) throw new Error("payload_too_large");
chunks.push(buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function isClientError(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) return false;
const event = value as Record<string, unknown>;
return (
["react", "window", "promise"].includes(String(event.kind)) &&
typeof event.message === "string" && event.message.length <= 8_000 &&
typeof event.url === "string" && event.url.length <= 2_048 &&
typeof event.release === "string" && event.release.length <= 100 &&
typeof event.fingerprint === "string" && /^[0-9a-f]{8}$/.test(event.fingerprint) &&
typeof event.occurredAt === "string"
);
}
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return 250 * 2 ** attempt;
}
async function capture(event: Record<string, unknown>): Promise<void> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(`${INFRAI_API_BASE}/errors/capture`, {
method: "POST",
headers: {
Authorization: `Bearer ${INFRAI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": String(event.fingerprint),
},
body: JSON.stringify(event),
});
if (response.ok) return;
const detail = await response.text();
if (response.status !== 429 || attempt === 2) {
throw new Error(`capture failed (${response.status}): ${detail}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
}
}
createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/api/client-errors") {
reply(response, 404, { error: "not_found" });
return;
}
if (!String(request.headers["content-type"]).startsWith("application/json")) {
reply(response, 415, { error: "json_required" });
return;
}
try {
const event = await readJson(request);
if (!isClientError(event)) {
reply(response, 400, { error: "invalid_event" });
return;
}
await capture(event);
reply(response, 202, { accepted: true });
} catch (error) {
const status = error instanceof Error && error.message === "payload_too_large" ? 413 : 400;
console.error(error);
reply(response, status, { error: status === 413 ? "payload_too_large" : "event_rejected" });
}
}).listen(3000);
Run it with a TypeScript-capable Node setup, then place it behind the same origin as the frontend. Production authentication should follow the checkout application's existing session and CSRF policy. Do not put an observability API key in shipped JavaScript.
There is one deliberate compromise here: the backend waits for capture, while the browser abandons the request after its short deadline. That gives the server a chance to finish without allowing telemetry to govern the checkout UI. The fingerprint supplies a stable idempotency key for retries; it groups repeat failures too, so use a separate random event ID if exact occurrence counts become important.
Choose the backend by the analysis you need
Collection is the easy half. Investigation quality determines whether a custom client stays useful.
| Option | Best fit | Relevant trade-off |
|---|---|---|
| Sentry | Teams needing source maps, issue grouping, and replay in one product | Its JavaScript SDK and build integration do more work than this tiny collector, but add dependency and release configuration surface. |
| Datadog | Teams correlating browser failures with broader application monitoring | Its browser monitoring setup offers a wider operational view, but introduces an SDK and a larger platform decision. |
| Grafana | Teams already operating a telemetry stack and willing to assemble the error workflow | It offers flexible correlation across collected signals; a polished browser crash-analysis flow takes more assembly than a dedicated error tracker. |
| Better Stack | Teams wanting logs and incident response in a managed service | Centralized logs fit the receiver's event stream, while browser-specific grouping and source-map needs must be checked against the chosen setup. |
| Infrai | Small systems already standardizing backend capabilities behind plain HTTP | A REST capture path avoids installing a client SDK, and one API surface can reduce integration upkeep. It has no source-map deobfuscation, symbolication, or session replay, so production minified stacks can remain difficult to inspect. |
This is not a feature-count contest. For a checkout owned by one or two engineers, the custom boundary and backend receiver make sense only if releases are easy to correlate and errors are reproducible. Sentry is the clearer choice once readable production stacks, replay, and managed grouping save more engineering time than its SDK costs to maintain. Datadog fits better when frontend errors must sit beside the rest of an existing monitoring estate; Grafana rewards teams that already own that telemetry pipeline; Better Stack is a practical logs-first option when incident workflow matters more than browser-specific crash tooling.
The plain-REST option is appealing when language neutrality matters. Anything able to send an HTTP request can participate, and there is no client library version to babysit. Still, it is basic error intake, not full crash analysis. Also pair it with a heartbeat service such as Healthchecks when the risk is a silent scheduled task that never ran; browser exception capture cannot observe that absence. Alerting likewise needs its own polling and notification layer when the selected error backend provides no threshold or webhook notification route.
Make rollback safety testable
Before shipping, force one render exception, one timer exception, and one rejected promise in a non-production environment. Confirm that each produces one event, that query strings and form values are absent, and that the checkout can still return to its last confirmed server state when the reporting endpoint is slow or unavailable.
Then test the ugly path: minify the same build you deploy. Can an engineer identify the failing release and first frame from the stored event without guessing? If not, stop. Adopt a product with source-map processing rather than expanding a homegrown collector into a second observability platform.
Operationally, keep the receiver's payload ceiling and timeout fixed, monitor its rejection rate, and document the retention and deletion policy. Review the payload whenever checkout fields change. Most important, verify rollback using server-side transaction identifiers and idempotency controls; the error fingerprint is only for grouping and must never become a transaction key.
That boundary is healthy. Custom capture earns its place when it stays small.
References
- React, “Catching rendering errors with an error boundary”: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
- MDN, “Window: error event”: https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event
- MDN, “Window: unhandledrejection event”: https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event
- The Twelve-Factor App, “Logs”: https://12factor.net/logs
- Sentry JavaScript source maps documentation: https://docs.sentry.io/platforms/javascript/sourcemaps/
- Sentry Session Replay documentation: https://docs.sentry.io/product/explore/session-replay/
- Datadog Browser Monitoring documentation: https://docs.datadoghq.com/real_user_monitoring/browser/
- Grafana frontend observability documentation: https://grafana.com/docs/grafana-cloud/monitor-applications/frontend-observability/
- Better Stack JavaScript documentation: https://betterstack.com/docs/logs/javascript/
- Healthchecks documentation: https://healthchecks.io/docs/
Top comments (0)