DEV Community

Cover image for What Is Middleware in Express and How It Works
Pratham
Pratham

Posted on

What Is Middleware in Express and How It Works

The checkpoint system between request and response — where logging, authentication, and validation live.


When I first saw this code in Express, I had no idea what it was doing:

app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
});
Enter fullscreen mode Exit fullscreen mode

It's not a route. It doesn't send a response. And what's next()? Why do we need to call it? What happens if we don't?

This, I learned, is middleware — and it's the most powerful concept in Express. Every request that hits your server passes through middleware before it reaches your route handler. It's where you log requests, check authentication, validate data, parse bodies, and handle errors — all without cluttering your route logic.

Understanding middleware was the moment Express went from "a routing library" to "a complete application framework" in my mind. Let me break it down the way it clicked for me in the ChaiCode Web Dev Cohort 2026.


What Is Middleware?

Middleware is a function that runs between the incoming request and the final response. It has access to the request object (req), the response object (res), and a function called next() that passes control to the next middleware or route handler.

The Checkpoint Analogy

Think of your Express app as an airport. A request is a passenger trying to board a plane (get a response). Between arriving at the airport and boarding, the passenger goes through multiple checkpoints:

Passenger (Request) arrives at the airport
        │
        ↓
┌──────────────────────┐
│  Checkpoint 1:       │
│  Ticket Check        │  ← "Do you have a valid request?"
│  (Logging middleware) │
└──────────┬───────────┘
           │ ✅ Pass → next()
           ↓
┌──────────────────────┐
│  Checkpoint 2:       │
│  Security Scan       │  ← "Are you authorized?"
│  (Auth middleware)   │
└──────────┬───────────┘
           │ ✅ Pass → next()
           ↓
┌──────────────────────┐
│  Checkpoint 3:       │
│  Baggage Check       │  ← "Is your data valid?"
│  (Validation)        │
└──────────┬───────────┘
           │ ✅ Pass → next()
           ↓
┌──────────────────────┐
│  Gate (Route Handler)│
│  Board the plane!    │  ← Response sent ✈️
└──────────────────────┘

If ANY checkpoint fails → passenger is stopped (error response).
If ALL checkpoints pass → passenger boards (route handler runs).
Enter fullscreen mode Exit fullscreen mode

Each checkpoint can:

  • Inspect the request (read headers, body, URL)
  • Modify the request (add properties, transform data)
  • End the request (send a response early — like a 401 Unauthorized)
  • Pass control to the next checkpoint (next())

Middleware Function Signature

Every middleware function has the same signature:

function myMiddleware(req, res, next) {
  // Do something with req or res
  next(); // Pass control to the next middleware
}
Enter fullscreen mode Exit fullscreen mode

Three parameters:

  • req — the request object (incoming data)
  • res — the response object (what you send back)
  • next — a function that calls the next middleware in the chain

Using Middleware

// Register middleware with app.use()
app.use(myMiddleware);
Enter fullscreen mode Exit fullscreen mode

Where Middleware Sits in the Request Lifecycle

Client sends HTTP request
        │
        ↓
┌──────────────────────────────────────────┐
│            EXPRESS APPLICATION             │
│                                          │
│   Middleware 1 (logging)                 │
│       │ next()                           │
│       ↓                                  │
│   Middleware 2 (body parser)             │
│       │ next()                           │
│       ↓                                  │
│   Middleware 3 (authentication)          │
│       │ next()                           │
│       ↓                                  │
│   Route Handler (app.get, app.post...)   │
│       │ res.json() / res.send()          │
│       ↓                                  │
│   Response sent to client                │
│                                          │
└──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Middleware runs in the order it's defined. The request flows through each middleware, one by one, until a response is sent or an error occurs.


The next() Function — The Key to Everything

next() is what makes the middleware chain work. Without it, the request gets stuck.

With next() — Chain Continues

app.use((req, res, next) => {
  console.log("Middleware 1: I ran!");
  next(); // ← Pass to the next middleware/route
});

app.use((req, res, next) => {
  console.log("Middleware 2: I ran too!");
  next(); // ← Pass to the route handler
});

app.get("/", (req, res) => {
  res.send("Response sent!");
});

// Output for GET /:
// Middleware 1: I ran!
// Middleware 2: I ran too!
// Response: "Response sent!"
Enter fullscreen mode Exit fullscreen mode

Without next() — Chain Stops

app.use((req, res, next) => {
  console.log("Middleware 1: I ran!");
  // next() NOT called — request is STUCK here
});

app.get("/", (req, res) => {
  res.send("You'll never see this!"); // ← Never executes
});

// Output for GET /:
// Middleware 1: I ran!
// (request hangs... browser shows loading spinner forever)
Enter fullscreen mode Exit fullscreen mode

Ending Early — Sending a Response Instead of next()

