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 });
});
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
}
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();
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 });
});
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
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,
});
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)
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 });
}
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)