
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 registered ahead of static file serving that 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 middleware
const requestLogger = (req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.url}`);
// Attach a custom property to the request
req.requestTime = timestamp;
// Pass control to the next middleware
next();
};
// 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 concerns
const 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 here
next();
});
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. It enforces a size limit: the default is 100kb, and larger bodies are rejected with a 413 before your handler runs. That default is fine for most JSON APIs. Lower it if your payloads are small (the example uses 10kb), and raise it only on the specific routes that need bigger bodies instead of globally.
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.
The next two snippets are pieces of the same app rather than standalone files: they reuse app from the earlier examples, and User and Post stand in for your data models (Mongoose, Prisma or similar). auth-middleware.js also reuses AppError and asyncHandler from error-handling.js.
// Fragment: assumes `app` from earlier and a `User` model
// Custom error class for operational errors
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// Async handler wrapper -- eliminates try/catch blocks
const asyncHandler = (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 new AppError('User not found', 404);
}
res.json({ user });
}));
// Handle 404 for unmatched routes (must come after all routes)
app.use((req, res, next) => {
next(new AppError(`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 development
if (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 an Express 4 fix. In Express 4, a rejected promise in an async route handler never reaches your error middleware. It becomes an unhandled rejection, and on Node 15 and later that crashes the process by default. The wrapper catches the rejection and forwards it to next().
Express 5 Handles Async Errors for You
Express 5 has been the latest release on npm since 5.1.0 (March 2025). Its router forwards a rejected promise or a thrown error from an async handler or middleware to next(err) on its own, so new Express 5 apps can drop the wrapper:
const express = require('express'); // express@5
const app = express();
// Stand-in for a database call that fails
const findUser = async (id) => {
throw new Error(`Database unavailable while loading user ${id}`);
};
// No wrapper: Express 5 passes the rejected promise to next(err)
app.get('/users/:id', async (req, res) => {
const user = await findUser(req.params.id);
res.json({ user });
});
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
app.listen(3000);
Requesting /users/42 returns a 500 with the error message from your handler, and the process keeps running. The asyncHandler calls in the rest of this article still work on Express 5; they just become redundant.
Ordering: 404 handler after all routes, error handler after that. Nope, not before. After.
Mistakes in Auth Middleware
A common mistake: 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.
// Fragment: reuses app, AppError and asyncHandler from error-handling.js
const jwt = require('jsonwebtoken');
// JWT verification middleware
const authenticate = asyncHandler(async (req, res, next) => {
// 1. Extract the token from the Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AppError('No authentication token provided', 401);
}
const token = authHeader.split(' ')[1];
// 2. Verify the token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 3. Check if user still exists in the database
const user = await User.findById(decoded.id);
if (!user) {
throw new AppError('User no longer exists', 401);
}
// 4. Attach user to the request object
req.user = user;
next();
});
// Role-based authorization middleware (factory function)
const authorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return next(
new AppError('Authentication required', 401)
);
}
if (!allowedRoles.includes(req.user.role)) {
return next(
new AppError('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)
const createRateLimiter = (windowMs, maxRequests) => {
const requests = new Map();
// Clean up expired entries every minute
setInterval(() => {
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
});
return next();
}
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/15min
In-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. Watch where the limiter sits relative to express.static(). Middleware runs in registration order, so a global limiter registered before express.static() counts every CSS, JS and image request, and legitimate API calls get throttled. Register express.static() first (static hits end there and never reach the limiter), or scope the limiter to your API paths as the example does with app.use('/api/', ...).
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 factory
const validate = (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 Joi
const 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 here
const user = await User.create(req.body);
res.status(201).json({ user });
})
);// 2. Response time and request ID middleware
const crypto = require('crypto');
const onHeaders = require('on-headers'); // npm install on-headers
const requestMetrics = (options = {}) => {
const {
headerName = 'X-Response-Time',
idHeaderName = 'X-Request-Id',
logSlowRequests = true,
slowThreshold = 1000 // milliseconds
} = options;
return (req, res, next) => {
// Generate a unique request ID
const requestId = crypto.randomUUID();
req.id = requestId;
res.set(idHeaderName, requestId);
// Track response time
const start = process.hrtime.bigint();
const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1e6;
// Headers can only be set before they are sent, so add
// the timing header just before they go out
onHeaders(res, () => {
res.setHeader(headerName, `${elapsedMs().toFixed(2)}ms`);
});
// 'finish' fires after the response is sent: log only, no headers
res.on('finish', () => {
const durationMs = elapsedMs();
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 assigns a unique request ID and times the request. The timing header is written from an on-headers hook, which runs just before the headers are sent. Setting it inside the finish event would be too late: by then the headers are gone and res.set() throws ERR_HTTP_HEADERS_SENT. So finish is used only for logging, which measures the full cycle. The response-time package does the same job if you only need the header. 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. - Static file serving — before the rate limiter, so asset requests never count against it.
- Rate limiting, ideally scoped to
/api. - 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. On Express 4, wrap every async handler with asyncHandler; on Express 5 the router does it for you. Get these right and most middleware bugs just stop happening.