Your Node.js app worked fine in development. In production, it randomly dies with exit code 1 and this cryptic message:
UnhandledPromiseRejectionWarning: Error: connection timeout
(node:15287) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
Then 30 seconds later, your process manager restarts it. PM2 logs show nothing useful.
Here's why: Node 18+ kills your entire process when you have unhandled promise rejections. Not just a warning anymore - the process actually exits with code 1.
Most developers learn this when their app dies in production. I've debugged this exact issue probably 50 times across different teams.
Why Your App Dies in Production
Missing try/catch everywhere:
// This crashes your server
app.get('/user/:id', async (req, res) => {
const user = await database.findUser(req.params.id);
res.json(user);
});
Database times out, query throws, no try/catch = dead server.
Fire-and-forget async work:
// Crashes server later
app.post('/process-data', async (req, res) => {
res.json({ message: 'Processing started' });
processLargeDataset(req.body.data); // No await, no catch
});
You sent the response already, but async work crashes the server.
Express 4 middleware problems:
// Middleware that kills your app
app.use(async (req, res, next) => {
await authenticateUser(req); // Throws
next(); // Never called
});
Express 4 doesn't catch async middleware errors. They become unhandled rejections that kill your server.
This broke in Express 4.16.0 when they added async support but forgot to catch middleware errors. Took me 4 hours to debug why our auth middleware kept crashing the server.
Fix #1: Async Wrapper for Express 4
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Now your routes won't crash
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await database.findUser(req.params.id);
res.json(user);
}));
This catches any promise rejection and sends it to your error handler. No more crashed servers.
Fix #2: Global Process Handlers
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
process.exit(1); // Let PM2/Docker restart it
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1);
});
Don't try to recover from these errors. Your app is fucked. Log it and die gracefully. Let PM2 or Docker restart the process.
Fix #3: Error Handler Middleware
const errorHandler = (error, req, res, next) => {
console.error('API Error:', error.message);
let statusCode = 500;
let message = 'Internal server error';
if (error.name === 'ValidationError') {
statusCode = 400;
message = 'Invalid request data';
} else if (error.status === 401) {
statusCode = 401;
message = 'Authentication required';
}
res.status(statusCode).json({
success: false,
error: message
});
};
app.use(errorHandler);
Put this after all your routes. It catches errors and sends proper HTTP responses instead of crashing.
Express 5 vs Express 4
Express 5 automatically catches async route handler errors. You don't need the asyncHandler wrapper.
// Express 5 - this works
app.get('/user/:id', async (req, res) => {
const user = await database.findUser(req.params.id);
res.json(user);
});
// Express 4 - need wrapper
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await database.findUser(req.params.id);
res.json(user);
}));
But you still need global process handlers and error middleware in both versions.
Database Error Handling
Database connections fail all the fucking time. Handle it:
const dbQuery = async (text, params) => {
let client;
try {
client = await pool.connect();
const result = await client.query(text, params);
return result;
} catch (error) {
console.error('Database error:', error.message);
throw error;
} finally {
if (client) client.release();
}
};
// Use it
app.get('/users/:id', asyncHandler(async (req, res) => {
const result = await dbQuery('SELECT * FROM users WHERE id = $1', [req.params.id]);
if (result.rows.length === 0) {
const error = new Error('User not found');
error.status = 404;
throw error;
}
res.json(result.rows[0]);
}));
Always release database connections in the finally block. Always. I've seen production apps run out of database connections because someone forgot this.
Your app starts throwing ECONNREFUSED
errors and you think Postgres is down. Nope - you just leaked all 100 connections from the pool. The fix is one line in a finally block, but it takes down prod for 2 hours.
Memory Leaks from Bad Error Handling
Clean up your shit when errors happen:
// This leaks memory
const cache = new Map();
app.get('/data/:id', async (req, res) => {
try {
let data = cache.get(req.params.id);
if (!data) {
data = await fetchExpensiveData(req.params.id);
cache.set(req.params.id, data); // Never cleaned up on errors
}
res.json(data);
} catch (error) {
res.status(500).json({ error: 'Failed' });
}
});
// Better - clean up on errors
app.get('/data/:id', async (req, res) => {
const cacheKey = req.params.id;
try {
let data = cache.get(cacheKey);
if (!data) {
data = await fetchExpensiveData(cacheKey);
cache.set(cacheKey, data);
// Auto-expire after 5 minutes
setTimeout(() => cache.delete(cacheKey), 300000);
}
res.json(data);
} catch (error) {
cache.delete(cacheKey); // Clean up failed requests
throw error;
}
});
The Real Problem
Every Node.js tutorial I've seen assumes your Postgres never times out and your users always send valid JSON. In production, everything breaks constantly:
- Database connections drop
- External APIs timeout
- Users send malformed data
- Network requests fail
- Memory runs out
- Disk fills up
Your error handling needs to expect failure, not success. When I review Node.js apps, the ones that stay up are the ones that assume everything will break.
Set up proper error handling before your app hits production. Trust me - debugging crashed servers at 3am sucks.