DEV Community

Aryan Gupta
Aryan Gupta

Posted on

How Express Middleware Works

In the previous part of Under the Hood, we explored how Node.js handles incoming requests using the event loop, asynchronous I/O, and non-blocking operations.

But most Node.js applications don't handle every request directly with Node's built-in HTTP module.

Instead, we commonly use Express.

And Express introduces one of the most important concepts in backend development:

Middleware

You have probably written code like this:

app.use(express.json());
Enter fullscreen mode Exit fullscreen mode

or:

app.use((req, res, next) => {
    console.log("Request received");
    next();
});
Enter fullscreen mode Exit fullscreen mode

But what actually happens when a request passes through middleware?

Why do we need next()?

What happens if middleware doesn't call next()?

And how does Express decide which middleware should run?

Let's go Under the Hood.


1. What Is Express?

Express is a web framework built on top of Node.js.

It provides a simpler way to build web servers and APIs by giving us features such as:

  • Routing
  • Middleware
  • Request and response handling
  • Error handling
  • JSON parsing
  • Static file serving

Without Express, we could create a server using Node's built-in http module:

const http = require("http");

const server = http.createServer((req, res) => {
    res.end("Hello World");
});

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

Express gives us a more structured way to organize the same kind of application.

For example:

const express = require("express");

const app = express();

app.get("/", (req, res) => {
    res.send("Hello World");
});

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

But one of the biggest ideas Express adds is its middleware system.


2. What Is Middleware?

A middleware function is a function that runs during the lifecycle of a request.

A typical middleware function looks like this:

app.use((req, res, next) => {

    console.log("Request received");

    next();

});
Enter fullscreen mode Exit fullscreen mode

It receives three important things:

req
res
next
Enter fullscreen mode Exit fullscreen mode

req

Contains information about the incoming request.

For example:

  • URL
  • HTTP method
  • headers
  • query parameters
  • body
  • route parameters

res

Used to send a response back to the client.

For example:

res.send("Hello");
Enter fullscreen mode Exit fullscreen mode

next

A function that tells Express:

"I'm finished. Continue to the next middleware."

This simple function is the foundation of the Express middleware system.


3. The Middleware Chain

Imagine we have three middleware functions:

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

app.use((req, res, next) => {
    console.log("Middleware 2");
    next();
});

app.use((req, res, next) => {
    console.log("Middleware 3");
    next();
});
Enter fullscreen mode Exit fullscreen mode

When a request arrives, Express processes them in order.

Request
   ↓
Middleware 1
   ↓
Middleware 2
   ↓
Middleware 3
   ↓
Route Handler
   ↓
Response
Enter fullscreen mode Exit fullscreen mode

This is called a middleware chain.

Each middleware can:

  1. Modify the request
  2. Modify the response
  3. Perform some operation
  4. End the request
  5. Pass control to the next middleware

4. Why Do We Need next()?

Consider:

app.use((req, res, next) => {
    console.log("Middleware 1");
});
Enter fullscreen mode Exit fullscreen mode

There is no next().

What happens?

The request reaches this middleware and execution stops there.

Express doesn't automatically know that this middleware has finished.

So the request can remain hanging unless the middleware sends a response.

Compare that with:

app.use((req, res, next) => {
    console.log("Middleware 1");
    next();
});
Enter fullscreen mode Exit fullscreen mode

Now Express knows:

Middleware 1
      ↓
   next()
      ↓
Next Middleware
Enter fullscreen mode Exit fullscreen mode

So next() is essentially how middleware passes control forward.


5. Middleware Can End the Request

Middleware doesn't always need to call next().

It can also send a response.

For example:

app.use((req, res, next) => {

    res.send("Request handled");

});
Enter fullscreen mode Exit fullscreen mode

The request ends here.

The flow becomes:

Request
   ↓
Middleware
   ↓
Response
   ↓
Request Ends
Enter fullscreen mode Exit fullscreen mode

There is no reason to call next() because there is nothing else that needs to handle the request.

