DEV Community

CodeWithDhanian
CodeWithDhanian

Posted on

Middleware & Request-Response Pipeline

Middleware is one of the most important concepts in backend engineering because it controls what happens between receiving an HTTP request and sending the HTTP response.

In frameworks such as Express.js, middleware functions sit inside a request-processing pipeline. Each middleware can inspect the request, modify the request or response, perform business-independent work, terminate the request, or pass control forward with next(). ([Express.js][1])

A useful mental model is:

Client
  |
  | HTTP Request
  v
┌─────────────────────┐
│ Request Parsing     │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Logging Middleware  │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Authentication      │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Authorization        │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Validation          │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Route Handler       │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Database / Service  │
└──────────┬──────────┘
           v
┌─────────────────────┐
│ Error Middleware    │
└──────────┬──────────┘
           v
        Response
Enter fullscreen mode Exit fullscreen mode

4.1 What Middleware Actually Is

An Express middleware function generally receives three important arguments:

(req, res, next)
Enter fullscreen mode Exit fullscreen mode
  • req — the incoming HTTP request
  • res — the outgoing HTTP response
  • next — function used to transfer control to the next middleware

A minimal middleware looks like this:

function logger(req, res, next) {
  console.log(`${req.method} ${req.originalUrl}`);

  next();
}
Enter fullscreen mode Exit fullscreen mode

Register it:

app.use(logger);
Enter fullscreen mode Exit fullscreen mode

The important operation is:

next();
Enter fullscreen mode Exit fullscreen mode

If middleware does not send a response, end the request, or call next(), the request can remain unresolved. ([Express.js][1])

4.2 The Request-Response Lifecycle

Consider:

POST /api/users
Content-Type: application/json
Authorization: Bearer TOKEN
Enter fullscreen mode Exit fullscreen mode

The backend can process it through multiple stages:

HTTP Request
     |
     v
Node.js HTTP Server
     |
     v
Express Application
     |
     v
Global Middleware
     |
     +---- Request ID
     |
     +---- Logging
     |
     +---- JSON Parsing
     |
     +---- CORS
     |
     +---- Security
     |
     v
Router
     |
     v
Authentication
     |
     v
Authorization
     |
     v
Validation
     |
     v
Controller
     |
     v
Service
     |
     v
Database
     |
     v
Controller
     |
     v
Response Middleware
     |
     v
HTTP Response
Enter fullscreen mode Exit fullscreen mode

Middleware order matters. Express executes middleware in the order in which it is registered. ([Express.js][2])

For example:

app.use(authMiddleware);
app.use(validationMiddleware);

app.post("/users", createUser);
Enter fullscreen mode Exit fullscreen mode

The request must pass through:

authMiddleware
      ↓
validationMiddleware
      ↓
createUser
Enter fullscreen mode Exit fullscreen mode

Changing the order changes the behavior.

4.3 Middleware Can Do Four Fundamental Things

1. Execute logic

function logger(req, res, next) {
  console.log(req.method);
  next();
}
Enter fullscreen mode Exit fullscreen mode

2. Modify the request

function attachRequestId(req, res, next) {
  req.requestId = crypto.randomUUID();
  next();
}
Enter fullscreen mode Exit fullscreen mode

Later code can access:

console.log(req.requestId);
Enter fullscreen mode Exit fullscreen mode

3. Modify the response

function securityHeaders(req, res, next) {
  res.setHeader("X-API-Version", "1");
  next();
}
Enter fullscreen mode Exit fullscreen mode

4. End the request

function maintenanceMode(req, res, next) {
  return res.status(503).json({
    error: "Service temporarily unavailable"
  });
}
Enter fullscreen mode Exit fullscreen mode

There is no next() because the middleware has already completed the request-response cycle. Express documents middleware as being able to execute code, modify req/res, end the cycle, or call next(). ([Express.js][1])

4.4 next() Is the Control Mechanism

Think of next() as:

"Middleware finished its job.
Continue processing this request."
Enter fullscreen mode Exit fullscreen mode

Example:

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

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

  next();
}
Enter fullscreen mode Exit fullscreen mode

The important distinction is:

next();
Enter fullscreen mode Exit fullscreen mode

means:

Continue
Enter fullscreen mode Exit fullscreen mode

while:

return res.status(401).json(...);
Enter fullscreen mode Exit fullscreen mode

means:

Stop
Enter fullscreen mode Exit fullscreen mode

A common mistake is accidentally doing both:

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

next();
Enter fullscreen mode Exit fullscreen mode

This can cause the pipeline to continue after a response has already been sent.

Prefer:

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

next();
Enter fullscreen mode Exit fullscreen mode

4.5 Application-Level Middleware

