Node.js Interview Prep
Express.js Essentials

Middleware Deep Dive

Built-in, Third-Party, and Custom

LinkedIn Hook

"Your Express app is just a stack of middleware functions in a trench coat."

Every Express request flows through a pipeline. Each function in that pipeline can read the request, modify it, respond to it, or pass it down the line. That single concept — (req, res, next) — is the entire mental model behind Express. Master it, and routing, authentication, logging, error handling, and security all become trivial.

Yet most developers still treat middleware as black-box magic. They copy app.use(cors()) from Stack Overflow without knowing what it does. They forget express.json() and wonder why req.body is undefined. They put authentication checks AFTER the static file handler and accidentally serve protected files to anonymous users.

In Lesson 6.2, I break down every middleware type Express supports — built-in, third-party, custom, application-level, router-level, and error-handling — with the exact ordering rules that separate working APIs from broken ones.

Read the full lesson -> [link]

#NodeJS #ExpressJS #BackendDevelopment #WebDevelopment #InterviewPrep


Middleware Deep Dive thumbnail


What You'll Learn

  • How built-in middleware (express.json, express.urlencoded, express.static) parses requests and serves files
  • Which third-party packages every production Express app needs (cors, helmet, morgan, compression, cookie-parser)
  • How to write your own middleware for logging, authentication, and request IDs
  • The difference between application-level, router-level, and error-handling middleware
  • Why error-handling middleware needs a 4-argument signature
  • How to apply middleware conditionally — to a single route, a route group, or the whole app
  • Async middleware patterns and how to wrap async handlers safely

The Airport Security Analogy — Why Middleware Order Matters

Imagine an airport. To board your flight, you walk through a series of stations: check-in, baggage drop, ID verification, security screening, customs, boarding gate. Each station inspects you, possibly modifies your state (you lose your water bottle, you get a boarding pass stamp), then either passes you forward or rejects you entirely. The order is non-negotiable. You cannot reach the boarding gate before passing security. You cannot check in luggage after going through customs.

Express middleware works exactly like this. A request enters at the top of your app.js file and walks through every app.use() call in registration order. Each middleware can read the request, mutate it (attach req.user, parse req.body), respond to it (block the request with a 401), or call next() to pass control to the next station. The response then walks back through any middleware that registered a callback after next().

If you put the security checkpoint after the boarding gate, anyone can board. If you put app.use(express.json()) after your route handlers, req.body will be undefined in those handlers. If you put your authentication middleware after express.static, the entire public/ folder is exposed to the world. Order is the entire game.

+---------------------------------------------------------------+
|              EXPRESS MIDDLEWARE PIPELINE                       |
+---------------------------------------------------------------+
|                                                                |
|   Incoming Request                                             |
|         |                                                      |
|         v                                                      |
|   +-----------+    helmet()         <- security headers        |
|   +-----------+                                                |
|         | next()                                               |
|         v                                                      |
|   +-----------+    cors()           <- CORS headers            |
|   +-----------+                                                |
|         | next()                                               |
|         v                                                      |
|   +-----------+    morgan('dev')    <- request logger          |
|   +-----------+                                                |
|         | next()                                               |
|         v                                                      |
|   +-----------+    express.json()   <- body parser             |
|   +-----------+                                                |
|         | next()                                               |
|         v                                                      |
|   +-----------+    requestId()      <- custom: attach req.id   |
|   +-----------+                                                |
|         | next()                                               |
|         v                                                      |
|   +-----------+    authCheck()      <- custom: verify token    |
|   +-----------+                                                |
|         | next()                                               |
|         v                                                      |
|   +-----------+    Route Handler    <- /api/users, etc.        |
|   +-----------+                                                |
|         |                                                      |
|         | (error?) ----> errorHandler(err, req, res, next)     |
|         v                                                      |
|   Response                                                     |
|                                                                |
+---------------------------------------------------------------+

The Three Categories of Middleware

Before any code, fix this taxonomy in your head. Express middleware always falls into one of three buckets, and the bucket determines where it lives and what it can do.

