Error handling workflow in Node.js applications
The First Rule of Node.js Production: Everything Will Break
Let me tell you about the worst 6 hours of my life. It was 2:30 AM on a Friday (because it's always a Friday), our payment processing was down, and the only error I had was:
UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'id' of undefined
at processPayment (/app/payment.js:47:23)
No request ID. No user context. No clue which payment was failing. Just 500,000 angry customers and a CEO who was surprisingly coherent for someone I'd just woken up.
That night taught me that Node.js error handling isn't about elegant code—it's about not getting fired.
What I Learned the Hard Way: Two Types of Fuckups
After debugging hundreds of outages, every Node.js error falls into two buckets:
Expected Fuckups (Operational Errors)
These will happen. Plan for them:
- Database craps out (AWS RDS has feelings apparently)
- Users send garbage JSON (
"age": "twenty-five"
broke our signup for 3 hours) - Third-party APIs randomly return 503 (looking at you, Stripe)
- Your Docker container runs out of memory (again)
Unexpected Fuckups (Programmer Errors)
These are on you:
- Forgetting `.catch()` on a Promise (Node 15+ crashes immediately - no warnings, no mercy)
- Reading
user.profile.id
whenuser.profile
is null (classic TypeError) - Passing a string to a function expecting a number
- Memory leaks that slowly kill your server
The "Oh Shit" Decision Tree
When something breaks at 3 AM:
- Operational error? → Log it, handle it, keep running, fix the root cause Monday
- Programmer error? → Log it, restart the process, fix it NOW before more users hit the same bug
This took me 2 years and countless sleepless nights to figure out. The restart part is crucial—corrupted state will spread like cancer.
Error Classes That Don't Suck
JavaScript's built-in Error
is basically useless for debugging production issues. Here's what actually works after trying 50 different approaches:
The One Error Class You Actually Need
// errors/AppError.js - Yes, one file. Keep it simple.
class AppError extends Error {
constructor(message, statusCode = 500, context = {}) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.context = context;
this.timestamp = new Date().toISOString();
this.isOperational = statusCode < 500; // 4xx = user error, 5xx = our error
Error.captureStackTrace(this, AppError);
}
}
module.exports = AppError;
How to Use It (Copy-Paste Ready)
// When user sends bad data
throw new AppError('User ID must be a number', 400, { userId: req.params.id });
// When database is down
throw new AppError('Database connection failed', 500, {
operation: 'getUserById',
userId: 123
});
// When external API fails
throw new AppError('Payment processor unavailable', 502, {
service: 'stripe',
paymentId: payment.id
});
I tried building fancy error hierarchies - ValidationError, DatabaseError, all that bullshit. Spent two weeks building this beautiful inheritance tree. Know what happened? My team couldn't remember which error to throw when. We ended up with DatabaseValidationNetworkError nonsense. One error class with good context is infinitely better than 10 error classes that confuse your team.
Error Handling Pattern:
The key insight is separating operational errors (network failures, bad user input) from programmer errors (null pointer exceptions, syntax errors). Handle the first, crash fast on the second.
Async/Await: The Only Pattern That Matters
Forget everything you know about callbacks. Here's the one pattern that has saved my ass countless times:
Controller Pattern (Copy This)
// controllers/userController.js
const AppError = require('../errors/AppError');
exports.getUser = async (req, res, next) => {
try {
const { id } = req.params;
// This will blow up if id is \"undefined\" - ask me how I know
if (!id || isNaN(parseInt(id))) {
throw new AppError('User ID must be a number', 400, { providedId: id });
}
// Always set timeouts. PostgreSQL will hang forever otherwise.
const user = await User.findById(parseInt(id), { timeout: 5000 });
if (!user) {
throw new AppError('User not found', 404, { userId: id });
}
res.json({ user });
} catch (error) {
next(error); // Let middleware handle it
}
};
The timeout part is critical. I once had a query hang for 8 hours because someone forgot a WHERE clause. 8 fucking hours. Watching PostgreSQL slowly eat all our connection pools while I tried to figure out why checkout was broken. 5 seconds is generous for most operations.
Retry Logic That Won't Piss Off Your APIs
Circuit Breaker Pattern:
When external services fail, implement a circuit breaker: try → fail fast → wait → try again. This prevents cascading failures.
// Don't retry everything. Stripe will ban you.
async function processPayment(paymentData) {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await stripe.charges.create(paymentData);
} catch (error) {
// 4xx errors = user problem, don't retry
if (error.statusCode < 500 && attempt === maxRetries) {
throw new AppError(`Payment failed: ${error.message}`, 400, {
stripeError: error.code,
paymentData: { amount: paymentData.amount } // Don't log card details!
});
}
// 5xx errors = their problem, maybe retry
if (error.statusCode >= 500 && attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Linear backoff is fine
continue;
}
throw new AppError('Payment processor unavailable', 502, {
attempt,
stripeError: error.code
});
}
}
}
Exponential backoff is overkill for most use cases. Linear backoff works fine and won't make you wait 16 seconds on the third retry.
Error Middleware That Won't Drive You Insane
Here's the error handler I use everywhere. It's 30 lines and handles 99% of cases:
// middleware/errorHandler.js
const AppError = require('../errors/AppError');
function errorHandler(error, req, res, next) {
// Log everything. You'll thank me later.
console.error('Error:', {
message: error.message,
stack: error.stack,
url: req.url,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent'),
requestId: req.id,
context: error.context || {}
});
// Figure out status code
let statusCode = 500;
let message = 'Something broke. We\'re looking into it.';
if (error instanceof AppError) {
statusCode = error.statusCode;
message = error.message;
} else if (error.name === 'ValidationError') {
statusCode = 400;
message = error.message;
} else if (error.code === 'ECONNREFUSED') {
statusCode = 503;
message = 'Database connection failed';
}
// Don't leak internal errors to users in production
if (statusCode >= 500 && process.env.NODE_ENV === 'production') {
message = 'Internal server error';
}
res.status(statusCode).json({
error: message,
requestId: req.id
});
}
module.exports = errorHandler;
That's it. No fancy abstractions. No metadata objects. Just log the error and return something useful to the client.
The Nuclear Option: Process Handlers
These catch the shit that crashes your app. Set them up once and forget about them:
// app.js - Put this at the very top
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise Rejection at:', promise, 'reason:', reason);
// In Node 15+, this will crash your app anyway. Log it first.
process.exit(1);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1); // Something is very wrong. Just die.
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received. Shutting down...');
server.close(() => {
process.exit(0);
});
});
Do NOT try to recover from these. If you get an uncaught exception, your process is corrupted. Log it and restart. That's what PM2 or Docker is for.
I've seen teams spend days trying to "gracefully handle" uncaught exceptions. It's a waste of time. Just crash fast and restart clean.
Request IDs: The One Thing That Will Save Your Sanity
Add this middleware and thank me later when you're debugging at 3 AM:
// middleware/requestId.js
const crypto = require('crypto');
function addRequestId(req, res, next) {
req.id = req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex');
res.setHeader('X-Request-ID', req.id);
next();
}
module.exports = addRequestId;
Now every error log will have a request ID. When a user reports "I got an error," you can find their exact request in the logs instead of playing guessing games.
The Bottom Line
Most error handling advice is theoretical bullshit. This stuff works because I've debugged 1000+ production incidents. Keep it simple, log everything with context, and restart when shit hits the fan. Your future self will thank you.