A factory assembly line shipping code, one piece cracked and glowing red, caught by a butterfly net before falling into production

Catch it first.

You shipped a bug. You found out from a support ticket because your tooling didn't catch it. Logs only cover the errors you anticipated. Error monitoring covers the rest: the unhandled promise rejection at 3am, the TypeError in a code path nobody tested.

So you build a capture layer. It hooks into the runtime, routes everything to a service that aggregates, deduplicates, and alerts. Your application code doesn't change.

The Abstraction

Start with one function. Every error in your app goes through it. Swap the backend later without touching call sites.

errors.ts
export function logError(
  error: unknown,
  context: Record<string, unknown> = {}
) {
  const err = error instanceof Error ? error : new Error(String(error));
  console.error("[error]", err.message, context);
  // In production: Sentry.captureException(), your own endpoint, etc.
}

Console output during development, production reporting when you wire up a backend. The context object carries whatever state you need to reproduce the issue.

Explicit Error Handling

Use logError in try/catch blocks where you anticipate failures. The context object carries the state you need to reproduce the issue.

payment-service.ts
import { logError } from "./errors";

async function chargeCard(orderId: string, amount: number) {
  try {
    const result = await stripe.charges.create({ amount });
    return result;
  } catch (error) {
    logError(error, { orderId, amount, operation: "chargeCard" });
    throw new PaymentError("Card charge failed", { cause: error });
  }
}

Report first, then re-throw if the caller needs to handle it. Or recover gracefully if you can. Either way, the error is captured.

DONTSwallow errors silently. A catch block with no reporting hides the problem. If you catch it, report it.

Error Boundaries (React)

React won't catch render errors with a global handler. Without an error boundary, one broken component takes down the entire tree.

ErrorBoundary.tsx
import React from "react";
import { logError } from "./errors";

interface Props {
  children: React.ReactNode;
  fallback: React.ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    logError(error, { componentStack: info.componentStack });
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Same logError call. The boundary catches it, your abstraction routes it.

Global Error Handlers

Global handlers catch the errors you didn't anticipate. They install once at startup and route everything through your abstraction.

browser-handlers.ts
import { logError } from "./errors";

window.addEventListener("error", (event) => {
  logError(event.error, {
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
  });
});

window.addEventListener("unhandledrejection", (event) => {
  logError(event.reason, { type: "unhandledrejection" });
});
node-handlers.ts
import { logError } from "./errors";

process.on("uncaughtException", (error) => {
  logError(error, { type: "uncaughtException" });
  process.exit(1); // Let it crash — restart cleanly
});

process.on("unhandledRejection", (reason) => {
  logError(reason, { type: "unhandledRejection" });
});

Catch at specific boundaries and let the rest bubble up to global handlers.

DONTWrap your entire app in one try/catch. You lose context about where the error originated.

Wire Up a Backend

Once your capture layer exists, pointing it at a real service is just configuration. Sentry gives you stack traces with source maps, intelligent grouping, release tracking, and alerts out of the box. It also handles the global handlers and error boundaries for you, so you can skip that boilerplate entirely.

errors.ts
import * as Sentry from "@sentry/node";

Sentry.init({
  dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
  environment: process.env.NODE_ENV,
});

export function logError(
  error: unknown,
  context: Record<string, unknown> = {}
) {
  const err = error instanceof Error ? error : new Error(String(error));
  Sentry.captureException(err, { extra: context });
}

Minified stack traces are useless. a.js:1:34523 tells you nothing. checkout.ts:142 in processPayment tells you where to look.

DOUpload source maps to your error tracking service.