+---------------------------------------------------------------+
|              MIDDLEWARE TAXONOMY                               |
+---------------------------------------------------------------+
|                                                                |
|  1. APPLICATION-LEVEL                                          |
|     app.use(middleware)                                        |
|     -> Runs for EVERY request to the app                       |
|     -> Examples: helmet, cors, body parsers                    |
|                                                                |
|  2. ROUTER-LEVEL                                               |
|     router.use(middleware)                                     |
|     -> Runs only for routes mounted on that router             |
|     -> Examples: auth check on /api/admin/*                    |
|                                                                |
|  3. ROUTE-LEVEL (per-route)                                    |
|     app.get('/path', middleware, handler)                      |
|     -> Runs only for that single endpoint                      |
|     -> Examples: validation on POST /users                     |
|                                                                |
|  4. ERROR-HANDLING (special case)                              |
|     app.use((err, req, res, next) => { ... })                  |
|     -> 4 arguments, MUST be defined LAST                       |
|     -> Triggered by next(err) anywhere in the pipeline         |
|                                                                |
+---------------------------------------------------------------+

The signatures are nearly identical. Regular middleware takes (req, res, next). Error-handling middleware takes (err, req, res, next). Express counts the function's argument length to decide which one it is — that 4th err parameter is what marks a function as an error handler.


Built-in Middleware — express.json, express.urlencoded, express.static

Express ships with three built-in middleware functions. Everything else used to live in a separate package called body-parser, but as of Express 4.16, the JSON and URL-encoded parsers are bundled directly into Express itself.

express.json + express.static

// app.js
// Built-in middleware demo: parsing JSON bodies and serving static files
const express = require('express');
const path = require('path');

const app = express();

// Parse JSON request bodies. Without this, req.body is undefined
// for any request with Content-Type: application/json.
app.use(express.json({
  limit: '1mb',           // Reject payloads larger than 1 MB
  strict: true,           // Only accept arrays and objects (not "true", "null")
}));

// Parse URL-encoded form bodies (e.g., HTML form submissions).
// extended: true uses the qs library, allowing nested objects.
app.use(express.urlencoded({
  extended: true,
  limit: '1mb',
}));

// Serve static files from the /public directory.
// IMPORTANT: place this BEFORE auth middleware if files are public,
// or AFTER auth middleware if files should be protected.
app.use(express.static(path.join(__dirname, 'public'), {
  maxAge: '1d',           // Browser cache for 1 day
  etag: true,             // Enable ETag headers for cache validation
}));

// A route that uses the parsed body
app.post('/api/echo', (req, res) => {
  // req.body is now a JavaScript object thanks to express.json()
  res.json({ youSent: req.body });
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

The express.static call points at a folder on disk. Any file inside /public is served directly — public/logo.png becomes http://localhost:3000/logo.png. There is no route handler involved; the middleware checks every incoming request against the file system and either serves a file or calls next() to let the next middleware handle it.


Third-Party Middleware Stack — The Production Essentials

Almost every real Express app uses the same five third-party packages. Together they form the standard "middleware stack" that you should set up in this exact order.

// app.js
// The production middleware stack — order matters
const express = require('express');
const helmet = require('helmet');           // Security headers
const cors = require('cors');               // Cross-origin resource sharing
const morgan = require('morgan');           // HTTP request logger
const compression = require('compression'); // gzip response bodies
const cookieParser = require('cookie-parser'); // Parse Cookie header

const app = express();

// 1. Helmet FIRST — sets security headers on every response.
// Includes Content-Security-Policy, X-Frame-Options, X-Content-Type-Options,
// Strict-Transport-Security, and ~12 other defensive headers.
app.use(helmet());

// 2. CORS — must come before route handlers so preflight OPTIONS
// requests are answered with the right Access-Control-* headers.
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,    // Allow cookies in cross-origin requests
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
}));

// 3. Compression — gzip response bodies before sending.
// Should run before any middleware that writes to the response.
app.use(compression({
  threshold: 1024,      // Only compress responses larger than 1 KB
}));

// 4. Morgan — log every incoming request.
// 'combined' = Apache combined log format, 'dev' = colorful dev output.
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));

// 5. Body parsers (built-in)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));

// 6. Cookie parser — populates req.cookies from the Cookie header.
// Pass a secret to also enable req.signedCookies.
app.use(cookieParser(process.env.COOKIE_SECRET));

app.get('/api/whoami', (req, res) => {
  res.json({
    cookies: req.cookies,
    signedCookies: req.signedCookies,
  });
});

app.listen(3000);

Why this order? Helmet first so security headers apply even to error responses. CORS before body parsers so preflight requests don't waste time parsing bodies. Compression before route handlers so responses are gzipped. Logging before body parsers so we capture every request even if parsing fails. Cookie parser anywhere before routes that read cookies.


Writing Custom Middleware — Request ID, Logger, Auth

Custom middleware is just a function with the signature (req, res, next). You can attach properties to req, modify res, log things, or short-circuit the pipeline by sending a response without calling next().

Custom requestId middleware

// middleware/requestId.js
// Attach a unique ID to every incoming request for tracing across logs
const { randomUUID } = require('crypto');

function requestId(req, res, next) {
  // Honor an upstream request ID if a load balancer / API gateway sent one,
  // otherwise generate a new UUID v4.
  const id = req.header('X-Request-Id') || randomUUID();

  // Attach to req so downstream handlers and loggers can read it
  req.id = id;

  // Echo back to the client for client-side correlation
  res.setHeader('X-Request-Id', id);

  // Pass control to the next middleware. Forgetting this is the #1
  // beginner mistake — the request will hang until it times out.
  next();
}

module.exports = requestId;
// middleware/logger.js
// Custom request logger that uses the request ID for correlation
function logger(req, res, next) {
  const start = Date.now();

  // Hook into the 'finish' event so we log AFTER the response is sent,
  // capturing the final status code and duration.
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(
      `[${req.id}] ${req.method} ${req.originalUrl} ` +
      `${res.statusCode} ${duration}ms`
    );
  });

  next();
}

module.exports = logger;
// middleware/authCheck.js
// Verify a Bearer token and attach the decoded user to req
const jwt = require('jsonwebtoken');

function authCheck(req, res, next) {
  const header = req.header('Authorization');

  // No header -> 401 and stop the pipeline (do NOT call next())
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing Bearer token' });
  }

  const token = header.slice('Bearer '.length);

  try {
    // Synchronous verify — throws on invalid signature or expiry
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;   // Attach for downstream handlers
    next();               // Pass control onward
  } catch (err) {
    // Pass error to the error-handling middleware via next(err)
    next(err);
  }
}

module.exports = authCheck;
// app.js — wiring the custom middleware
const express = require('express');
const requestId = require('./middleware/requestId');
const logger = require('./middleware/logger');
const authCheck = require('./middleware/authCheck');

const app = express();

app.use(requestId);   // Must be first so every log line has an ID
app.use(logger);
app.use(express.json());

// Public route — no auth
app.get('/health', (req, res) => res.json({ ok: true, id: req.id }));

// Protected routes — auth check applied per route
app.get('/api/me', authCheck, (req, res) => {
  res.json({ user: req.user, requestId: req.id });
});

Async Middleware — The Wrapper Pattern

Express 4 does not natively understand promises. If your async middleware throws or rejects, the error vanishes into the void and the request hangs forever. Express 5 fixes this, but until you upgrade you need a wrapper.

asyncHandler wrapper + async middleware

// utils/asyncHandler.js
// Wrap an async function so any thrown error or rejected promise
// is forwarded to Express's error-handling middleware via next(err).
function asyncHandler(fn) {
  return function wrapped(req, res, next) {
    // Promise.resolve() coerces sync functions to promises too,
    // so this wrapper works for both sync and async handlers.
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

module.exports = asyncHandler;
// routes/users.js — using the wrapper
const express = require('express');
const asyncHandler = require('../utils/asyncHandler');
const db = require('../db');

const router = express.Router();

// Async middleware: load the user from the DB and attach to req.
// Without asyncHandler, a thrown DB error would crash the process
// (or in Express 4, hang the request indefinitely).
const loadUser = asyncHandler(async (req, res, next) => {
  const user = await db.users.findById(req.params.id);

  if (!user) {
    // Short-circuit with a 404 — do NOT call next()
    return res.status(404).json({ error: 'User not found' });
  }

  req.targetUser = user;
  next();   // Continue to the route handler
});

// Async route handler also wrapped
router.get('/:id', loadUser, asyncHandler(async (req, res) => {
  const posts = await db.posts.findByUserId(req.targetUser.id);
  res.json({ user: req.targetUser, posts });
}));

module.exports = router;

In Express 5 (or with the express-async-errors package), you can drop the wrapper and write async (req, res, next) => { ... } directly. Until then, the wrapper is essential.


Router-Level Middleware — Scoping Middleware to a Route Group

Application-level middleware (app.use) runs for every request. But often you want middleware to apply only to a subset of routes — for example, an admin authentication check that should run only on /api/admin/*. That's what router-level middleware is for.

Router-scoped middleware example

// routes/admin.js
// Router-level middleware — applies only to routes mounted on this router
const express = require('express');

const router = express.Router();

// This middleware runs ONLY for requests that match this router's mount point.
// It does NOT affect /api/users, /api/products, or any other router.
router.use((req, res, next) => {
  // Only allow users with role === 'admin'
  if (!req.user || req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  next();
});

// All routes below inherit the admin check above
router.get('/dashboard', (req, res) => {
  res.json({ message: `Welcome, admin ${req.user.email}` });
});

router.delete('/users/:id', (req, res) => {
  res.json({ deleted: req.params.id });
});

module.exports = router;
// app.js — mounting the router with conditional middleware per route
const express = require('express');
const authCheck = require('./middleware/authCheck');
const adminRouter = require('./routes/admin');

const app = express();

app.use(express.json());

// Apply authCheck to ALL /api/* routes by mounting it on the path
app.use('/api', authCheck);

// Mount the admin router. The admin router's internal middleware
// (the role check) only runs for /api/admin/* routes.
app.use('/api/admin', adminRouter);

// Per-route middleware: validation runs ONLY for POST /api/products
function validateProduct(req, res, next) {
  if (!req.body.name || !req.body.price) {
    return res.status(400).json({ error: 'name and price required' });
  }
  next();
}

app.post('/api/products', validateProduct, (req, res) => {
  res.status(201).json({ created: req.body });
});

// Error-handling middleware — MUST be defined LAST and have 4 args
app.use((err, req, res, next) => {
  console.error(`[${req.id}] Error:`, err.message);

  // Don't leak stack traces in production
  const message = process.env.NODE_ENV === 'production'
    ? 'Internal server error'
    : err.message;

  res.status(err.status || 500).json({ error: message });
});

app.listen(3000);

Notice the layering: authCheck runs for every /api/* request, then for /api/admin/* requests the admin router's internal role check runs on top of it, and POST /api/products additionally runs validateProduct before the handler. Each route ends up with exactly the middleware it needs.


Common Mistakes

1. Forgetting express.json() and wondering why req.body is undefined. This is the most common Express bug on Stack Overflow. If you POST JSON to an Express route without app.use(express.json()) registered first, req.body is undefined and you get a Cannot read properties of undefined error. Always register body parsers near the top of your middleware stack, before any routes that read req.body.

2. Putting authentication AFTER express.static. If app.use(express.static('public')) is registered before your auth middleware, the entire public/ folder is publicly accessible — including any private files someone might drop in there by accident. Either keep the static folder strictly public, or register authCheck before express.static and split static assets into separate public/private folders.

3. Forgetting to call next() (or calling it twice). If your middleware doesn't call next() and doesn't send a response, the request hangs until the client times out. If it calls next() AND sends a response, you'll get the dreaded Cannot set headers after they are sent to the client error. Rule of thumb: every middleware path should either call next() exactly once OR send a response exactly once — never both, never neither.

4. Defining error-handling middleware with only 3 arguments. Express identifies error handlers by counting function arguments. (err, req, res, next) is an error handler; (req, res, next) is regular middleware. If you write app.use((req, res, next) => { ... }) and expect it to catch errors, it won't. The 4-argument signature is mandatory, even if you don't use next inside the body.

5. Registering error-handling middleware too early. Error handlers must be registered AFTER all routes and other middleware. If you app.use(errorHandler) at the top of app.js, errors thrown by routes registered later will not reach it — Express only forwards errors to handlers that come after the throwing middleware in registration order.

6. Not wrapping async handlers in Express 4. An async function that throws (or returns a rejected promise) without an asyncHandler wrapper will not trigger your error middleware. The error gets swallowed and the request hangs. Use the asyncHandler wrapper, the express-async-errors package, or upgrade to Express 5.


Interview Questions

1. "What is middleware in Express, and what does the next function do?"

Middleware is a function with the signature (req, res, next) that sits in Express's request pipeline. Each request flows through every registered middleware in order. A middleware can read or modify the request and response, end the request by sending a response, or pass control to the next middleware by calling next(). The next function is how Express knows you're done with your work and ready for the next station to run. If you never call next() and never send a response, the request hangs forever. If you call next(err) with an argument, Express skips all remaining regular middleware and jumps directly to the next error-handling middleware.

2. "What's the difference between application-level, router-level, and error-handling middleware?"

Application-level middleware is registered with app.use() and runs for every request to the app (or every request matching a path prefix if you pass one). Router-level middleware is registered with router.use() on an express.Router() instance and runs only for routes mounted on that specific router — it's how you scope middleware to a feature area like /api/admin. Error-handling middleware has a four-argument signature (err, req, res, next) and is invoked only when an earlier middleware passes an error via next(err). Express identifies it by argument count, so the 4th err parameter is mandatory. Error handlers must be registered last so they sit at the end of the pipeline.

3. "Why does the order of app.use() calls matter?"

Express processes middleware in registration order. A request walks down the stack from top to bottom, hitting each app.use() in sequence. This means a middleware can only see requests that have passed through everything registered above it — and it can only affect responses generated by code below it. If you register express.json() after your route handlers, those handlers will see req.body as undefined because the parser hasn't run yet. If you register an authentication check after express.static, the entire static folder is exposed because the static handler responds before auth gets a chance to block. Order isn't a stylistic choice; it's how the entire pipeline works.

4. "How does error-handling middleware work, and why does it need four arguments?"

Express looks at every registered middleware's function.length property to decide what kind of middleware it is. A function with 3 parameters is treated as regular middleware. A function with 4 parameters — specifically (err, req, res, next) — is treated as an error handler. When any middleware calls next(err) with an argument, Express skips all remaining regular middleware and jumps to the next function in the stack with 4 parameters. Inside the error handler, you can inspect the error, log it, set a status code, and send an error response. The 4-argument signature is mandatory even if you don't use next in the body — Express won't recognize it as an error handler otherwise.

5. "How do you handle async errors in Express middleware?"

In Express 4, async functions don't automatically propagate errors. If an async middleware throws or returns a rejected promise, the error is not caught by Express and the request hangs until it times out. The standard fix is a wrapper function — typically called asyncHandler — that wraps the async function and attaches a .catch(next) to the returned promise, forwarding any error to the error-handling middleware. Alternatively, you can use the express-async-errors package, which monkey-patches Express to handle this automatically. Express 5 (currently in release candidate) handles promise rejections natively, so you can write async (req, res, next) => { ... } without any wrapper. Until you're on Express 5, the wrapper is the safest approach.


Quick Reference — Middleware Cheat Sheet

+---------------------------------------------------------------+
|              MIDDLEWARE CHEAT SHEET                            |
+---------------------------------------------------------------+
|                                                                |
|  BUILT-IN:                                                     |
|  app.use(express.json())                                       |
|  app.use(express.urlencoded({ extended: true }))               |
|  app.use(express.static('public'))                             |
|                                                                |
|  THIRD-PARTY (production stack):                               |
|  app.use(helmet())          <- security headers                |
|  app.use(cors({ origin }))  <- CORS                            |
|  app.use(compression())     <- gzip                            |
|  app.use(morgan('combined'))<- logging                         |
|  app.use(cookieParser())    <- req.cookies                     |
|                                                                |
|  CUSTOM:                                                       |
|  function mw(req, res, next) { ...; next() }                   |
|  app.use(mw)                                                   |
|                                                                |
|  ERROR HANDLER (4 args, defined LAST):                         |
|  app.use((err, req, res, next) => { ... })                     |
|                                                                |
|  ROUTER-LEVEL:                                                 |
|  const r = express.Router()                                    |
|  r.use(authCheck)                                              |
|  app.use('/api/admin', r)                                      |
|                                                                |
|  PER-ROUTE:                                                    |
|  app.post('/items', validate, handler)                         |
|                                                                |
|  ASYNC WRAPPER:                                                |
|  const ah = fn => (req, res, next) =>                          |
|    Promise.resolve(fn(req, res, next)).catch(next)             |
|  app.get('/x', ah(async (req, res) => { ... }))                |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|              ORDERING RULES                                    |
+---------------------------------------------------------------+
|                                                                |
|  1. helmet FIRST (security headers on every response)          |
|  2. cors before body parsers (preflight efficiency)            |
|  3. compression before route handlers                          |
|  4. morgan before body parsers (log even on parse fail)        |
|  5. body parsers before routes that read req.body              |
|  6. auth before protected routes (and before static if needed) |
|  7. routes in the middle                                       |
|  8. 404 handler near the end                                   |
|  9. error-handling middleware ABSOLUTELY LAST                  |
|                                                                |
+---------------------------------------------------------------+
TypeSignatureWhere DefinedRuns When
Application-level(req, res, next)app.use(...)Every request (or path prefix)
Router-level(req, res, next)router.use(...)Requests to that router's mount point
Route-level(req, res, next)app.METHOD(path, mw, handler)That single route only
Error-handling(err, req, res, next)app.use(...) LASTWhen next(err) is called
Built-invariesbundled with ExpressConfigured manually
Third-partyvariesfrom npm packagesConfigured manually

Prev: Lesson 6.1 -- Express Basics & Middleware Next: Lesson 6.3 -- Routing Organization


This is Lesson 6.2 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.

On this page