This is useful for things such as:

  • Blocking unauthorized requests
  • Returning cached responses
  • Handling specific conditions
  • Returning maintenance messages

6. Middleware Can Modify the Request

One powerful feature of middleware is that it can add information to the request object.

For example:

app.use((req, res, next) => {

    req.userRole = "admin";

    next();

});
Enter fullscreen mode Exit fullscreen mode

Later middleware can access:

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

The flow becomes:

Incoming Request
       ↓
Authentication Middleware
       ↓
req.userRole = "admin"
       ↓
Next Middleware
       ↓
Route Handler
Enter fullscreen mode Exit fullscreen mode

This allows information to be passed through different stages of request processing.


7. A Real Authentication Example

Imagine an API endpoint:

GET /profile
Enter fullscreen mode Exit fullscreen mode

We want only authenticated users to access it.

We could create middleware:

function authenticate(req, res, next) {

    const token = req.headers.authorization;

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

    next();
}
Enter fullscreen mode Exit fullscreen mode

Then use it:

app.get("/profile", authenticate, (req, res) => {

    res.json({
        message: "Profile data"
    });

});
Enter fullscreen mode Exit fullscreen mode

The flow is:

      Client
        ↓
   GET /profile
        ↓
   authenticate()
        ↓
   Token exists?
        ↓
 ┌───────────────┐
 │               │
No              Yes
 │               │
 ↓               ↓
401           next()
Response         ↓
              Route
                ↓
             Response
Enter fullscreen mode Exit fullscreen mode

This is one of the reasons middleware is so useful.

Instead of putting authentication logic inside every route, we can create it once and reuse it.


8. Types of Middleware

Express applications commonly use different types of middleware.

Application-level middleware

Applied to the application:

app.use((req, res, next) => {
    console.log("Request received");
    next();
});
Enter fullscreen mode Exit fullscreen mode

This can run for many or all requests depending on how it is configured.


Router-level middleware

Middleware can also be attached to a router.

const router = express.Router();

router.use((req, res, next) => {
    console.log("Router middleware");
    next();
});
Enter fullscreen mode Exit fullscreen mode

This is useful when an application has multiple groups of routes.

For example:

/users
/products
/orders
Enter fullscreen mode Exit fullscreen mode

We can have different middleware for different routers.


Built-in middleware

Express also provides built-in middleware.

For example:

app.use(express.json());
Enter fullscreen mode Exit fullscreen mode

This allows Express to parse incoming JSON request bodies.

If the client sends:

{
    "name": "Aryan"
}
Enter fullscreen mode Exit fullscreen mode

the parsed data can be accessed through:

req.body
Enter fullscreen mode Exit fullscreen mode

Error-handling middleware

Express also supports special middleware for handling errors.

It has four parameters:

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

For example:

app.use((err, req, res, next) => {

    console.error(err);

    res.status(500).json({
        message: "Something went wrong"
    });

});
Enter fullscreen mode Exit fullscreen mode

The four parameters are important because they identify this function as error-handling middleware.


9. How Express Knows the Order

Middleware order matters.

Consider:

app.use((req, res, next) => {
    console.log("A");
    next();
});

app.use((req, res, next) => {
    console.log("B");
    next();
});

app.get("/", (req, res) => {
    console.log("C");
    res.send("Hello");
});
Enter fullscreen mode Exit fullscreen mode

A request to / produces:

A
B
C
Enter fullscreen mode Exit fullscreen mode

Express processes middleware and routes according to their order in the application.

This means that changing the order can change the behavior of your application.

For example:

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

app.use((req, res, next) => {
    console.log("Middleware");
    next();
});
Enter fullscreen mode Exit fullscreen mode

The middleware won't run for that request because the route already handled it.

This is why middleware placement is important.


10. Middleware as a Pipeline

A useful mental model is to think of middleware as a pipeline.

