DEV Community

Cover image for What is Middleware in Express and How It Works
Akash Kumar
Akash Kumar

Posted on

What is Middleware in Express and How It Works

"Middleware is the invisible assembly line your request travels through before it ever reaches your route."


Introduction

You've set up an Express server. You have routes. Things work. But then real-world requirements show up — you need to log every incoming request, verify that users are authenticated, validate request bodies, handle errors gracefully. Where does all that logic go?

The answer is middleware — one of the most powerful and fundamental concepts in Express.js. Once you understand middleware, you stop writing tangled route handlers and start building clean, composable server applications.

This guide walks through what middleware is, how it fits into the request lifecycle, the different types available, and how to use it in practice.


1. What Is Middleware?

In Express, middleware is a function that has access to the request object (req), the response object (res), and a special function called next().

Every middleware function can do one of three things:

  • Execute any code — logging, validation, authentication checks
  • Modify req or res — attach data, set headers, parse a body
  • End the request-response cycle — send back a response and stop
  • Call next() — pass control to the next middleware in line

Think of middleware as a series of checkpoints a request must pass through before it reaches its final destination — your route handler.

// The shape of every middleware function
function myMiddleware(req, res, next) {
  // Do something with the request or response
  console.log("Checkpoint reached!");

  // Then either end the cycle...
  // res.send("Stopped here");

  // ...or pass control to the next middleware
  next();
}
Enter fullscreen mode Exit fullscreen mode

2. Where Middleware Sits in the Request Lifecycle

Every time a client sends a request to your Express server, that request doesn't jump straight to your route handler. It travels through a pipeline — a chain of middleware functions executed in order, one after another.

CLIENT REQUEST
     │
     ▼
┌─────────────────────────────────────────────────────────────┐
│                   EXPRESS APPLICATION                       │
│                                                             │
│  ┌──────────────┐   ┌──────────────┐   ┌────────────────┐  │
│  │  Middleware  │   │  Middleware  │   │  Middleware    │  │
│  │      1       │──►│      2       │──►│       3        │  │
│  │   (logger)   │   │   (auth)     │   │  (validator)   │  │
│  └──────────────┘   └──────────────┘   └────────────────┘  │
│         │                 │                    │            │
│     next() called     next() called        next() called   │
│                                                 │           │
│                                        ┌────────────────┐  │
│                                        │  Route Handler │  │
│                                        │  GET /profile  │  │
│                                        └────────────────┘  │
│                                                 │           │
└─────────────────────────────────────────────────┼───────────┘
                                                  │
                                                  ▼
                                           CLIENT RESPONSE
Enter fullscreen mode Exit fullscreen mode

Each middleware in the chain either passes the baton (calls next()) or ends the race (sends a response). If a middleware doesn't call next() and doesn't send a response, the request just... hangs. That's a bug worth memorizing.

The pipeline analogy: Imagine your request is water flowing through pipes. Each pipe fitting (middleware) can filter it, add something to it, or block it entirely. Your route handler is the tap at the end — it only receives water that made it through every fitting before it.


3. Types of Middleware in Express

Express organizes middleware into four categories. Understanding which type to reach for is key to keeping your code organized.

Application-Level Middleware

This middleware is bound directly to your app instance using app.use() or app.METHOD(). It runs for every request that matches its path (or all requests, if no path is given).

const express = require("express");
const app = express();

// Runs on EVERY incoming request (no path specified)
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
});

// Runs only for requests to /dashboard and its sub-paths
app.use("/dashboard", (req, res, next) => {
  console.log("Dashboard section accessed");
  next();
});
Enter fullscreen mode Exit fullscreen mode

When to use it: Global concerns — logging, parsing request bodies, setting security headers — anything that should run across all (or most) routes.


Router-Level Middleware

Router-level middleware works identically to application-level middleware, but it's bound to an Express Router instance (express.Router()). This lets you group related routes and their middleware together into modular units.

const express = require("express");
const router = express.Router();

// Middleware scoped only to this router
router.use((req, res, next) => {
  console.log("User router middleware running");
  next();
});

router.get("/profile", (req, res) => {
  res.send("User profile page");
});

router.get("/settings", (req, res) => {
  res.send("User settings page");
});

