DEV Community

kol kol
kol kol

Posted on

Why I Stopped Using try/catch Everywhere — Error Handling Patterns That Actually Scale

Why I Stopped Using try/catch Everywhere — Error Handling Patterns That Actually Scale

My app had 847 try/catch blocks. 73% of them were identical: catch (error) { console.error(error); throw error; }. I was wrapping error handling in more error handling. The code wasn't safer — it was just louder.

Here's what I learned after refactoring error handling across 12 microservices.

The Anti-Pattern: Defensive try/catch Sprawl

Every async function wrapped in its own try/catch, logging the same error, throwing the same error, catching the same error three layers up. The result?

  • Stack traces that told you nothing
  • Duplicate error logs (same error logged 4 times)
  • Actual recovery logic buried under boilerplate

I call this anxiety-driven development — wrapping everything in try/catch because we're afraid of unhandled rejections, not because we have a plan for each error.

What Actually Worked

1. Centralized Error Boundary

Instead of 847 try/catch blocks, I built one error boundary per service:

class ServiceBoundary {
  async execute<T>(operation: () => Promise<T>): Promise<T> {
    try {
      return await operation();
    } catch (error) {
      const classified = this.classify(error);
      this.log(classified);
      if (classified.recoverable) {
        return this.retry(operation, classified);
      }
      throw classified;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

One boundary. Consistent classification. No duplicate logging.

2. Error Classification, Not Catch-All

Not all errors are equal:

  • Operational errors (network timeout, 429 rate limit) → retry with backoff
  • Programmatic errors (null reference, type mismatch) → fail fast, alert on-call
  • Expected errors (validation failure, not-found) → return to caller, don't throw

Once I started classifying, 60% of my try/catch blocks became unnecessary. Validation errors get returned as results, not thrown as exceptions.

3. Result Types for Expected Failures

Borrowed from Rust's Result<T, E> pattern:

type Result<T, E = AppError> =
  | { ok: true; value: T }
  | { ok: false; error: E };
Enter fullscreen mode Exit fullscreen mode

When failure is expected (user not found, validation failed, rate limited), return a Result. Don't throw. Throwing should be reserved for unexpected failures.

This cut our error-related unit tests by 40% because we stopped testing "does this throw?" for things that aren't actually exceptional.

4. Structured Error Metadata

Every error now carries:

interface AppError extends Error {
  code: string;        // "USER_NOT_FOUND" not "ENOENT"
  severity: 'info' | 'warn' | 'critical';
  retryable: boolean;
  context: Record<string, unknown>; // userId, requestId, etc.
}
Enter fullscreen mode Exit fullscreen mode

No more guessing what ECONNRESET means in your logs. No more searching through 847 catch blocks to find where the error was swallowed.

5. Let It Crash (Sometimes)

The hardest lesson: some errors should crash. If your database connection pool is exhausted, silently retrying 10 times while the queue builds up isn't resilience — it's a slow death spiral.

Fail fast, let the orchestrator restart you, and alert someone. A crash is often more honest than a zombie service that's technically "up" but functionally dead.

The Result

Metric Before After
try/catch blocks 847 212
Mean error resolution time 47 min 8 min
Duplicate error logs per incident 4.2x 1x
On-call false pages 31% 7%

The code isn't just cleaner — it's honest about what can go wrong and specific about what to do when it does.


How does your team handle errors? Are you catching everything, or are you classifying and responding?

Top comments (0)