Imagine an incoming request:

                Request
                   ↓
          ┌─────────────────┐
          │ Logging         │
          └────────┬────────┘
                   ↓
          ┌─────────────────┐
          │ JSON Parser     │
          └────────┬────────┘
                   ↓
          ┌─────────────────┐
          │ Authentication  │
          └────────┬────────┘
                   ↓
          ┌─────────────────┐
          │ Authorization   │
          └────────┬────────┘
                   ↓
          ┌─────────────────┐
          │ Route Handler   │
          └────────┬────────┘
                   ↓
                Response
Enter fullscreen mode Exit fullscreen mode

Each stage can inspect or modify the request before passing it forward.

This pipeline model makes complex backend applications easier to understand.


11. What Happens If Middleware Sends a Response?

Suppose:

app.use((req, res, next) => {

    if (!req.headers.authorization) {
        return res.status(401).send("Unauthorized");
    }

    next();

});
Enter fullscreen mode Exit fullscreen mode

If there is no authorization header:

Request
   ↓
Authentication Middleware
   ↓
No Token
   ↓
401 Response
   ↓
Request Ends
Enter fullscreen mode Exit fullscreen mode

The route handler never runs.

This is useful because middleware can stop invalid requests before they reach the business logic.


12. Why return Is Often Used

You will often see:

return res.status(401).json({
    message: "Unauthorized"
});
Enter fullscreen mode Exit fullscreen mode

The return makes sure the function exits immediately after sending the response.

For example, without careful control flow:

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

next();
Enter fullscreen mode Exit fullscreen mode

The code could continue and call next() after attempting to send the response.

A safer pattern is:

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

next();
Enter fullscreen mode Exit fullscreen mode

The key idea is:

Once middleware has ended the request, it should not continue the normal middleware chain.


13. Error Handling in Middleware

Errors can occur anywhere in an application.

For example:

app.get("/users", async (req, res, next) => {

    try {

        const users = await User.find();

        res.json(users);

    } catch (error) {

        next(error);

    }

});
Enter fullscreen mode Exit fullscreen mode

Instead of handling the error inside every part of the application, we can pass it to error-handling middleware.

app.use((err, req, res, next) => {

    console.error(err);

    res.status(500).json({
        message: "Internal Server Error"
    });

});
Enter fullscreen mode Exit fullscreen mode

The flow becomes:

Request
   ↓
Route
   ↓
Database Operation
   ↓
Error
   ↓
next(error)
   ↓
Error Middleware
   ↓
Error Response
Enter fullscreen mode Exit fullscreen mode

This gives applications a centralized way to handle errors.


14. next() vs next(error)

There is an important difference.

next()

Means:

Continue normally.

next();
Enter fullscreen mode Exit fullscreen mode

Flow:

Middleware
    ↓
next()
    ↓
Next Middleware
Enter fullscreen mode Exit fullscreen mode

next(error)

Means:

Something went wrong. Pass this error forward.

next(error);
Enter fullscreen mode Exit fullscreen mode

Flow:

Middleware
    ↓
Error
    ↓
next(error)
    ↓
Error Handler
Enter fullscreen mode Exit fullscreen mode

This distinction is fundamental to Express error handling.


15. Middleware Can Be Reused

One of the biggest benefits of middleware is reusability.

For example:

function logger(req, res, next) {

    console.log(req.method, req.url);

    next();
}
Enter fullscreen mode Exit fullscreen mode

We can use it globally:

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

Or for a specific route:

app.get("/profile", logger, (req, res) => {

    res.send("Profile");

});
Enter fullscreen mode Exit fullscreen mode

Instead of repeating the same logic in multiple routes, we define it once.


16. A Complete Example

Let's combine several concepts.

const express = require("express");

const app = express();

app.use(express.json());

app.use((req, res, next) => {

    console.log(req.method, req.url);

    next();

});

function authenticate(req, res, next) {

    const token = req.headers.authorization;

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

    next();
}

app.get("/profile", authenticate, (req, res) => {

    res.json({
        message: "Profile data"
    });

});

app.use((err, req, res, next) => {

    console.error(err);

    res.status(500).json({
        message: "Internal Server Error"
    });

});

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

Now imagine a request:

GET /profile
Enter fullscreen mode Exit fullscreen mode

