Fix: Express Middleware Not Working — Order Wrong, Errors Not Caught, or async Errors Silently Dropped
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Express middleware issues — middleware execution order, error-handling middleware signature, async error propagation with next(err), and common middleware misconfigurations.
The Problem
Middleware runs but doesn’t affect the response, or it’s skipped entirely:
app.use((req, res, next) => {
req.user = getUser(req); // Sets req.user
next();
});
app.get('/profile', (req, res) => {
console.log(req.user); // undefined — middleware didn't run?
});Or an error handler never fires even when errors are thrown:
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
app.get('/data', async (req, res) => {
const data = await db.query(); // throws — but error handler never runs
res.json(data);
});Or a specific route middleware is applied to the wrong routes:
app.use('/admin', authMiddleware); // Should protect admin routes
app.get('/admin/dashboard', (req, res) => {
res.send('Dashboard'); // Accessible without auth
});Why This Happens
Express middleware executes in the order it’s registered. Most issues trace back to these rules:
- Order matters absolutely — middleware registered after a route handler doesn’t run for that route. Express processes middleware top-to-bottom, stopping when a response is sent.
- Error-handling middleware needs exactly 4 arguments —
(err, req, res, next). If you write(err, req, res)(omittingnext), Express treats it as regular middleware, not an error handler, and skips it. asyncfunctions don’t forward errors automatically — if anasyncroute throws, Express doesn’t catch it unless you explicitly callnext(err)or wrap the handler. Uncaught promise rejections are silently dropped in older Express versions.next()must be called — if a middleware doesn’t callnext()and doesn’t send a response, the request hangs forever.
Express’s middleware model is a linear pipeline built on function.length introspection. The framework distinguishes regular middleware ((req, res, next)) from error handlers ((err, req, res, next)) by counting declared arguments at registration time. That introspection is the root cause of the silent-skip behavior: write (err, req, res) with a TypeScript narrowing helper that strips the fourth parameter, and Express silently demotes your error handler to ordinary middleware. The framework does not warn you.
Async error propagation has a deeper history. Express 4 was designed in 2014, before native async/await existed, and the router never adopted a Promise-aware execution model. A rejected Promise returned from a route handler is unhandled and either crashes the process (Node 15+) or vanishes into an unhandledRejection event (older Node). Express 5 fixes this by awaiting handler return values, but until your package.json actually lists express@5, you must assume Promises do not bubble. This is the single largest source of “my error handler never runs” reports on Stack Overflow.
Mounting middleware on a path prefix also surprises developers coming from frameworks with explicit route DSLs. When you write app.use('/api', router), Express strips /api from req.url before invoking the router. Inside the router, paths must be written relative to the mount point. Forgetting this leads to handlers that never match because they expect /api/users but receive /users.
Fix 1: Register Middleware Before the Routes It Should Affect
Express runs middleware in registration order. Middleware must appear before the routes it needs to intercept:
const express = require('express');
const app = express();
// WRONG — middleware registered after the route
app.get('/profile', (req, res) => {
console.log(req.user); // undefined, middleware hasn't run yet
res.json({ user: req.user });
});
app.use((req, res, next) => { // Too late — /profile already matched
req.user = { id: 1, name: 'Alice' };
next();
});
// CORRECT — middleware registered before the route
app.use((req, res, next) => {
req.user = { id: 1, name: 'Alice' };
next();
});
app.get('/profile', (req, res) => {
console.log(req.user); // { id: 1, name: 'Alice' }
res.json({ user: req.user });
});Standard middleware registration order:
const app = express();
// 1. Built-in / third-party middleware first
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
app.use(helmet());
// 2. Logging
app.use(morgan('dev'));
// 3. Auth / session middleware
app.use(sessionMiddleware);
app.use(authMiddleware);
// 4. Route handlers
app.use('/api/users', userRouter);
app.use('/api/orders', orderRouter);
// 5. 404 handler — after all routes
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// 6. Error handler — must be last
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});Fix 2: Use the Correct Error-Handler Signature
Express identifies error-handling middleware by its 4-argument signature. All four parameters must be declared, even if you don’t use next:
// WRONG — 3 arguments, Express treats this as regular middleware
app.use((err, req, res) => {
res.status(500).send(err.message); // Never called for errors
});
// WRONG — named function but same problem
app.use(function errorHandler(err, req, res) {
res.status(500).send(err.message);
});
// CORRECT — 4 arguments, Express recognizes this as an error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
});Error handler must be registered last:
// All routes and middleware first
app.use('/api', apiRouter);
// Error handler at the bottom — after all routes
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err); // Delegate to default handler if response started
}
res.status(err.status || 500).json({ error: err.message });
});Note:
res.headersSentguards against writing to a response that’s already been started. Attempting to set headers afterres.send()throws aCannot set headers after they are senterror.
Fix 3: Propagate async Errors to next()
Express 4 doesn’t catch rejected promises in route handlers. You must forward async errors explicitly:
// WRONG — thrown error silently drops in Express 4
app.get('/users', async (req, res) => {
const users = await db.query('SELECT * FROM users'); // throws — never caught
res.json(users);
});
// CORRECT — catch and forward to next()
app.get('/users', async (req, res, next) => {
try {
const users = await db.query('SELECT * FROM users');
res.json(users);
} catch (err) {
next(err); // Forwards to the error-handling middleware
}
});Wrap async handlers to avoid repetition:
// asyncHandler wrapper — eliminates try/catch boilerplate
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Usage — clean, no try/catch needed
app.get('/users', asyncHandler(async (req, res) => {
const users = await db.query('SELECT * FROM users');
res.json(users);
}));
app.post('/users', asyncHandler(async (req, res) => {
const user = await db.create(req.body);
res.status(201).json(user);
}));Note: Express 5 (currently in beta) handles async errors automatically — rejected promises in route handlers are automatically forwarded to the next error handler. If you’re on Express 5, the
asyncHandlerwrapper is unnecessary.
Check your Express version:
npm list expressIf you see [email protected], async error forwarding is built in.
Fix 4: Apply Route-Specific Middleware Correctly
Middleware can be scoped to specific routes or passed inline as route-level middleware:
// WRONG — this mounts authMiddleware at /admin prefix
// but doesn't protect sub-routes unless the router handles it
app.use('/admin', authMiddleware);
app.get('/admin/dashboard', (req, res) => {
// authMiddleware ran, but only if it calls next() correctly
});
// CORRECT — inline middleware for specific routes
app.get('/admin/dashboard', authMiddleware, (req, res) => {
res.send('Dashboard');
});
// CORRECT — apply to a router, protecting all its routes
const adminRouter = express.Router();
adminRouter.use(authMiddleware); // Applies to all routes in this router
adminRouter.get('/dashboard', (req, res) => res.send('Dashboard'));
adminRouter.get('/users', (req, res) => res.send('Users'));
adminRouter.get('/settings', (req, res) => res.send('Settings'));
app.use('/admin', adminRouter);Multiple inline middleware:
app.post('/upload',
authenticate, // Check JWT
authorize('admin'), // Check role
upload.single('file'), // multer file upload
validateFileType, // Custom validation
async (req, res) => {
// All middleware passed — process the upload
res.json({ url: req.file.path });
}
);Fix 5: Common Middleware Misconfigurations
express.json() not applied — body is undefined:
// WRONG — no body parser
app.post('/data', (req, res) => {
console.log(req.body); // undefined
});
// CORRECT
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.post('/data', (req, res) => {
console.log(req.body); // { name: 'Alice', ... }
});CORS middleware applied too late:
// WRONG — CORS headers missing on preflight OPTIONS requests
app.use('/api', router);
app.use(cors()); // Too late
// CORRECT — CORS before routes
app.use(cors({
origin: process.env.ALLOWED_ORIGIN,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use('/api', router);Middleware that accidentally ends the request chain:
// BUG — sends response AND calls next()
app.use((req, res, next) => {
if (!req.headers.authorization) {
res.status(401).json({ error: 'Unauthorized' });
// Missing return — execution continues, next() also runs
next(); // Causes "Cannot set headers after they are sent"
} else {
next();
}
});
// CORRECT — return after sending response
app.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});Static files middleware ordering:
// WRONG — API routes after static files causes issues if paths overlap
app.use(express.static('public'));
app.use('/api', apiRouter);
// CORRECT — API routes first, static files as fallback
app.use('/api', apiRouter);
app.use(express.static('public'));Fix 6: Build a Production-Ready Middleware Stack
A complete, production-ready Express middleware configuration:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const app = express();
// Security headers
app.use(helmet());
// CORS — configure before routes
app.use(cors({
origin: process.env.CORS_ORIGIN?.split(',') || 'http://localhost:3000',
credentials: true,
}));
// Rate limiting — apply before body parsing to reduce DoS risk
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', limiter);
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Request ID — attach to all requests for tracing
app.use((req, res, next) => {
req.id = crypto.randomUUID();
res.setHeader('X-Request-Id', req.id);
next();
});
// Routes
app.use('/api/v1/users', usersRouter);
app.use('/api/v1/orders', ordersRouter);
app.use('/health', (req, res) => res.json({ status: 'ok' }));
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: `Cannot ${req.method} ${req.path}` });
});
// Centralized error handler
app.use((err, req, res, next) => {
// Log error with request context
console.error({
requestId: req.id,
method: req.method,
path: req.path,
error: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
});
if (res.headersSent) return next(err);
const status = err.status || err.statusCode || 500;
res.status(status).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
requestId: req.id,
});
});Creating reusable middleware with options:
// Middleware factory — accepts configuration
function requireRole(role) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (req.user.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Usage
app.get('/admin/users', authenticate, requireRole('admin'), listUsers);
app.get('/reports', authenticate, requireRole('manager'), getReports);Cross-Tool Comparison: Express vs Fastify vs Koa vs Hono vs NestJS
Express is the historical default, but every major Node framework has rethought the middleware model. The execution order rules from Express don’t always carry over, and porting code without understanding the differences produces subtle bugs.
Express uses a linear array of (req, res, next) functions. Order is registration order. Errors propagate by calling next(err), and only error-signature middleware (four arguments) catches them. Async support is opt-in until v5. The advantage is simplicity and a huge ecosystem; the disadvantage is that the contract is implicit and the framework can’t help you when you violate it.
Fastify replaces middleware with hooks, named lifecycle phases (onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse, onError). Each hook runs at a defined point in the request lifecycle and returns a Promise — async/await is the native model. The schema-based validation runs between phases, so you don’t write if (!body.email) return res.status(400) by hand. Migrating from Express usually means splitting one omnibus middleware into the right hook phase.
Koa uses an onion model with async generators (now async/await). Each middleware calls await next() and code after that call runs during the response phase. This makes timing, logging, and error wrapping trivial: wrap await next() in try/catch and you have a structured error handler with no special signature. Koa is closer to ASP.NET’s IApplicationBuilder.Use than to Express.
Hono also uses the onion model but is designed for edge runtimes (Cloudflare Workers, Deno Deploy, Bun). It uses a Context object instead of separate req/res, and middleware is (c, next) => Promise<void>. The execution model is closer to Koa than Express, with first-class TypeScript inference for path params and bodies. If you’ve ever fought Express’s req.body typing, Hono’s approach feels like a generation forward.
NestJS layers a dependency-injection framework on top of Express (or Fastify). Middleware in NestJS is one of five execution phases: middleware → guards → interceptors → pipes → controllers → exception filters. Each phase has a defined order, and DI containers wire them in. Auth almost always belongs in a guard (return true or throw UnauthorizedException), cross-cutting logging in an interceptor (wrap the response observable), and validation in a pipe. Forcing everything through plain Express-style middleware in NestJS works but throws away the framework’s structure.
Async ordering also differs. Express middleware runs sequentially via next() callbacks; Koa, Hono, Fastify hooks, and NestJS interceptors all use Promises natively. Error propagation likewise diverges: Express needs next(err), Koa and Hono let exceptions bubble through the await chain, Fastify routes errors to the onError hook, and NestJS routes them to exception filters. When porting code, redo the error-handling layer first — that’s where the bugs hide.
// Koa equivalent of Express's "log request duration" middleware
app.use(async (ctx, next) => {
const start = Date.now();
await next(); // Run downstream
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});The Express version requires registering one middleware to capture the start time and a separate res.on('finish') listener to record duration — onion-model frameworks fold both into one function.
Still Not Working?
Middleware runs but next() is never called — if a middleware sends a response (like res.json()) it must not call next(). And if it doesn’t send a response, it must call next(). Forgetting either causes requests to hang indefinitely. Always ensure one of these two things happens in every code path.
Router-level app.use() with a path prefix strips the prefix — when you mount a router with app.use('/api', router), the /api prefix is stripped before reaching the router. Inside the router, define routes as /users, not /api/users:
// router.js
router.get('/users', handler); // Correct — matches /api/users
// NOT
router.get('/api/users', handler); // Wrong — would match /api/api/usersMiddleware not running for OPTIONS requests — some authentication middleware returns early for non-GET/POST methods, accidentally blocking CORS preflight requests. Explicitly allow OPTIONS to pass through before auth checks:
app.use((req, res, next) => {
if (req.method === 'OPTIONS') return next(); // Let CORS handle it
authenticate(req, res, next);
});express-async-errors package as an alternative — instead of writing asyncHandler wrappers, install express-async-errors and require it once at the top of your app. It monkey-patches Express 4 to forward async errors automatically:
require('express-async-errors'); // Add this once, before route definitions
const express = require('express');
// Now async route errors are forwarded to the error handler automaticallyMiddleware order looks correct but a sub-router still skips it — express.Router() instances have their own middleware stack independent of the parent app. Mounting app.use(authMiddleware) does not retroactively apply auth to a router that was already wired up with app.use('/api', router). The parent middleware runs before the router dispatches, but if the router registered its own routes before you added the parent middleware, you’ll see inconsistent behavior across hot reloads. Always wire middleware top-down and finalize the app structure before serving requests.
Cluster mode swallows uncaught exceptions in a single worker — if you run Express under cluster or pm2, an uncaught synchronous error crashes only the affected worker. The master respawns it, so the request fails but the service stays up — which can mask middleware bugs because the error never reaches your central logger. Hook process.on('uncaughtException') in workers to forward to your logging pipeline, and treat any restart as a P1 alert until you’ve added a proper error handler.
For related Express issues, see Fix: Node.js Stream Error, Fix: Express Rate Limit Not Working, Fix: Express CORS Not Working, and Fix: Express Async Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Express req.body Is undefined
How to fix req.body being undefined in Express — missing body-parser middleware, wrong Content-Type header, middleware order issues, and multipart form data handling.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.