Application-level middleware applies to the entire application or a broad section of it.

app.use(express.json());

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

For example:

Every Request
      |
      v
express.json()
      |
      v
Request Logger
      |
      v
Router
Enter fullscreen mode Exit fullscreen mode

Express provides built-in middleware such as express.json(), express.urlencoded(), and express.static(). ([Express.js][1])

4.6 Router-Level Middleware

Large applications should not put every middleware function directly into app.js.

Use routers:

src/
├── app.js
├── middleware/
│   ├── auth.js
│   ├── validate.js
│   └── error.js
├── routes/
│   ├── users.js
│   └── orders.js
├── controllers/
│   ├── users.js
│   └── orders.js
└── services/
    ├── users.js
    └── orders.js
Enter fullscreen mode Exit fullscreen mode

Example:

import express from "express";

const router = express.Router();

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

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

  next();
}

router.use(authenticate);

router.get("/", (req, res) => {
  res.json({
    message: "Authenticated users"
  });
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Mount it:

app.use("/api/users", userRouter);
Enter fullscreen mode Exit fullscreen mode

Now:

GET /api/users
Enter fullscreen mode Exit fullscreen mode

passes through the router's authentication middleware before reaching the handler. Express supports both application-level and router-level middleware. ([Express.js][1])

4.7 Authentication Middleware

Authentication determines:

Who is making this request?

A simplified JWT middleware:

import jwt from "jsonwebtoken";

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

  if (!header?.startsWith("Bearer ")) {
    return res.status(401).json({
      error: "Missing authentication token"
    });
  }

  const token = header.substring(7);

  try {
    const payload = jwt.verify(
      token,
      process.env.JWT_SECRET
    );

    req.user = payload;

    next();
  } catch {
    return res.status(401).json({
      error: "Invalid or expired token"
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The pipeline becomes:

Request
   |
   v
Authorization Header
   |
   v
Extract Token
   |
   v
Verify Signature
   |
   +---- Invalid → 401
   |
   v
req.user = payload
   |
   v
next()
   |
   v
Controller
Enter fullscreen mode Exit fullscreen mode

The middleware creates a controlled boundary between unauthenticated HTTP traffic and authenticated application logic.

4.8 Authorization Middleware

Authentication answers who you are.

Authorization answers what you are allowed to do.

export function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        error: "Authentication required"
      });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: "Forbidden"
      });
    }

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

Use it:

router.delete(
  "/users/:id",
  authenticate,
  requireRole("admin"),
  deleteUser
);
Enter fullscreen mode Exit fullscreen mode

The pipeline becomes:

Request
   ↓
authenticate
   ↓
requireRole("admin")
   ↓
deleteUser
Enter fullscreen mode Exit fullscreen mode

4.9 Validation Middleware

Validation should happen before business logic.

function validateCreateUser(req, res, next) {
  const { name, email, password } = req.body;

  if (
    typeof name !== "string" ||
    typeof email !== "string" ||
    typeof password !== "string"
  ) {
    return res.status(400).json({
      error: "Invalid request body"
    });
  }

  if (password.length < 8) {
    return res.status(400).json({
      error: "Password must contain at least 8 characters"
    });
  }

  next();
}
Enter fullscreen mode Exit fullscreen mode

Then:

router.post(
  "/",
  authenticate,
  validateCreateUser,
  createUser
);
Enter fullscreen mode Exit fullscreen mode

This prevents the controller from becoming responsible for every HTTP-level validation rule.

4.10 Error-Handling Middleware

Errors should have a centralized path instead of every controller independently formatting errors.

Express identifies error-handling middleware through its four-argument signature:

(err, req, res, next)
Enter fullscreen mode Exit fullscreen mode

([Express.js][1])

Example:

export function errorHandler(err, req, res, next) {
  console.error(err);

  const statusCode = err.statusCode || 500;

  res.status(statusCode).json({
    error: err.message || "Internal server error"
  });
}
Enter fullscreen mode Exit fullscreen mode

Register it after routes:

app.use("/api/users", userRouter);
app.use("/api/orders", orderRouter);

app.use(errorHandler);
Enter fullscreen mode Exit fullscreen mode

A typical structure is:

Request
   ↓
Middleware
   ↓
Router
   ↓
Controller
   ↓
Service
   ↓
Error
   ↓
next(error)
   ↓
Error Handler
   ↓
HTTP Response
Enter fullscreen mode Exit fullscreen mode

4.11 Async Middleware

Modern backend applications perform many asynchronous operations:

  • Database queries
  • HTTP requests
  • File operations
  • Cache operations
  • Message queues
  • External APIs

Example:

async function loadUser(req, res, next) {
  try {
    const user = await userService.findById(req.params.id);

    if (!user) {
      return res.status(404).json({
        error: "User not found"
      });
    }

    req.userRecord = user;

    next();
  } catch (error) {
    next(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

The important pattern is:

await operation
      |
      +---- success → next()
      |
      +---- failure → next(error)
Enter fullscreen mode Exit fullscreen mode

In current Express behavior, rejected promises from middleware are propagated to next() automatically in Express 5, while explicitly calling next(error) remains a clear pattern for asynchronous control flow. ([Express.js][2])

4.12 Complete Backend Pipeline

A clean production-oriented structure can look like:

import express from "express";
import crypto from "node:crypto";

const app = express();

app.use(express.json());

app.use((req, res, next) => {
  req.requestId = crypto.randomUUID();

  console.log({
    requestId: req.requestId,
    method: req.method,
    path: req.originalUrl
  });

  next();
});

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

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

  req.user = {
    id: "user-123",
    role: "admin"
  };

  next();
}

function requireRole(role) {
  return (req, res, next) => {
    if (req.user?.role !== role) {
      return res.status(403).json({
        error: "Forbidden"
      });
    }

    next();
  };
}

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

  if (!name || !email) {
    return res.status(400).json({
      error: "name and email are required"
    });
  }

  next();
}

app.post(
  "/api/users",
  authenticate,
  requireRole("admin"),
  validateUser,
  async (req, res, next) => {
    try {
      const user = {
        id: crypto.randomUUID(),
        name: req.body.name,
        email: req.body.email
      };

      res.status(201).json(user);
    } catch (error) {
      next(error);
    }
  }
);

app.use((err, req, res, next) => {
  console.error({
    requestId: req.requestId,
    error: err.message
  });

  res.status(500).json({
    error: "Internal server error",
    requestId: req.requestId
  });
});

app.listen(3000, () => {
  console.log("Server running on port 3000");
});
Enter fullscreen mode Exit fullscreen mode

The complete execution path is:

POST /api/users
       |
       v
express.json()
       |
       v
Request ID Middleware
       |
       v
Authentication
       |
       v
Authorization
       |
       v
Validation
       |
       v
Route Handler
       |
       v
Business Logic
       |
       v
Response
Enter fullscreen mode Exit fullscreen mode

If something fails:

Any Middleware / Controller
          |
          v
     next(error)
          |
          v
   Error Middleware
          |
          v
    HTTP Error Response
Enter fullscreen mode Exit fullscreen mode

4.13 Middleware Composition

Middleware becomes powerful when small functions are composed.

Instead of one enormous function:

function everything(req, res, next) {
  // authentication
  // authorization
  // validation
  // logging
  // caching
  // rate limiting
  // business logic
  // error handling
}
Enter fullscreen mode Exit fullscreen mode

Prefer:

router.post(
  "/orders",
  requestLogger,
  rateLimiter,
  authenticate,
  requireRole("customer"),
  validateOrder,
  createOrder
);
Enter fullscreen mode Exit fullscreen mode

This creates a readable pipeline of responsibilities.

Each middleware should ideally have one clear responsibility.

4.14 Route-Specific vs Global Middleware

Global middleware:

app.use(requestLogger);
Enter fullscreen mode Exit fullscreen mode

is appropriate when almost every request needs the behavior.

Route-specific middleware:

app.post(
  "/payments",
  authenticate,
  requireRole("admin"),
  processPayment
);
Enter fullscreen mode Exit fullscreen mode

is appropriate when the behavior only applies to particular endpoints.

A practical structure is:

Global
├── JSON parsing
├── Request ID
├── CORS
├── Security headers
└── General logging

Router
├── Authentication
├── Authorization
├── Validation
└── Resource-specific rules

Controller
└── Business operation
Enter fullscreen mode Exit fullscreen mode

4.15 The Most Important Middleware Rules

Middleware order is execution order.

next() transfers control forward.

next(error) transfers control to error-handling middleware.

Sending a response should normally terminate the current path.

Authentication should occur before protected business logic.

Authorization should occur after authentication.

Validation should happen before expensive business operations.

Error middleware should be centralized.

Middleware should remain focused and composable.

Business logic should not be buried inside generic middleware.

A well-designed request-response pipeline makes the backend easier to secure, test, debug, observe, and maintain. Express's middleware model is explicitly built around this ordered chain of functions that can modify requests and responses, terminate processing, or delegate to subsequent middleware. ([Express.js][1])

Backend Engineering Ebook

Grab the Backend Engineering ebook:
https://codewithdhanian.gumroad.com/l/ungqng

Middleware & Request-Response Pipeline<br>

Top comments (0)