// Mount the router on the app
app.use("/users", router);
Enter fullscreen mode Exit fullscreen mode

When to use it: Feature-specific concerns — auth checks that only apply to /admin routes, validation that only applies to /api routes. Keeps your app.js clean and each feature self-contained.


Built-in Middleware

Express ships with a small set of built-in middleware functions that handle common tasks out of the box.

// Parse incoming JSON request bodies
// Attaches parsed data to req.body
app.use(express.json());

// Parse URL-encoded form data (from HTML forms)
app.use(express.urlencoded({ extended: true }));

// Serve static files (HTML, CSS, images) from a directory
app.use(express.static("public"));
Enter fullscreen mode Exit fullscreen mode

These three cover the vast majority of what you need for a standard web application or REST API. Always add express.json() before any route that expects a JSON body — otherwise req.body will be undefined.

When to use it: Body parsing and static file serving. These almost always appear at the top of every Express application.


Error-Handling Middleware

Error-handling middleware has a special signature — it takes four parameters: (err, req, res, next). Express recognizes the four-argument signature and only calls this middleware when an error occurs.

// Must be registered LAST, after all other app.use() and routes
app.use((err, req, res, next) => {
  console.error("Error caught:", err.message);
  res.status(err.status || 500).json({
    error: err.message || "Internal Server Error"
  });
});
Enter fullscreen mode Exit fullscreen mode

To trigger it from anywhere in your application, call next(error) and pass an error object:

app.get("/data", (req, res, next) => {
  try {
    // Something that might fail
    const result = riskyOperation();
    res.json(result);
  } catch (err) {
    next(err); // Skips all regular middleware and jumps to error handler
  }
});
Enter fullscreen mode Exit fullscreen mode

When to use it: Centralizing error responses. Without it, you'd write the same res.status(500) block in every route.


4. Execution Order: Order Is Everything

Middleware in Express runs in the exact order it is registered. This is not a minor implementation detail — it's the defining rule of the middleware system.

const app = express();

app.use((req, res, next) => {
  console.log("Step 1: Logger");
  next();
});

app.use((req, res, next) => {
  console.log("Step 2: Auth check");
  next();
});

app.use((req, res, next) => {
  console.log("Step 3: Request validation");
  next();
});

app.get("/profile", (req, res) => {
  console.log("Step 4: Route handler");
  res.send("Profile data");
});

// Console output for GET /profile:
// Step 1: Logger
// Step 2: Auth check
// Step 3: Request validation
// Step 4: Route handler
Enter fullscreen mode Exit fullscreen mode

A common source of bugs is registering middleware after the routes it's meant to protect. If you put your auth check below your route, it simply won't run for that route.

// ❌ WRONG — auth middleware registered too late
app.get("/secret", (req, res) => res.send("Secret data"));
app.use(authMiddleware); // This never runs for /secret

// ✅ CORRECT — auth middleware registered before the route
app.use(authMiddleware);
app.get("/secret", (req, res) => res.send("Secret data"));
Enter fullscreen mode Exit fullscreen mode

5. The Role of next()

next() is the function that keeps the pipeline moving. Without it, your request stalls permanently.

Middleware calls next()   →  Next middleware runs
Middleware sends response →  Pipeline ends (no more middleware runs)
Middleware does neither   →  Request hangs forever (a bug)
Enter fullscreen mode Exit fullscreen mode

Passing an Error with next(err)

Calling next() with no argument moves to the next regular middleware. Calling next(err) with an argument skips all regular middleware and jumps directly to the nearest error-handling middleware.

// Middleware chain execution paths:

//  next()          ──►  Next regular middleware or route handler
//  next(new Error) ──►  Skips everything → error handler
//  res.send()      ──►  Ends the request-response cycle entirely
Enter fullscreen mode Exit fullscreen mode
function checkApiKey(req, res, next) {
  const apiKey = req.headers["x-api-key"];

  if (!apiKey) {
    // Skip remaining middleware, jump to error handler
    return next(new Error("API key required"));
  }

  // All good — continue down the chain
  next();
}
Enter fullscreen mode Exit fullscreen mode

Always return when calling next() early. Without return, the rest of the middleware function keeps executing after next(), which can cause double-response errors (Cannot set headers after they are sent).


6. Real-World Examples

Logging Middleware

