Express.js is Boring. That’s Exactly Why I Still Use It.
I spend half my week reading about new backend frameworks. Hono is incredibly fast. Elysia has amazing type safety. Bun is supposedly eating Node’s lunch. But when I had to build a custom e-commerce API last Tuesday, I opened my terminal and typed npm install express.
Well, that’s not entirely accurate. I know. It’s not flashy.
But when you’re building something that actually needs to handle payments, user sessions, and complex relational data, boring is exactly what you want. You don’t want to spend three hours debugging an edge-runtime compatibility issue with your database driver. You want to write your business logic and go home.
My typical stack for these kinds of projects hasn’t fundamentally changed in years, but the tools inside it have matured. I use Express for the routing, Sequelize for the database interactions, and JWT for stateless authentication. It’s a proven pipeline. And here is how I structure it now that we’re finally living in the Express 5 era.
Stop Dumping Everything in server.js
The biggest criticism of Express is that it’s unopinionated. It gives you a request object, a response object, and tells you to figure the rest out yourself. This usually results in junior developers building massive, 2,000-line index.js files.
I enforce a strict separation of concerns: Routes, Controllers, and Middleware. The router only defines the endpoints. The controller handles the actual logic. The middleware intercepts things like authentication or payload validation.
With Express 5.0 finally being the standard, we get native Promise support. This is a massive deal. I spent years wrapping every single async route in a custom catchAsync utility just to prevent unhandled promise rejections from crashing the server. Now? Express catches rejected promises automatically and passes them to your error handler.
// routes/book.routes.js
import { Router } from 'express';
import { getBooks, createBook } from '../controllers/book.controller.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
// Express 5 handles the async errors natively. No wrappers needed.
router.get('/', getBooks);
router.post('/', requireAuth, createBook);
export default router;
// controllers/book.controller.js
export const getBooks = async (req, res) => {
// If this throws, it goes straight to the global error handler
const books = await Book.findAll({ where: { status: 'published' } });
res.json(books);
};
Taming the Database with Sequelize
People love to hate on ORMs. They complain about the generated SQL or the overhead. Look, if you’re building a hyper-optimized analytics engine, write raw SQL. If you’re building a bookstore API with users, orders, inventory, and reviews, writing raw SQL for every single relation is a massive waste of time.
Sequelize makes interacting with relational data completely painless once you get the associations right. I usually pair it with PostgreSQL.
I do have one specific warning from recent experience. I was running a staging environment on a cheap t3.micro EC2 instance last month. The API kept throwing SequelizeConnectionError under moderate load. It wasn’t a code bug. The default connection pool settings in Sequelize are too aggressive for instances with heavily constrained memory.
Probably if you’re running on a small instance (under 1GB RAM), you’ll want to dial back the pool max connections.
// config/database.js
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialect: 'postgres',
logging: false, // Turn this off in prod unless you hate your disk space
pool: {
max: 5, // Dropped this from the default. Saved my staging server.
min: 0,
acquire: 30000,
idle: 10000
}
});
export default sequelize;
Keeping it Secure with JWT
The middleware pattern in Express makes implementing JWT validation incredibly clean. The request comes in, the middleware checks the header, verifies the signature, and attaches the user payload to the request object. If it fails, it kicks the request back with a 401 before the controller even knows it exists.
// middleware/auth.js
import jwt from 'jsonwebtoken';
export const requireAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Attach user info for the controller
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
};
The Reality Check: Performance in 2026
I hear the performance arguments against Express constantly. Yes, it’s slower than the newest micro-frameworks in synthetic “Hello World” benchmarks.
But let’s talk about real-world numbers. I recently migrated an older Express 4 app to Express 5.0.1 running on Node.js 22.14 LTS. I ripped out about 40 lines of async-wrapper boilerplate. Because of the V8 garbage collection improvements in the newer Node runtime, combined with the removal of those intermediary promise wrappers, our baseline memory usage dropped by 14%.
More importantly, our database queries take 45ms. The Express routing overhead takes about 2ms. Switching to a framework that routes in 0.2ms isn’t going to make my API noticeably faster to the end user. Optimizing my PostgreSQL 16.2 indexes will.
When you structure an Express app with clear controllers, utilize an ORM like Sequelize to handle the heavy lifting of data relations, and secure it with a simple JWT middleware pipeline, you get a codebase that any developer can understand in ten minutes.
I’ll take that over the framework-of-the-month any day.
