Error Handling Patterns — Backend and Frontend Best Practices
Cover try/catch antipatterns, Result types, React error boundaries, centralized error handling in Express and FastAPI, error logging, and user-facing vs developer-facing errors.
Most codebases handle errors in one of two ways: swallow them silently, or crash and blame the user. Neither is acceptable. Good error handling is the difference between "the app is broken" and "something went wrong with your payment -- here's what to do."
The patterns here apply across languages and frameworks. The code examples use JavaScript/TypeScript and Python, but the principles are universal.
The Try/Catch Antipatterns
Antipattern 1: The Empty Catch
try {
await saveUserProfile(data);
} catch (e) {
// TODO: handle this later
}
"Later" never comes. The operation fails silently. The user thinks their profile saved. Support tickets pile up.
Antipattern 2: Catch-and-Log-Only
try {
await processPayment(order);
} catch (e) {
console.log(e); // Logged somewhere nobody checks
// Execution continues as if payment succeeded
}
Logging is necessary, but it's not handling. The caller still needs to know the operation failed.
Antipattern 3: Pokemon Exception Handling (Gotta Catch 'Em All)
try:
user = get_user(user_id)
orders = get_orders(user.id)
total = calculate_total(orders)
receipt = generate_receipt(total)
send_email(user.email, receipt)
except Exception:
return {"error": "Something went wrong"}
Five different operations that can fail in five different ways, all caught by one generic handler. Which one failed? Was it a network error, a validation error, or a business logic error? Nobody knows.
The Fix: Narrow Catches, Specific Handling
try:
user = get_user(user_id)
except UserNotFoundError:
return {"error": "User not found"}, 404
try:
orders = get_orders(user.id)
except DatabaseConnectionError:
logger.error("Database unreachable", exc_info=True)
return {"error": "Service temporarily unavailable"}, 503
total = calculate_total(orders) # Pure function -- no try/catch needed
try:
receipt = generate_receipt(total)
send_email(user.email, receipt)
except EmailDeliveryError as e:
logger.warning(f"Email failed for {user.id}: {e}")
# Non-critical -- continue, but flag for retry
queue_email_retry(user.email, receipt)
Each error type gets appropriate handling. Some are user-facing (404), some are operational (503), some are non-critical (email retry).
Result Types: Errors as Values
Exceptions are control flow. They jump up the call stack until something catches them. This makes code harder to reason about -- you can't tell from a function's signature whether it might throw.
The Result pattern (popularized by Rust, but implementable anywhere) makes errors explicit return values.
TypeScript Result Type
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
Using It
type ValidationError = { field: string; message: string };
function validateEmail(email: string): Result<string, ValidationError> {
if (!email.includes("@")) {
return err({ field: "email", message: "Invalid email format" });
}
if (email.length > 254) {
return err({ field: "email", message: "Email too long" });
}
return ok(email.toLowerCase().trim());
}
async function createUser(data: UserInput): Result<User, string> {
const emailResult = validateEmail(data.email);
if (!emailResult.ok) {
return err(emailResult.error.message);
}
try {
const user = await db.users.create({
email: emailResult.value,
name: data.name,
});
return ok(user);
} catch (e) {
if (e.code === "P2002") {
return err("Email already registered");
}
return err("Failed to create user");
}
}
// Caller is FORCED to handle the error
const result = await createUser(formData);
if (!result.ok) {
showError(result.error);
return;
}
const user = result.value; // TypeScript knows this is User
The compiler forces you to check result.ok before accessing result.value. No forgotten error handling.
When to Use Result vs Exceptions
| Scenario | Use | Why |
|---|---|---|
| Expected failures (validation, not found) | Result | Caller should handle these |
| Unexpected failures (out of memory, network) | Exceptions | Can't reasonably handle everywhere |
| Pure functions with predictable errors | Result | Makes error cases explicit |
| Third-party library errors | Exception → Result at boundary | Convert at the edge of your code |
| Errors that need to propagate far up | Exceptions | Result types don't propagate automatically |
Centralized Error Handling in Express
Every Express app needs a global error handler. Without one, unhandled errors crash the process.
// Custom error classes
class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational: boolean = true
) {
super(message);
this.name = "AppError";
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(404, ${resource} not found);
}
}
class ValidationError extends AppError {
constructor(public errors: { field: string; message: string }[]) {
super(400, "Validation failed");
}
}
// Route handlers throw these
app.get("/api/users/:id", async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) throw new NotFoundError("User");
res.json(user);
} catch (err) {
next(err); // Forward to error handler
}
});
// Global error handler (must have 4 parameters)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log all errors
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
});
if (err instanceof AppError) {
// Operational errors -- safe to show to user
const response: any = { error: err.message };
if (err instanceof ValidationError) {
response.errors = err.errors;
}
return res.status(err.statusCode).json(response);
}
// Programming errors -- don't leak details
res.status(500).json({ error: "Internal server error" });
});
The distinction between operational errors (bad input, resource not found, external service down) and programming errors (null reference, type error, logic bug) is critical. Operational errors get user-friendly messages. Programming errors get generic responses and loud alerts to the dev team.
Centralized Error Handling in FastAPI
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import logging
app = FastAPI()
logger = logging.getLogger(__name__)
class AppException(Exception):
def __init__(self, status_code: int, detail: str, error_code: str = None):
self.status_code = status_code
self.detail = detail
self.error_code = error_code
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
logger.warning(f"{exc.error_code}: {exc.detail} [{request.url}]")
return JSONResponse(
status_code=exc.status_code,
content={
"error": exc.detail,
"code": exc.error_code,
},
)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.error(f"Unhandled error: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": "Internal server error"},
)
# Usage in routes
@app.get("/api/users/{user_id}")
async def get_user(user_id: int):
user = await db.get_user(user_id)
if not user:
raise AppException(
status_code=404,
detail="User not found",
error_code="USER_NOT_FOUND",
)
return user
React Error Boundaries
React components that catch JavaScript errors in their child component tree. Without them, a single error in one component crashes the entire app.
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Log to error tracking service
console.error("Error boundary caught:", error, info.componentStack);
this.props.onError?.(error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h3 className="text-red-800 font-medium">Something went wrong</h3>
<p className="text-red-600 text-sm mt-1">
Please try refreshing the page.
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="mt-2 text-sm text-red-700 underline"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
Use granular error boundaries -- not one at the root, but around individual features:
function Dashboard() {
return (
<div className="grid grid-cols-3 gap-4">
<ErrorBoundary fallback={<WidgetError name="Analytics" />}>
<AnalyticsWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="Recent Orders" />}>
<RecentOrdersWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="User Activity" />}>
<UserActivityWidget />
</ErrorBoundary>
</div>
);
}
If the analytics widget crashes, the orders and activity widgets keep working. One broken feature doesn't nuke the whole page.
Error Boundaries Don't Catch Everything
| Caught | Not Caught |
|---|---|
| Render errors | Event handlers |
| Lifecycle errors | Async code (setTimeout, promises) |
| Constructor errors | Server-side rendering |
// Global unhandled promise rejection handler
window.addEventListener("unhandledrejection", (event) => {
errorTrackingService.capture(event.reason);
showToast("An unexpected error occurred");
});
User-Facing vs Developer-Facing Errors
Every error has two audiences. The user needs to know what to do. The developer needs to know what broke.
// Error response shape
interface ErrorResponse {
// For the USER -- human-readable, actionable
message: string;
// For the DEVELOPER -- machine-readable error code
code: string;
// For SUPPORT -- correlation ID to find in logs
requestId: string;
// Optional -- specific fields that failed
errors?: { field: string; message: string }[];
}
// Example responses:
// 400:
{
"message": "Please check the highlighted fields and try again.",
"code": "VALIDATION_ERROR",
"requestId": "req_abc123",
"errors": [
{ "field": "email", "message": "This email is already registered" },
{ "field": "password", "message": "Password must be at least 8 characters" }
]
}
// 500:
{
"message": "Something went wrong on our end. Please try again in a few minutes.",
"code": "INTERNAL_ERROR",
"requestId": "req_def456"
}
The requestId is the link between what the user sees and what you see in logs. When a user says "I got an error," you ask for the request ID and look it up.
Error Logging Strategy
// Structured logging -- not console.log("error: " + err)
logger.error({
message: "Payment processing failed",
error: {
name: err.name,
message: err.message,
stack: err.stack,
},
context: {
userId: user.id,
orderId: order.id,
amount: order.total,
paymentMethod: order.paymentMethod, // NOT the card number
requestId: req.id,
},
metadata: {
service: "payment-service",
environment: process.env.NODE_ENV,
version: process.env.APP_VERSION,
},
});
Structured JSON logs are searchable. String logs aren't. When your app is processing 10,000 requests per minute, the ability to query error.name = "PaymentGatewayTimeout" AND context.paymentMethod = "stripe" is the difference between a 5-minute diagnosis and a 5-hour one.
Error Handling Decision Matrix
| Error Type | HTTP Code | User Message | Log Level | Alert |
|---|---|---|---|---|
| Validation failure | 400 | Show field errors | debug | No |
| Authentication failure | 401 | "Please sign in" | info | After N failures |
| Authorization failure | 403 | "You don't have access" | warn | Yes, if pattern |
| Resource not found | 404 | "Not found" | debug | No |
| Rate limit | 429 | "Slow down, try again in Xs" | info | If sustained |
| Business logic error | 422 | Specific explanation | info | Depends |
| External service down | 502/503 | "Try again shortly" | error | Yes |
| Unexpected exception | 500 | Generic message | error | Yes, immediately |
| Database connection lost | 500 | Generic message | critical | Yes, page on-call |