Writing Better APIs: Error Handling and Logging That Actually Works

You know that moment when your API throws a 500 error and you have no idea why? Or when you're hunting through 47 route files trying to find where that specific error message comes from? Let's fix that.
The Copy-Paste Problem
Most Express.js codebases look like this:
app.get('/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
console.log(error);
res.status(500).json({ error: 'Something went wrong' });
}
});
app.post('/users', async (req, res) => {
try {
const user = await User.create(req.body);
res.status(201).json(user);
} catch (error) {
console.log(error);
res.status(500).json({ error: 'Something went wrong' });
}
});
// ... 45 more routes with identical try-catch blocks
Every route has the same error handling code. Every route logs errors differently. When production breaks, you're searching through console.log statements hoping to find something useful.
The Async Wrapper Pattern
Here's a tiny function that changes everything:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
This wrapper catches any error from your async route handlers and passes them to Express's error handling middleware. Now your routes look like this:
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User not found');
}
res.json(user);
}));
app.post('/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
}));
No try-catch blocks. Just throw errors when something goes wrong. The wrapper handles everything.
Centralized Error Handling
Now that errors flow to one place, you need middleware to catch them properly:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 400);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
}
}
The isOperational flag is crucial. It separates expected errors (user not found, validation failed) from unexpected errors (database connection lost, null reference).
Here's the error handler middleware:
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
} else {
// Production: don't leak error details
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
// Programming error: log and hide details
console.error('ERROR 💥', err);
res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
}
});
In development, you get full stack traces. In production, users see clean error messages while unexpected errors are hidden and logged for investigation.
Why This Pattern Works
Operational errors are part of normal application flow. A user requests a resource that doesn't exist? That's operational. Invalid input? Operational. These errors should be handled gracefully and returned to the user.
Programming errors are bugs in your code. Trying to read a property of undefined? Programming error. Database connection string is wrong? Programming error. These should never be shown to users.
The isOperational flag lets you handle both correctly in production.
Logging That Actually Helps
console.log(error) tells you something broke. It doesn't tell you which user triggered it, what they were trying to do, or how to reproduce it.
Here's a proper request logger:
const requestLogger = (req, res, next) => {
const start = Date.now();
// Capture the original end function
const originalEnd = res.end;
res.end = function(...args) {
const duration = Date.now() - start;
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
userAgent: req.get('user-agent'),
ip: req.ip,
userId: req.user?.id || 'anonymous',
// Add correlation ID for tracking across services
correlationId: req.headers['x-correlation-id'] || generateId()
}));
// Call the original end function
return originalEnd.apply(this, args);
};
next();
};
app.use(requestLogger);
This logs every request with context. Now when something breaks, you know who did what and how long it took.
Structured Logging for Production
JSON logs are searchable. String concatenation is not.
// Bad: impossible to search or filter
console.log('User [email protected] failed login attempt from 192.168.1.1');
// Good: structured and queryable
logger.warn({
event: 'login_failed',
email: '[email protected]',
ip: '192.168.1.1',
timestamp: new Date().toISOString(),
attemptCount: 3
});
Use a logging library like Winston or Pino:
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: 'error.log',
level: 'error'
}),
new winston.transports.File({
filename: 'combined.log'
})
]
});
// Console logging for development
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
module.exports = logger;
Now you can search your logs by event type, user ID, status code, or any field you include.
Error Logging with Context
When an error happens, log everything you need to debug it:
app.use((err, req, res, next) => {
const errorLog = {
timestamp: new Date().toISOString(),
error: {
message: err.message,
stack: err.stack,
statusCode: err.statusCode
},
request: {
method: req.method,
url: req.url,
headers: req.headers,
body: req.body,
params: req.params,
query: req.query,
userId: req.user?.id
}
};
if (err.isOperational) {
logger.warn(errorLog);
} else {
logger.error(errorLog);
}
// Send response
if (process.env.NODE_ENV === 'production') {
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
} else {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
}
});
This logs the full context of failed requests. When debugging, you can see exactly what the user sent and what went wrong.
Monitoring Real Performance
Logging tells you what happened after the fact. Monitoring tells you what's happening right now.
Track key metrics in your routes:
const metrics = {
requestCount: 0,
errorCount: 0,
slowRequestCount: 0,
totalResponseTime: 0
};
const monitoringMiddleware = (req, res, next) => {
const start = Date.now();
metrics.requestCount++;
res.on('finish', () => {
const duration = Date.now() - start;
metrics.totalResponseTime += duration;
if (duration > 1000) {
metrics.slowRequestCount++;
logger.warn({
event: 'slow_request',
duration,
method: req.method,
url: req.url,
userId: req.user?.id
});
}
if (res.statusCode >= 500) {
metrics.errorCount++;
}
});
next();
};
// Endpoint to expose metrics
app.get('/metrics', (req, res) => {
const avgResponseTime = metrics.totalResponseTime / metrics.requestCount;
const errorRate = (metrics.errorCount / metrics.requestCount) * 100;
res.json({
requestCount: metrics.requestCount,
errorCount: metrics.errorCount,
errorRate: `${errorRate.toFixed(2)}%`,
slowRequestCount: metrics.slowRequestCount,
avgResponseTime: `${avgResponseTime.toFixed(2)}ms`
});
});
Now you can track error rates, response times, and slow requests. Hook this up to Prometheus, Datadog, or any monitoring service.
Alert on What Matters
Not every error needs an alert. A single 404? Normal. A 50% error rate? Critical.
const alerting = {
errorThreshold: 100, // Alert after 100 errors
errorWindow: 60000, // in 1 minute
errors: []
};
const checkAlertThreshold = () => {
const now = Date.now();
const recentErrors = alerting.errors.filter(
timestamp => now - timestamp < alerting.errorWindow
);
alerting.errors = recentErrors;
if (recentErrors.length >= alerting.errorThreshold) {
logger.error({
event: 'error_threshold_exceeded',
errorCount: recentErrors.length,
timeWindow: `${alerting.errorWindow / 1000}s`,
message: 'High error rate detected - investigate immediately'
});
// Send alert to your monitoring service
// sendSlackAlert(), sendPagerDutyAlert(), etc.
// Reset to avoid spam
alerting.errors = [];
}
};
app.use((err, req, res, next) => {
// Track errors for alerting
if (!err.isOperational) {
alerting.errors.push(Date.now());
checkAlertThreshold();
}
// ... rest of error handling
});
This alerts when error rates spike, not on every single error.
Complete Setup
Here's what a complete setup looks like:
const express = require('express');
const winston = require('winston');
const app = express();
// Logger setup
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Async wrapper
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Custom errors
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
// Request logger
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start,
ip: req.ip
});
});
next();
});
// Your routes
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.json(user);
}));
// Error handler (must be last)
app.use((err, req, res, next) => {
const errorLog = {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: req.user?.id
};
if (err.isOperational) {
logger.warn(errorLog);
res.status(err.statusCode).json({
status: 'fail',
message: err.message
});
} else {
logger.error(errorLog);
res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
}
});
app.listen(3000);
Clean routes, centralized error handling, structured logging, and proper monitoring. This is how production APIs should work.
Key Takeaways
Error handling:
Use async wrappers to eliminate try-catch boilerplate
Centralize error handling in one middleware
Distinguish operational errors from programming errors
Never leak error details in production
Logging and monitoring:
Log structured JSON, not string messages
Include context with every log (user, URL, timestamp)
Track metrics like error rates and response times
Alert on patterns, not individual errors
The async wrapper pattern alone cuts hundreds of lines of repetitive code. Combined with proper logging, you go from hunting through console statements to querying structured logs. Your debugging time drops from hours to minutes.
Stop copy-pasting try-catch blocks. Stop using console.log. Start building APIs that are actually maintainable.
TLDR;
Use an async wrapper to remove try-catch blocks from every route. Create centralized error handling middleware with custom error classes that distinguish operational errors (expected) from programming errors (bugs). Implement structured JSON logging with Winston or Pino instead of console.log. Track metrics like error rates, response times, and slow requests. Alert on error rate spikes, not individual errors. This approach reduces boilerplate by 70%+, makes debugging dramatically faster, and gives you production visibility you can actually use.





