DEV Community

Code Atlas
Code Atlas

Posted on

Error Handling Patterns That Actually Scale

Error Handling Patterns That Actually Scale

Every codebase starts with try/catch sprinkled around, and it works until it doesn't. Once you hit a certain size, inconsistent error handling becomes a source of bugs, not a cure. Here are the patterns I've found that hold up in production.

1. Fail Fast vs. Fail Gracefully

Decide early which operations are critical and which are optional. For critical paths (e.g., database connection), fail fast: throw immediately and let the process crash or restart. For non-critical (e.g., sending a notification), fail gracefully: log and continue.

// Fail fast
const db = connectDB(); // throws if unavailable

// Fail gracefully
sendEmail(user.email).catch(err => {
  logger.warn('Email failed', { err, userId: user.id });
});
Enter fullscreen mode Exit fullscreen mode

Mixing these up leads to silent data loss or unnecessary crashes. Write it in your README.

2. The Result Object Pattern

Instead of throwing exceptions for expected failures (validation, not found), return a structured result. This makes control flow explicit and avoids the "throw for everything" trap.

type Result<T> = { ok: true; value: T } | { ok: false; error: Error };

function findUser(id: string): Result<User> {
  const user = db.find(id);
  if (!user) return { ok: false, error: new Error('User not found') };
  return { ok: true, value: user };
}

// Usage
const result = findUser('123');
if (!result.ok) {
  // handle expected miss
} else {
  // use result.value
}
Enter fullscreen mode Exit fullscreen mode

This pattern shines in languages without checked exceptions. It forces callers to handle the error case at compile time (if you're using TypeScript) and makes the happy path obvious.

3. Centralized Error Handling in Async Code

In Node.js, unhandled promise rejections crash processes. Don't rely on process.on('unhandledRejection') as a band-aid; instead, wrap your async entry points.

async function main() {
  try {
    await startServer();
  } catch (err) {
    logger.fatal('Server crashed', { err });
    process.exit(1);
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

For Express, use an error-handling middleware as the single place to format responses and log.

app.use((err, req, res, next) => {
  logger.error('Request failed', { err, path: req.path });
  res.status(err.status || 500).json({ message: err.message });
});
Enter fullscreen mode Exit fullscreen mode

Never log the same error in multiple places; you'll get duplicate logs and lose the original stack.

4. Wrap External Calls

Third-party APIs and SDKs throw their own error types. Wrap them in a domain error so your business logic doesn't depend on library specifics.

class PaymentError(Exception):
    pass

def charge_card(card):
    try:
        stripe.Charge.create(...)
    except stripe.error.CardError as e:
        raise PaymentError(f"Card declined: {e.message}") from e
    except stripe.error.APIConnectionError:
        raise PaymentError("Payment service unreachable") from e
Enter fullscreen mode Exit fullscreen mode

This also lets you add context (like user ID) at the boundary, which is invaluable when debugging.

5. Logging: Include Context, Not Just Messages

A log line like "Error: something went wrong" is useless. Include the operation, IDs, and any relevant state. Use structured logging (JSON) so you can query later.

logger.error('Failed to process order', {
  orderId: order.id,
  userId: order.userId,
  error: err.message,
  stack: err.stack,
});
Enter fullscreen mode Exit fullscreen mode

Avoid logging sensitive data (passwords, tokens) and avoid logging the same error at multiple levels.

6. Retry with Exponential Backoff

Transient failures (network hiccups, timeouts) are common. Implement retries with jitter to avoid thundering herds.

import time
import random

def retry(fn, attempts=3, base_delay=0.1):
    for i in range(attempts):
        try:
            return fn()
        except Exception:
            if i == attempts - 1:
                raise
            delay = base_delay * (2 ** i) + random.uniform(0, 0.1)
            time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode

Only retry idempotent operations. If the operation isn't idempotent, you'll double-charge or duplicate writes.

7. Don't Swallow Errors

An empty catch {} is a bug waiting to happen. If you catch, you must handle or rethrow. If you truly want to ignore (rare), comment why.

try {
  await trackEvent();
} catch (err) {
  // Analytics must never break the main flow
  logger.debug('Analytics failed', { err });
}
Enter fullscreen mode Exit fullscreen mode

Final Thought

The goal isn't to eliminate errors; it's to make them predictable and observable. Pick a few patterns, apply them consistently, and revisit your error handling when you add new features. Your future self will thank you.

For more on JavaScript promises, see the MDN guide on using promises.

Top comments (0)