What is middleware in Express.js and how does it work?
Middleware in Express.js is a function that sits between an incoming HTTP request and the route handler, with access to req, res, and a next() function to pass control forward.
Theory
TL;DR
- Airport security line: each checkpoint (middleware) inspects your bag (
req), stamps your ticket (res), waves you through (next()). Any checkpoint can stop you. - Middleware runs in the order you register it. Order matters.
- Call
next()to pass control forward. Skip it and the request hangs. - Error middleware takes 4 args:
(err, req, res, next). Always place it last. - Shared logic across routes (logging, auth) goes in middleware. Business logic goes in the route handler.
Quick example
const express = require('express');
const app = express();
// Logging middleware - runs on every request
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`); // GET /users
next(); // pass control to the next function
});
// Auth middleware - runs only on /users
app.use('/users', (req, res, next) => {
if (req.headers.authorization) {
next(); // token found, proceed
} else {
res.status(401).send('Unauthorized'); // stops here
}
});
// Route handler - only reached if auth passes
app.get('/users', (req, res) => {
res.json({ users: ['Alice', 'Bob'] });
});
app.listen(3000);GET /users with a valid auth header: logs the request, passes auth check, returns users. Without the header: 401, done.
How the pipeline works
Express builds a stack of middleware functions during app setup, stored as layers in app._router.stack. When a request arrives, Node's http.Server calls app.handle(req, res), which walks the stack one layer at a time. Each layer executes its function and passes next as a dispatcher to advance to the next matching layer or route.
Async middleware does not call next() automatically. You have to do it manually, or the request hangs waiting for a response that never arrives. I have seen this cause multi-hour debugging sessions in codebases where middleware was written by developers new to Express.
Middleware types
Application-level middleware runs on every request (or every request matching a path prefix) when registered with app.use().
Route-level middleware applies only to specific routes: app.get('/path', middleware, handler). You can chain multiple middleware functions before the final handler.
Error-handling middleware has exactly 4 parameters: (err, req, res, next). Express recognizes the 4-arg signature and routes errors there when you call next(err).
Built-in: express.json(), express.urlencoded(), express.static() cover the most common needs without extra packages.
Third-party: morgan for logging, helmet for security headers, cors for cross-origin requests, express-rate-limit for rate limiting.
When to use
- Cross-cutting concerns (logging, CORS, security headers):
app.use()at the top of your file. - Route-specific checks (auth, request validation): pass middleware directly into the route definition.
- Error recovery:
app.use((err, req, res, next) => {...})placed after all routes. - Static file serving:
express.static('public'). - Business logic belongs in route handlers, not middleware.
next(), next('route'), and next(err)
next() // proceed to the next middleware or route handler
next('route') // skip remaining middleware in this stack, jump to next route match
next(err) // jump directly to the error handlernext('route') is a senior-level detail. It lets you write fallback routes: run some middleware, decide the current route should not handle this request, and skip to the next matching route definition.
Common mistakes
Forgetting next() in async middleware
// Wrong - request hangs forever
app.use(async (req, res, next) => {
req.data = await db.query('SELECT * FROM users');
// no next() call
});
// Correct
app.use(async (req, res, next) => {
try {
req.data = await db.query('SELECT * FROM users');
next();
} catch (err) {
next(err);
}
});Express processes middleware synchronously. An async function returns a Promise immediately, but Express does not wait for it. The await resolves, but next() was never called.
Registering global middleware after routes
// Wrong - logger never runs for /users
app.get('/users', handler);
app.use(logger);
// Correct
app.use(logger);
app.get('/users', handler);Routes match first. Any app.use() registered after a matching route is skipped for that route.
Calling next() twice
// Wrong - triggers next middleware twice
app.use((req, res, next) => {
next();
next(); // second call corrupts the stack
});This causes duplicate processing, duplicate log entries, and "headers already sent" errors. Call next() exactly once per middleware function.
Error handler placed before routes
// Wrong
app.use(errorHandler);
app.get('/users', handler);
// Correct - error handler is always last
app.get('/users', handler);
app.use(errorHandler);Wrong dependency order on req
If your middleware reads req.body, make sure express.json() runs first. Otherwise req.body is undefined and your middleware fails silently.
Real-world usage
morgan: HTTP request logging in production APIshelmet: sets security HTTP headers (used in over 1 million npm projects)passport.js: authentication viaapp.use(passport.initialize())cors: cross-origin resource sharing for frontend/backend setupsexpress-rate-limit: protects/apiroutes from abuseexpress.json(): replaced the oldbody-parserpackage (built-in since Express 4.16)
Follow-up questions
Q: What is the difference between app.use() and router.use()?
A: app.use() registers middleware globally on the application. router.use() scopes it to a sub-router, so it only runs for routes defined on that router. Useful when splitting a large API into separate modules.
Q: Can middleware use async/await? What are the pitfalls?
A: Yes. But you must call next() manually after async work finishes and wrap everything in try/catch to call next(err) on failure. Unhandled rejections crash the Node process in version 15+ and silently drop requests in older versions.
Q: How does next('route') work?
A: It skips all remaining middleware in the current route stack and jumps to the next matching route definition. Useful for conditional routing, such as checking a feature flag and falling through to a default handler if the flag is off.
Q: What happens if you call res.send() and then next()?
A: The response is already sent. next() advances the stack, but any middleware that tries to write to res again gets a "headers already sent" error. Add return before res.send() to prevent this.
Q: How much does a large middleware stack affect performance?
A: Very little. The synchronous loop through the stack takes roughly 1 microsecond per layer. The real bottleneck is async I/O, not stack depth. Measure with clinic.js if you suspect overhead.
Examples
Basic: request logging and auth gate
const express = require('express');
const app = express();
app.use(express.json());
// Logger - records method, url, and marks start time
app.use((req, res, next) => {
req.startTime = Date.now();
console.log(`--> ${req.method} ${req.url}`);
next();
});
// Auth check for protected routes
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Token required' });
req.user = { id: 42 }; // in real code: verify the JWT here
next();
}
app.get('/public', (req, res) => {
res.json({ message: 'Anyone can see this' });
});
app.get('/private', requireAuth, (req, res) => {
res.json({ message: 'Hello', userId: req.user.id });
});
app.listen(3000);The logger runs on both routes. requireAuth runs only on /private. If the token is missing, the route handler never executes.
Intermediate: async middleware with proper error handling
const fakeDB = {
findUser: async (token) => {
if (token === 'valid') return { id: 1, name: 'Alice', admin: true };
throw new Error('User not found');
}
};
app.get('/profile',
// Step 1: load user from DB based on token
async (req, res, next) => {
try {
req.user = await fakeDB.findUser(req.query.token);
next();
} catch (err) {
next(err); // sends to error handler, not res.send()
}
},
// Step 2: check admin access
(req, res, next) => {
if (!req.user.admin) {
return next(new Error('Access denied'));
}
next();
},
// Step 3: respond
(req, res) => {
res.json(req.user);
}
);
// Central error handler - must be last, must have 4 args
app.use((err, req, res, next) => {
console.error(err.message);
res.status(500).json({ error: err.message });
});Without next(err) in the async middleware, an unhandled promise rejection crashes the process (Node 15+) or silently drops the request in older versions. The explicit try/catch with next(err) is the correct pattern.
Senior: rate limiter with request context
const express = require('express');
const app = express();
// Attaches timing and user ID to every request object
app.use((req, res, next) => {
req.startTime = Date.now();
req.userId = req.headers['x-user-id'] || 'anonymous';
next();
});
// Simple in-memory rate limiter for /api routes
const requestCounts = {};
app.use('/api', (req, res, next) => {
const key = req.userId;
requestCounts[key] = (requestCounts[key] || 0) + 1;
if (requestCounts[key] > 100) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
app.get('/api/users/:id', (req, res) => {
const elapsed = Date.now() - req.startTime;
console.log(`${req.userId} fetched user ${req.params.id} in ${elapsed}ms`);
res.json({ id: req.params.id, name: 'Jane' });
});
app.listen(3000);The first middleware enriches req with context that later middleware and handlers read freely. This is a common pattern in production APIs: one middleware populates req.user, req.requestId, req.startTime, and the rest of the stack uses those values without re-fetching anything.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.