Sometimes middleware should stop the chain. For example, authentication:

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: "Unauthorized — no token provided" });
    // No next() — request ends here!
  }
  next(); // Token exists, continue to route handler
});
Enter fullscreen mode Exit fullscreen mode

The rule is simple: every middleware must either call next() or send a response. Never do neither, never do both.


Execution Order of Middleware

Middleware runs in the order you define it in your code. This order matters:

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

// Middleware 1 — runs FIRST
app.use((req, res, next) => {
  console.log("1. Logging");
  req.requestTime = new Date().toISOString();
  next();
});

// Middleware 2 — runs SECOND
app.use((req, res, next) => {
  console.log("2. Auth check");
  next();
});

// Middleware 3 — runs THIRD
app.use(express.json()); // built-in body parser

// Route handler — runs LAST
app.get("/", (req, res) => {
  console.log("4. Route handler");
  res.json({ time: req.requestTime });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode
Request: GET /
  → 1. Logging
  → 2. Auth check
  → 3. Body parser (silent)
  → 4. Route handler → response sent
Enter fullscreen mode Exit fullscreen mode

Multiple Middleware Execution Chain

Request arrives
    │
    ↓
┌───────────┐  next()  ┌───────────┐  next()  ┌───────────┐  next()  ┌──────────┐
│ Logger    │ ──────→ │ Auth      │ ──────→ │ Parser   │ ──────→ │ Route    │
│           │         │ Check     │         │ (JSON)   │         │ Handler  │
└───────────┘         └───────────┘         └───────────┘         └────┬─────┘
                                                                       │
                                                                  res.json()
                                                                       │
                                                                       ↓
                                                                  Response sent
                                                                  to client
Enter fullscreen mode Exit fullscreen mode

If you define the auth middleware after the route, the route runs without authentication. Order is everything.


Types of Middleware

1. Application-Level Middleware

Middleware that applies to the entire app — every request goes through it.

// Applies to ALL routes
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
});
Enter fullscreen mode Exit fullscreen mode

You can also limit it to specific paths:

// Only applies to routes starting with /api
app.use("/api", (req, res, next) => {
  console.log("API request detected");
  next();
});
Enter fullscreen mode Exit fullscreen mode

2. Router-Level Middleware

Middleware that applies to a specific group of routes using express.Router():

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

// This middleware only applies to routes in this router
router.use((req, res, next) => {
  console.log("Admin route accessed");
  next();
});

router.get("/dashboard", (req, res) => {
  res.json({ page: "Admin Dashboard" });
});

router.get("/settings", (req, res) => {
  res.json({ page: "Admin Settings" });
});

// Mount the router at /admin
app.use("/admin", router);

// GET /admin/dashboard → middleware runs, then route handler
// GET /users → middleware does NOT run (different path)
Enter fullscreen mode Exit fullscreen mode

3. Built-In Middleware

Express comes with a few middleware functions out of the box:

// Parse JSON request bodies
app.use(express.json());

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

// Serve static files (CSS, images, JS)
app.use(express.static("public"));
Enter fullscreen mode Exit fullscreen mode
Built-In Middleware What It Does
express.json() Parses JSON request bodies → req.body
express.urlencoded() Parses form submissions → req.body
express.static("dir") Serves files from a directory (HTML, CSS, images)

4. Third-Party Middleware

Middleware from npm packages:

const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");

app.use(cors()); // Enable Cross-Origin requests
app.use(helmet()); // Set security headers
app.use(morgan("dev")); // HTTP request logging
Enter fullscreen mode Exit fullscreen mode

5. Error-Handling Middleware

Special middleware with four parameters(err, req, res, next):

app.use((err, req, res, next) => {
  console.error("Error:", err.message);
  res.status(500).json({ error: "Something went wrong!" });
});
Enter fullscreen mode Exit fullscreen mode

Express recognizes this as error-handling middleware because of the four-parameter signature. It only runs when an error is thrown or passed via next(err).


Real-World Examples

Example 1: Request Logger

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

  // This runs AFTER the response is sent
  res.on("finish", () => {
    const duration = Date.now() - start;
    console.log(
      `${req.method} ${req.url}${res.statusCode} (${duration}ms)`,
    );
  });

  next();
}

app.use(logger);

// Output:
// GET /api/users → 200 (12ms)
// POST /api/users → 201 (45ms)
// GET /api/missing → 404 (3ms)
Enter fullscreen mode Exit fullscreen mode

Example 2: Authentication

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

  if (!token) {
    return res.status(401).json({ error: "No token provided" });
  }

  if (token !== "Bearer my-secret-token") {
    return res.status(403).json({ error: "Invalid token" });
  }

  // Token is valid — attach user info to request
  req.user = { id: 1, name: "Pratham", role: "admin" };
  next();
}