Logs every request with its method, URL, and how long the server took to respond.

function requestLogger(req, res, next) {
  const start = Date.now();

  // Hook into the response finish event to capture response time
  res.on("finish", () => {
    const duration = Date.now() - start;
    console.log(
      `${req.method} ${req.url}${res.statusCode} (${duration}ms)`
    );
  });

  next(); // Always call next — logger should never block a request
}

app.use(requestLogger);

// Output example:
// GET /users → 200 (14ms)
// POST /login → 401 (3ms)
// GET /profile → 200 (22ms)
Enter fullscreen mode Exit fullscreen mode

Authentication Middleware

Checks for a valid JWT token in the Authorization header. Blocks the request with a 401 if invalid, or attaches the decoded user to req.user and continues.

const jwt = require("jsonwebtoken");

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  // Check header exists and has the right format
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "No token provided" });
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Attach user data for downstream handlers
    next();
  } catch (err) {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

// Applied selectively to protected routes only
app.get("/profile", authenticate, (req, res) => {
  res.json({ message: `Welcome, ${req.user.name}!` });
});

app.get("/settings", authenticate, (req, res) => {
  res.json({ settings: getUserSettings(req.user.id) });
});
Enter fullscreen mode Exit fullscreen mode

Request Validation Middleware

Validates that a request body contains the required fields before it reaches the route handler. Returns a descriptive 400 error if anything is missing.

function validateRegistration(req, res, next) {
  const { name, email, password } = req.body;
  const errors = [];

  if (!name || name.trim().length < 2) {
    errors.push("Name must be at least 2 characters");
  }

  if (!email || !email.includes("@")) {
    errors.push("A valid email address is required");
  }

  if (!password || password.length < 8) {
    errors.push("Password must be at least 8 characters");
  }

  if (errors.length > 0) {
    // Stop here — don't let invalid data reach the route handler
    return res.status(400).json({ errors });
  }

  next(); // Validation passed — continue
}

app.post("/register", validateRegistration, (req, res) => {
  // If we're here, req.body is guaranteed to be valid
  const user = createUser(req.body);
  res.status(201).json(user);
});
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here's how a realistic Express application composes all of these middleware types in the correct order:

const express = require("express");
const app = express();

// ── 1. Built-in middleware (always first) ──────────────────────
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// ── 2. Application-level middleware (global) ───────────────────
app.use(requestLogger);

// ── 3. Public routes (no auth required) ───────────────────────
app.post("/register", validateRegistration, registerUser);
app.post("/login", loginUser);

// ── 4. Protected routes (auth required) ───────────────────────
app.use("/api", authenticate);       // All /api/* routes are protected
app.get("/api/profile", getProfile);
app.put("/api/settings", updateSettings);

// ── 5. Error handler (always last) ────────────────────────────
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({ error: err.message });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Middleware Type Bound To Typical Use
Application-level app.use() Logging, body parsing, global auth
Router-level router.use() Feature-scoped logic (admin, API)
Built-in app.use() JSON parsing, static files
Error-handling app.use(err, req, res, next) Centralized error responses
next() Call What Happens
next() Move to the next middleware or route handler
next(err) Skip to the nearest error-handling middleware
res.send() / res.json() End the request-response cycle
Nothing called Request hangs — always a bug

Key Takeaways

  • Middleware functions run in registration order — sequence matters
  • Every middleware must either call next() or send a response — never neither
  • Use return next() to prevent code from continuing after passing control
  • Use next(err) to trigger your centralized error handler
  • Built-in middleware (express.json()) should be registered before any routes that need it
  • Error-handling middleware (err, req, res, next) must always be last

What's Next?

Now that you have a solid foundation in Express middleware, great next steps include:

  • Third-party middlewarehelmet for security headers, cors for cross-origin requests, morgan for production-grade logging
  • express-validator — a dedicated library for request validation with a clean, chainable API
  • Rate limiting — using express-rate-limit to protect routes from abuse
  • Async middleware — wrapping async functions cleanly so errors are always caught

Middleware is the architecture that separates a prototype from a production application. Master the pipeline, and your Express code will be cleaner, safer, and far easier to maintain.


If this clicked for you, the next read is async/await error handling in Express — because try/catch in every route handler is its own kind of callback hell. 🚀

Top comments (0)