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 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

js
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)

js
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 handler

next('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

js
// 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

js
// 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

js
// 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

js
// 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 APIs
  • helmet: sets security HTTP headers (used in over 1 million npm projects)
  • passport.js: authentication via app.use(passport.initialize())
  • cors: cross-origin resource sharing for frontend/backend setups
  • express-rate-limit: protects /api routes from abuse
  • express.json(): replaced the old body-parser package (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

js
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

js
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

js
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 ready
Premium

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

Finished reading?