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());
or:
app.use((req, res, next) => {
console.log("Request received");
next();
});
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);
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);
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();
});
It receives three important things:
req
res
next
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");
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();
});
When a request arrives, Express processes them in order.
Request
↓
Middleware 1
↓
Middleware 2
↓
Middleware 3
↓
Route Handler
↓
Response
This is called a middleware chain.
Each middleware can:
- Modify the request
- Modify the response
- Perform some operation
- End the request
- Pass control to the next middleware
4. Why Do We Need next()?
Consider:
app.use((req, res, next) => {
console.log("Middleware 1");
});
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();
});
Now Express knows:
Middleware 1
↓
next()
↓
Next Middleware
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");
});
The request ends here.
The flow becomes:
Request
↓
Middleware
↓
Response
↓
Request Ends
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();
});
Later middleware can access:
console.log(req.userRole);
The flow becomes:
Incoming Request
↓
Authentication Middleware
↓
req.userRole = "admin"
↓
Next Middleware
↓
Route Handler
This allows information to be passed through different stages of request processing.
7. A Real Authentication Example
Imagine an API endpoint:
GET /profile
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();
}
Then use it:
app.get("/profile", authenticate, (req, res) => {
res.json({
message: "Profile data"
});
});
The flow is:
Client
↓
GET /profile
↓
authenticate()
↓
Token exists?
↓
┌───────────────┐
│ │
No Yes
│ │
↓ ↓
401 next()
Response ↓
Route
↓
Response
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();
});
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();
});
This is useful when an application has multiple groups of routes.
For example:
/users
/products
/orders
We can have different middleware for different routers.
Built-in middleware
Express also provides built-in middleware.
For example:
app.use(express.json());
This allows Express to parse incoming JSON request bodies.
If the client sends:
{
"name": "Aryan"
}
the parsed data can be accessed through:
req.body
Error-handling middleware
Express also supports special middleware for handling errors.
It has four parameters:
(err, req, res, next)
For example:
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({
message: "Something went wrong"
});
});
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");
});
A request to / produces:
A
B
C
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();
});
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
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();
});
If there is no authorization header:
Request
↓
Authentication Middleware
↓
No Token
↓
401 Response
↓
Request Ends
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"
});
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();
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();
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);
}
});
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"
});
});
The flow becomes:
Request
↓
Route
↓
Database Operation
↓
Error
↓
next(error)
↓
Error Middleware
↓
Error Response
This gives applications a centralized way to handle errors.
14. next() vs next(error)
There is an important difference.
next()
Means:
Continue normally.
next();
Flow:
Middleware
↓
next()
↓
Next Middleware
next(error)
Means:
Something went wrong. Pass this error forward.
next(error);
Flow:
Middleware
↓
Error
↓
next(error)
↓
Error Handler
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();
}
We can use it globally:
app.use(logger);
Or for a specific route:
app.get("/profile", logger, (req, res) => {
res.send("Profile");
});
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);
Now imagine a request:
GET /profile
The simplified flow is:
Request
↓
express.json()
↓
Logger
↓
authenticate
↓
Token exists?
/ \
No Yes
↓ ↓
401 Response Route
↓
Response
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
Each matching layer gets a chance to process the request.
Calling:
next();
moves processing forward.
Sending a response ends the request.
Calling:
next(error);
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");
});
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();
Prefer:
if (!token) {
return res.status(401).send("Unauthorized");
}
next();
❌ 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
Each middleware can:
Inspect
↓
Modify
↓
Continue
↓
OR
↓
End Request
And when something goes wrong:
Error
↓
next(error)
↓
Error Middleware
↓
Error Response
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
reqandres. - 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
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.
Top comments (0)