// Public route — no auth needed
app.get("/", (req, res) => {
  res.json({ message: "Welcome! This is public." });
});

// Protected routes — auth required
app.get("/api/profile", authenticate, (req, res) => {
  res.json({ user: req.user });
});

app.get("/api/dashboard", authenticate, (req, res) => {
  res.json({ message: `Welcome back, ${req.user.name}!` });
});
Enter fullscreen mode Exit fullscreen mode

Notice: authenticate is passed as an argument to the route, not via app.use(). This makes it apply to only specific routes — not everything.

Example 3: Request Validation

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

  if (!name || name.trim().length === 0) {
    errors.push("Name is required");
  }

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

  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }

  next();
}

app.post("/api/users", validateUser, (req, res) => {
  // If we reach here, validation passed!
  const user = { id: Date.now(), ...req.body };
  res.status(201).json({ message: "User created!", user });
});
Enter fullscreen mode Exit fullscreen mode

The route handler only runs if validation passes. Clean separation of concerns.


Middleware Patterns — Practical Combinations

Here's how middleware typically looks in a real Express app:

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

// ── Global Middleware (runs on EVERY request) ──
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
});

// ── Public Routes (no auth needed) ──
app.get("/", (req, res) => {
  res.json({ message: "Public home page" });
});

app.post("/login", (req, res) => {
  // Login logic...
  res.json({ token: "abc123" });
});

// ── Auth Middleware (applies to everything below) ──
app.use((req, res, next) => {
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: "Unauthorized" });
  req.user = { name: "Pratham" }; // Decoded from token
  next();
});

// ── Protected Routes (auth required) ──
app.get("/api/profile", (req, res) => {
  res.json({ user: req.user });
});

app.get("/api/settings", (req, res) => {
  res.json({ settings: { theme: "dark" } });
});

// ── Error Handler (must be LAST) ──
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode
Flow for GET /api/profile:

  1. express.json()        → parses body (global)
  2. Logger                → logs "GET /api/profile" (global)
  3. Auth middleware        → checks token, attaches req.user
  4. Route handler         → sends { user: req.user }

Flow for GET / (public):

  1. express.json()        → parses body (global)
  2. Logger                → logs "GET /" (global)
  3. Route handler         → sends { message: "Public home page" }
  (Auth middleware is defined AFTER this route — never runs!)
Enter fullscreen mode Exit fullscreen mode

Let's Practice: Hands-On Assignment

Part 1: Build a Logger Middleware

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

// Your logger middleware
app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next();
});

app.get("/", (req, res) => res.send("Home"));
app.get("/about", (req, res) => res.send("About"));
app.get("/api/data", (req, res) => res.json({ data: [1, 2, 3] }));

app.listen(3000);
// Visit each route — see the logs appear in your terminal
Enter fullscreen mode Exit fullscreen mode

Part 2: Add Authentication Middleware

function auth(req, res, next) {
  const apiKey = req.headers["x-api-key"];
  if (apiKey !== "secret123") {
    return res.status(401).json({ error: "Invalid API key" });
  }
  next();
}

// Public
app.get("/", (req, res) => res.send("Public — no key needed"));

// Protected
app.get("/api/secret", auth, (req, res) => {
  res.json({ message: "You have access!" });
});

// Test without key: curl http://localhost:3000/api/secret
// Test with key: curl -H "x-api-key: secret123" http://localhost:3000/api/secret
Enter fullscreen mode Exit fullscreen mode

Part 3: Build a Request Timing Middleware

app.use((req, res, next) => {
  req.startTime = Date.now();

  res.on("finish", () => {
    const duration = Date.now() - req.startTime;
    console.log(`${req.method} ${req.url}${res.statusCode} [${duration}ms]`);
  });

  next();
});
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Middleware is a function that runs between request and response. It can inspect, modify, or end the request. Every middleware must either call next() or send a response.
  2. next() passes control to the next middleware in the chain. Without it, the request hangs. Middleware executes in the order it's defined — order matters.
  3. Types: Application-level (global), router-level (grouped), built-in (express.json()), third-party (cors, helmet), and error-handling (4 parameters).
  4. Route-specific middleware is passed as an argument to the route: app.get("/path", authMiddleware, handler). This keeps public routes public and protected routes protected.
  5. Middleware enables separation of concerns — logging, auth, validation, and error handling each live in their own function, not tangled inside route handlers.

Wrapping Up

Middleware is what makes Express more than a router — it's the backbone of the entire request pipeline. Logging? Middleware. Authentication? Middleware. Body parsing? Middleware. Error handling? Middleware. Once you understand the req → middleware → middleware → route → res flow, you can build any feature by plugging in the right middleware at the right place.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Middleware was the concept that turned my Express apps from "routes that do everything" into "clean, organized, production-ready code." It's the architectural pattern that makes everything else possible.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)