Skip to main content

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 your route handler, with access to the req object, the res object, and a next callback.

Theory

TL;DR

  • Think of middleware as airport security checkpoints: every request walks through them in registration order, and each checkpoint can inspect, modify, or stop the request before handing it off.
  • Core mechanic: call next() to continue the chain, send a response to end it.
  • Use for logic that applies to many routes (logging, auth, CORS). For single-route logic, a direct handler is simpler.
  • Error middleware takes four arguments (err, req, res, next) and must be registered after all other middleware.

Quick example

javascript
const express = require('express'); const app = express(); // Middleware 1: logs request time app.use((req, res, next) => { console.log('Request at:', new Date().toISOString()); next(); }); // Middleware 2: attaches user data to req app.use((req, res, next) => { req.user = { id: 123 }; next(); }); // Route handler sees the modified req app.get('/', (req, res) => { res.json({ message: 'Hello', userId: req.user.id }); // Output: { message: 'Hello', userId: 123 } }); app.listen(3000);

Both middleware functions run before the route handler. The second one attaches req.user, so the handler reads it without needing to know how it got there. That shared req object is the whole point of the pattern.

How Express chains middleware

Every app.use(fn) pushes fn onto an internal stack array. When a request arrives, Express iterates that array in registration order, calling each function. Each function decides what happens next: call next() to continue, call res.send() to end the cycle, or call next(err) to jump to error handling.

The req and res objects travel through the entire chain unchanged in identity. Any property you attach in middleware 1 is still there in middleware 5. That is how auth middleware can set req.user and every downstream handler can read it without any extra work.

When to use

  • Request logging for all routes: register once with app.use() at the top of the file.
  • JWT verification before protected routes: pass the middleware function directly to the route or to a router group.
  • Body parsing: app.use(express.json()) runs before any handler sees the request body.
  • CORS headers: middleware that adds headers and calls next(), or returns early for OPTIONS requests.
  • Async error catching: four-argument error middleware registered at the very end.

Skip middleware when the logic applies to exactly one route with no reuse potential. A direct handler keeps things obvious.

How the stack works internally

Node.js passes each HTTP request to a single http.ServerRequest listener. Express attaches itself there and, on each request, walks its internal stack synchronously via next() calls. Each call advances the pointer to the next item in the array. Blocking code stalls every request because Node is single-threaded. If you want to understand why that matters at scale, the event loop article covers it in detail.

Express 5 (release candidate as of 2024) catches rejected promises from async middleware automatically. In Express 4, you must wrap async code in try/catch and call next(err) manually. Most production codebases I have seen still run Express 4, so that pattern matters and comes up in almost every senior interview about Express.

Common mistakes

Calling next() twice

javascript
// Wrong app.use((req, res, next) => { next(); next(); // triggers downstream middleware a second time });

The second call re-invokes the next function in the stack. This causes duplicate log entries, double database queries, and "headers already sent" errors. Call next() exactly once, or not at all if you are sending a response.

Registering error middleware too early

javascript
// Wrong: logger never runs when errors occur app.use(errorHandler); app.use(logger);

Express identifies error middleware by its four-argument signature. Registering it early means it catches errors before the rest of the chain runs. It must be last.

Modifying req or res after sending a response

javascript
app.use((req, res, next) => { res.send('Done'); req.foo = 'bar'; // silently ignored next(); // no effect });

After res.send(), the response is committed. Further changes to req or res are lost without any error. Check res.headersSent if the flow is unclear.

Blocking I/O inside middleware

javascript
// Wrong app.use((req, res, next) => { const data = fs.readFileSync('/large-file'); // holds all requests next(); });

Use fs.promises.readFile with await instead. Sync I/O in middleware is one of the most common performance problems in Express apps.

Missing try/catch in async middleware (Express 4)

javascript
// Wrong in Express 4 - unhandled rejection, error handler skipped app.use(async (req, res, next) => { await fetchUser(); // if this rejects, nothing catches it next(); }); // Correct app.use(async (req, res, next) => { try { await fetchUser(); next(); } catch (err) { next(err); // routes to the error handler } });

Unhandled async rejections in Express 4 print a warning but skip the error handler entirely. If you are not sure how async/await errors propagate, the async/await article walks through the mechanics.

Real-world usage

  • express.json(): built-in middleware that parses JSON request bodies before handlers see them.
  • helmet: app.use(helmet()) sets a set of security headers in one line. Used in the majority of production Express apps.
  • morgan: app.use(morgan('combined')) for structured HTTP request logging.
  • passport.authenticate('jwt'): auth middleware that verifies tokens and populates req.user.
  • Mongoose pre/post hooks: the same conceptual pattern at the schema level, but outside Express itself.

Follow-up questions

Q: What is the execution order when both app.use() and router-level middleware are present?
A: App-level middleware runs first in registration order, then router-level, then route-specific handlers. Within each level, registration order determines execution order.

Q: How does Express 4 handle async errors differently from Express 5?
A: Express 4 requires manual try/catch with next(err). Express 5 catches rejected promises from async middleware automatically, removing the need for wrappers.

Q: Can middleware access req.params?
A: It depends on registration. Top-level app.use() without a path pattern gets req.params as an empty object. Route-specific middleware and router middleware receive the params defined in the path.

Q: How do you skip remaining middleware without sending a response?
A: Call next('route') to skip to the next matching route handler. There is no way to skip silently without either sending a response or using next('route').

Q (senior): In a clustered Node.js setup, does state stored on req survive across workers?
A: No. req lives only within a single request-response cycle on a single worker process. For data that needs to persist across requests or workers, use a session store backed by Redis or a database. Treating middleware state as stateless is the correct model for any production deployment.

Examples

Logging and request enrichment

javascript
const express = require('express'); const app = express(); app.use((req, res, next) => { req.startTime = Date.now(); console.log(`[${req.method}] ${req.url}`); next(); }); app.use((req, res, next) => { req.requestId = Math.random().toString(36).slice(2); next(); }); app.get('/status', (req, res) => { res.json({ requestId: req.requestId, elapsed: Date.now() - req.startTime, }); }); app.listen(3000);

Two middleware functions add data to req. The handler reads both values without caring where they came from. This separation keeps handlers focused on one job and makes each middleware independently testable.

Auth and CORS in production

javascript
const express = require('express'); const app = express(); // CORS middleware app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Authorization, Content-Type'); if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); // JWT auth middleware const authenticate = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token' }); // Production: jwt.verify(token, process.env.SECRET, callback) req.user = { id: 1, role: 'admin' }; // simulated next(); }; app.get('/protected', authenticate, (req, res) => { res.json({ data: 'Secret', user: req.user }); }); app.listen(3000);

CORS middleware handles preflight requests and ends the cycle there. Auth middleware is passed only to routes that need it. The return before res.status(401) prevents next() from also being called after the response is sent.

Async error handling with a wrapper

javascript
const express = require('express'); const app = express(); // Helper that wraps async handlers for Express 4 const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.use( asyncHandler(async (req, res, next) => { const user = await fetchUserFromDB(req.headers['x-user-id']); if (!user) return res.status(404).json({ error: 'Not found' }); req.user = user; next(); }) ); app.get('/me', (req, res) => { res.json({ user: req.user }); }); // Error handler - always last app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: 'Internal error' }); }); app.listen(3000);

The asyncHandler wrapper catches any rejected promise and forwards it to next, routing it to the error handler. This pattern is standard in Express 4 codebases. Express 5 removes the need for the wrapper entirely.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?