Express is basically just a thin wrapper around Node's built-in HTTP server that doesn't suck to use. TJ Holowaychuk built it in 2010 because writing web servers in raw Node was ass, and somehow it became the standard everyone uses.
The Middleware Stack (Where Everything Goes Wrong)
Express uses middleware - basically a chain of functions that process your request. Sounds simple until you spend 3 hours debugging why your auth middleware isn't running (spoiler: order matters and the middleware docs won't tell you).
Here's what actually happens in production:
const express = require('express');
const app = express();
// This order will bite you in the ass
app.use(express.json({ limit: '10mb' })); // Parse JSON (crashes on huge payloads)
app.use(cors()); // CORS - put this BEFORE your routes or cry
app.use(helmet()); // Security headers
app.use(morgan('combined')); // Logging
// Your auth middleware better be here or everything breaks
app.use('/api', authMiddleware);
app.listen(3000, () => {
console.log('Server running on 3000');
});
The middleware stack is great until you spend 4 hours debugging why requests hang. Usually it's because some middleware didn't call next()
and your request is stuck in limbo.
Express 5: Finally Catches Async Errors
Express 5.0 finally shipped in September 2024 after being stuck in beta hell since 2014. The biggest win? It finally catches async errors automatically:
// In Express 4, this crashes your app silently
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id); // Throws on invalid ID
res.json(user);
});
// Express 5 actually catches this shit
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id); // Auto-caught now
if (!user) return res.status(404).json({error: 'User not found'});
res.json(user);
});
They also dropped Node 16 support, which broke half the tutorials on the internet but made the codebase cleaner. The Node.js release schedule shows why this was necessary.
Why Big Companies Still Use Express (Despite Faster Alternatives)
Big companies still use Express because it's predictable as hell. When you're serving millions of requests, you want boring tech that doesn't surprise you. Companies like Netflix, GitHub, and LinkedIn all rely on Express for production workloads.
Performance benchmarks show Fastify destroying Express in synthetic tests, but guess what kills your app in production? Shitty database queries, not framework overhead. I've debugged "slow" Express apps where the real problem was 47 unindexed database queries on every request.
The Real Performance Killers (Hint: Not Express)
After fixing enough production fires, here's what actually kills performance:
- Your database queries - Fix your indexes before blaming Express. Use EXPLAIN to analyze query performance.
- Synchronous operations - One
fs.readFileSync()
will tank your app. Check the Node.js performance best practices. - Memory leaks - Usually from not cleaning up event listeners. Use clinic.js to profile memory usage.
- Middleware bloat - Do you really need 47 security headers? OWASP security headers guide shows what actually matters.
- No connection pooling - Your DB connections are the real bottleneck. Configure proper connection pooling for databases.
The Express Ecosystem (5000+ Middleware Packages)
The npm ecosystem has middleware for everything. Need auth? Passport.js. Security headers? Helmet.js. Rate limiting? express-rate-limit. File uploads? Multer.
This is Express's real superpower - someone already solved your problem and put it on npm. Compare that to newer frameworks where you're rebuilding basic middleware from scratch. Check out the Awesome Express list for curated middleware packages and tools.
Deployment Reality Check
Express + Docker works great until your health checks start failing randomly. Here's what actually works in production:
// Health check that doesn't suck
app.get('/health', (req, res) => {
// Don't just return 200 - check your dependencies
const status = {
server: 'ok',
timestamp: new Date().toISOString()
};
// Quick DB ping (timeout after 1s)
Promise.race([
checkDatabase(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 1000)
)
]).then(() => {
status.database = 'ok';
res.json(status);
}).catch(err => {
status.database = 'error';
status.error = err.message;
res.status(503).json(status);
});
});
Pro tip: Kubernetes will kill your pod if health checks fail 3 times. Don't return 500 because your Redis cache is down - that's not a reason to restart the whole app. For more Docker best practices, see the Docker Node.js guide and Kubernetes health check patterns.