Introduction
app.use(express.json()) after your route handlers means every req.body is undefined. That one line in the wrong place breaks everything.
Middleware order is the whole game in Express. Not the concept of middleware -- that is just a function with req, res, next. The interesting part is all the ways ordering goes wrong. Error handlers that never catch errors because they have three parameters instead of four. Auth middleware that runs on public routes. Rate limiters that apply after static file serving and throttle your CSS.
So instead of walking through middleware concepts bottom-up, this is organized around the mistakes. What breaks, why, and what the fix looks like.
How Middleware Works Internally
A middleware function gets three arguments: req, res, and next. Registration order is execution order. Each function can modify the request, send a response, or call next().
const express = require('express');
const app = express();
// A simple logging middlewareconstrequestLogger = (req, res, next) => {
const timestamp = newDate().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
// Attach a custom property to the request
req.requestTime = timestamp;
// Pass control to the next middlewarenext();
};
// Register it globally -- runs for every request
app.use(requestLogger);
app.get('/', (req, res) => {
res.json({
message: 'Hello from Express!',
requestedAt: req.requestTime
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});Register logging after your route handler and it never runs for that route. Register it after express.static() and it misses every static file request. Obvious in theory. Less obvious when you are staring at empty logs.
The other trap: forget to call next() and also forget to send a response. The request hangs forever. Express does not throw an error or warn you. It just sits there. Silently. Check for missing next() calls first whenever a request hangs.
Mistakes in Scoping
Your file upload middleware should not run on authentication endpoints. Your admin checks should not fire on public pages. But with app.use(), everything is global by default. Router-level middleware fixes this by binding to an express.Router() instance instead of the app.
const express = require('express');
const app = express();
// Create separate routers for different concernsconst apiRouter = express.Router();
const adminRouter = express.Router();
// This middleware only runs for /api/* routes
apiRouter.use((req, res, next) => {
console.log('API request received');
req.isApiRequest = true;
next();
});
apiRouter.get('/users', (req, res) => {
res.json({ users: ['Alice', 'Bob', 'Charlie'] });
});
apiRouter.get('/posts', (req, res) => {
res.json({ posts: ['Hello World', 'Second Post'] });
});
// This middleware only runs for /admin/* routes
adminRouter.use((req, res, next) => {
console.log('Admin area accessed');
// You could add admin-specific auth checks herenext();
});
adminRouter.get('/dashboard', (req, res) => {
res.json({ dashboard: 'Admin stats here' });
});
// Mount the routers at their respective paths
app.use('/api', apiRouter);
app.use('/admin', adminRouter);
app.listen(3000);Each router gets its own middleware stack. A new developer opens a router file and sees exactly what middleware applies. No tracing through the entire app setup. And this is the opinion I will push: if your Express app has more than five routes and you are not using routers, you are making a mistake. Global middleware is a footgun at scale.
Mistakes in Body Parsing
Express ships three built-in middleware functions. You do not need body-parser separately anymore -- bundled since 4.16.
express.json() parses JSON bodies. Without it, req.body is undefined on POST requests. Set a size limit. The default is 100kb, which sounds reasonable until someone sends a 50MB payload and your server chokes. This has bitten me.
express.urlencoded() handles form submissions. express.static() serves files from a directory.
const express = require('express');
const path = require('path');
const app = express();
// Parse JSON bodies with a 10kb limit
app.use(express.json({
limit: '10kb',
strict: true// Only accept arrays and objects
}));
// Parse URL-encoded form data
app.use(express.urlencoded({
extended: true,
limit: '10kb'
}));
// Serve static files from the 'public' directory// Files will be accessible at the root URL path// e.g., public/styles.css -> http://localhost:3000/styles.css
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: '1d', // Cache files for 1 day
etag: true, // Enable ETag headers
lastModified: true, // Enable Last-Modified headers
index: 'index.html'// Default file for directories
}));
app.post('/api/data', (req, res) => {
// req.body is now available because of express.json()
console.log(req.body);
res.json({ received: true, data: req.body });
});
app.listen(3000);Read the code.
Mistakes in Error Handling
Express identifies error handlers by argument count. Four parameters means error handler. Three means regular middleware. Name your function errorHandler but give it three parameters and Express will never route errors to it. No warning. No error. It just silently skips it.
This is honestly a terrible API decision. The framework distinguishes behavior based on Function.length, which means arrow functions with destructured defaults can silently change the argument count. But it is the API we have, so you need to know the 4-argument signature before looking at any code.
// Custom error class for operational errorsclassAppErrorextends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// Async handler wrapper -- eliminates try/catch blocksconstasyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Route that might throw an error
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw newAppError('User not found', 404);
}
res.json({ user });
}));
// Handle 404 for unmatched routes (must come after all routes)
app.use((req, res, next) => {
next(newAppError(`Cannot find ${req.originalUrl}`, 404));
});
// Centralized error handler (must have 4 parameters!)
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = err.isOperational
? err.message
: 'Something went wrong on our end';
// Log the full error in developmentif (process.env.NODE_ENV === 'development') {
console.error('Error:', err);
}
res.status(statusCode).json({
status: 'error',
statusCode,
message,
...(process.env.NODE_ENV === 'development' && {
stack: err.stack
})
});
});AppError separates operational errors from programming errors. Operational errors get their message sent to the client. Programming errors get a generic "Something went wrong." You do not want to leak stack traces.
The asyncHandler wrapper is non-optional in any serious Express app. Without it, a rejected promise in an async route handler never reaches your error middleware. The request just hangs. With the wrapper, errors forward to next() automatically.
Ordering: 404 handler after all routes, error handler after that. Nope, not before. After.
Mistakes in Auth Middleware
The mistake everyone makes: applying auth middleware globally and then carving out exceptions for public routes. Do it the other way around. Keep routes unprotected by default, apply auth per-router or per-route. Otherwise every new public endpoint requires remembering to add it to the exclusion list, and the one time you forget is the one time it matters.
const jwt = require('jsonwebtoken');
// JWT verification middlewareconstauthenticate = asyncHandler(async (req, res, next) => {
// 1. Extract the token from the Authorization headerconst authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw newAppError('No authentication token provided', 401);
}
const token = authHeader.split(' ')[1];
// 2. Verify the tokenconst decoded = jwt.verify(token, process.env.JWT_SECRET);
// 3. Check if user still exists in the databaseconst user = await User.findById(decoded.id);
if (!user) {
throw newAppError('User no longer exists', 401);
}
// 4. Attach user to the request object
req.user = user;
next();
});
// Role-based authorization middleware (factory function)constauthorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
returnnext(
newAppError('Authentication required', 401)
);
}
if (!allowedRoles.includes(req.user.role)) {
returnnext(
newAppError('Insufficient permissions', 403)
);
}
next();
};
};
// Usage: chain authenticate and authorize together
app.get('/api/users',
authenticate,
authorize('admin', 'manager'),
asyncHandler(async (req, res) => {
const users = await User.find();
res.json({ users });
})
);
// Public route -- no auth required
app.get('/api/public/posts', asyncHandler(async (req, res) => {
const posts = await Post.find({ published: true });
res.json({ posts });
}));authorize is a factory function. Pass in the roles you need, get back a middleware function. No separate middleware per role. And look at how the middleware chains on the route: authenticate, then authorize('admin', 'manager'), then the handler. If authentication fails, nothing downstream runs.
Worth it.
Rate Limiting and Security Middleware
A bot hits your API with thousands of requests per minute. Node runs out of memory. The process crashes. Rate limiting would have stopped this.
// Simple in-memory rate limiter (good for learning)constcreateRateLimiter = (windowMs, maxRequests) => {
const requests = newMap();
// Clean up expired entries every minutesetInterval(() => {
const now = Date.now();
for (const [key, data] of requests) {
if (now - data.windowStart > windowMs) {
requests.delete(key);
}
}
}, 60000);
return (req, res, next) => {
const clientIp = req.ip;
const now = Date.now();
const clientData = requests.get(clientIp);
if (!clientData || now - clientData.windowStart > windowMs) {
// New window for this client
requests.set(clientIp, {
windowStart: now,
count: 1
});
returnnext();
}
if (clientData.count >= maxRequests) {
const retryAfter = Math.ceil(
(clientData.windowStart + windowMs - now) / 1000
);
res.set('Retry-After', retryAfter);
return res.status(429).json({
error: 'Too many requests',
retryAfter: `${retryAfter} seconds`
});
}
clientData.count++;
next();
};
};
// Apply different limits for different routes
app.use('/api/', createRateLimiter(60000, 100)); // 100 req/min
app.use('/auth/', createRateLimiter(900000, 10)); // 10 req/15minIn-memory rate limiting works for single-server setups. For multiple instances, back it with Redis. The express-rate-limit package with a Redis store handles this.
Other security middleware: helmet (HTTP headers), cors (cross-origin), input sanitization (NoSQL injection). Each roughly one line. But the mistake I see constantly is putting rate limiting after express.static(). Your static assets eat into the rate limit budget and legitimate API calls get throttled. Always scope rate limiters to your API paths specifically.
Getting It Right
Factory functions. Accept configuration, return middleware. Be explicit about what you modify on req and res. Handle errors instead of swallowing them. Do one thing.
// 1. Request validation middleware factoryconstvalidate = (schema, source = 'body') => {
return (req, res, next) => {
const dataToValidate = req[source];
const { error, value } = schema.validate(dataToValidate, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const errors = error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}));
return res.status(400).json({
status: 'validation_error',
errors
});
}
// Replace with sanitized/validated data
req[source] = value;
next();
};
};
// Usage with Joiconst Joi = require('joi');
const createUserSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().min(13).max(120)
});
app.post('/api/users',
validate(createUserSchema),
asyncHandler(async (req, res) => {
// req.body is guaranteed to be valid hereconst user = await User.create(req.body);
res.status(201).json({ user });
})
);// 2. Response time and request ID middlewareconst crypto = require('crypto');
constrequestMetrics = (options = {}) => {
const {
headerName = 'X-Response-Time',
idHeaderName = 'X-Request-Id',
logSlowRequests = true,
slowThreshold = 1000// milliseconds
} = options;
return (req, res, next) => {
// Generate a unique request IDconst requestId = crypto.randomUUID();
req.id = requestId;
res.set(idHeaderName, requestId);
// Track response timeconst start = process.hrtime.bigint();
// Hook into the response finish event
res.on('finish', () => {
const end = process.hrtime.bigint();
const durationMs = Number(end - start) / 1e6;
res.set(headerName, `${durationMs.toFixed(2)}ms`);
if (logSlowRequests && durationMs > slowThreshold) {
console.warn(
`[SLOW] ${req.method} ${req.url}`,
`${durationMs.toFixed(2)}ms`,
`ID: ${requestId}`
);
}
});
next();
};
};
// Register with custom configuration
app.use(requestMetrics({
slowThreshold: 500,
logSlowRequests: process.env.NODE_ENV === 'production'
}));The validation middleware takes a schema and an optional source (body, params, or query). It strips unknown fields and returns structured errors with field paths. Much easier for a frontend than a generic 400.
The metrics middleware hooks into the response's finish event to measure the full request-response cycle and assigns a unique request ID. When someone reports a bug, you ask for the request ID from their response headers and trace it through your logs.
Think about what happens when your custom middleware throws. If it does anything async, catch errors and forward them with
next(err). Middleware that crashes silently is worse than middleware that does not exist.
Putting It All Together
A production middleware stack, top to bottom:
- Security headers (Helmet, CORS) -- before any response goes out.
- Request metrics (IDs, timing) -- wraps everything.
- Body parsing (
express.json,express.urlencoded) -- before anything readsreq.body. - Global rate limiting.
- Static file serving.
- Router-level middleware (auth, validation) per route group.
- Route handlers.
- 404 handler for unmatched routes.
- Error handler -- always last.
This is not convention. It is correctness. Security headers after a route handler means some responses ship without them. Body parsing after a POST handler means req.body is undefined. Error handler before routes means it never catches anything. And wrap every async handler with asyncHandler. Get these right and most middleware bugs just stop happening.