The simplified flow is:

                    Request
                       ↓
                express.json()
                       ↓
                    Logger
                       ↓
                  authenticate
                       ↓
                 Token exists?
                   /       \
                 No         Yes
                 ↓           ↓
             401 Response   Route
                             ↓
                          Response
Enter fullscreen mode Exit fullscreen mode

This is the Express middleware pipeline in action.


17. What Happens Under the Hood?

At a high level, when a request enters an Express application, Express maintains a stack of middleware and route handlers.

Conceptually:

Express Application
        ↓
   Middleware Stack
        ↓
 ┌──────┴──────┐
 ↓             ↓
Middleware   Middleware
 ↓             ↓
 └──────┬──────┘
        ↓
   Route Handler
        ↓
     Response
Enter fullscreen mode Exit fullscreen mode

Each matching layer gets a chance to process the request.

Calling:

next();
Enter fullscreen mode Exit fullscreen mode

moves processing forward.

Sending a response ends the request.

Calling:

next(error);
Enter fullscreen mode Exit fullscreen mode

moves the request into error-handling flow.

This simple mechanism is what allows Express applications to build complex request-processing pipelines.


18. Common Mistakes

❌ Forgetting next()

app.use((req, res, next) => {
    console.log("Hello");
});
Enter fullscreen mode Exit fullscreen mode

The request may hang because neither the middleware nor the next handler completes the request.


❌ Sending a response and continuing

if (!token) {
    res.status(401).send("Unauthorized");
}

next();
Enter fullscreen mode Exit fullscreen mode

Prefer:

if (!token) {
    return res.status(401).send("Unauthorized");
}

next();
Enter fullscreen mode Exit fullscreen mode

❌ Putting middleware in the wrong order

Middleware that needs to run before a route must be registered appropriately before that route.

Order matters.


❌ Putting all logic into middleware

Middleware should generally handle cross-cutting request-processing concerns such as:

  • Logging
  • Authentication
  • Validation
  • Parsing
  • Authorization
  • Error handling

Business logic is often better kept inside appropriate services or route/controller layers.


19. The Mental Model

If you remember only one thing from this article, remember this:

Request
   ↓
Middleware
   ↓
Middleware
   ↓
Middleware
   ↓
Route Handler
   ↓
Response
Enter fullscreen mode Exit fullscreen mode

Each middleware can:

Inspect
   ↓
Modify
   ↓
Continue
   ↓
OR
   ↓
End Request
Enter fullscreen mode Exit fullscreen mode

And when something goes wrong:

Error
  ↓
next(error)
  ↓
Error Middleware
  ↓
Error Response
Enter fullscreen mode Exit fullscreen mode

That's the core idea behind Express middleware.


20. Conclusion

Express middleware gives us a structured way to process requests before they reach the final route handler.

Instead of putting authentication, logging, validation, error handling, and other common operations inside every route, we can separate them into reusable middleware functions.

The most important concepts to remember are:

  • Middleware runs during the request-response lifecycle.
  • next() passes control to the next middleware.
  • Middleware can modify req and res.
  • Middleware can end a request by sending a response.
  • Middleware order matters.
  • next(error) passes errors to error-handling middleware.
  • Middleware can be reused across multiple routes.

The bigger picture is:

Client
  ↓
HTTP Request
  ↓
Node.js
  ↓
Express
  ↓
Middleware Pipeline
  ↓
Route Handler
  ↓
Database / Business Logic
  ↓
HTTP Response
  ↓
Client
Enter fullscreen mode Exit fullscreen mode

Now we know how a request reaches Express and how it moves through the application.

But eventually, the route handler needs data.

So the next question is:

When our application asks MongoDB for a user, how does MongoDB actually find that data?

That's what we'll explore in the next part of Under the Hood.


References

  1. Express.js — Using Middleware

  2. Express.js — Writing Middleware for Use in Express Apps

  3. Express.js — Error Handling

  4. Express.js — Routing

  5. Express.js — Basic Routing

Top comments (0)