If you've been writing Express apps for a while, you've used middleware. app.use(express.json()), app.use(cors()), the usual suspects. But there's a difference between using something and understanding it. This post is about the latter.
What Express Actually Is
Before middleware makes sense, you need to understand what Express is at its core.
Express is built on top of Node's built-in http module. When you do const app = express(), app is a function — specifically, a function that Node's HTTP server uses as a request handler. When you call app.listen(3000), Express tells Node: "spin up an HTTP server and every time a request comes in, run it through me."
Node's raw HTTP server hands you this:
http.createServer((req, res) => {
// no routing
// no req.body
// no req.params
// you're completely on your own
})
Express wraps that and adds a middleware pipeline on top. That pipeline is the entire point of the framework.
The Pipeline
When a request hits your server, Express doesn't run a single function. It maintains an internal array of middleware functions, built up as you call app.use() and define routes.
app.use(middlewareA)
app.use(middlewareB)
app.use(middlewareC)
Internally: [middlewareA, middlewareB, middlewareC]
When a request arrives, Express starts at index 0 and works forward. next() is the mechanism that moves the index forward. When you call next(), you're saying "increment the pointer, run the next function in the array."
This is why order matters — you're building the array sequentially, and requests walk through it in the same sequence.
The Middleware Function
function middleware(req, res, next) {}
req — Not Node's raw request object. Express extends it. On top of req.url, req.method, req.headers, you get req.params, req.query, req.body, req.ip, req.path. Express added all of those.
res — Same story. Express extends Node's raw response. That's where res.json(), res.send(), res.status(), res.redirect() come from. None of those exist in raw Node.
next — A function Express generates internally and passes to each middleware. It knows the current position in the middleware array and how to advance it.
How next() Really Works
Basic call — move forward:
next()
Advances to the next middleware. Nothing else.
Call with an argument — trigger error handling:
next(new Error('something failed'))
When you pass anything into next, Express skips every remaining normal middleware and route handler, jumping straight to your error-handling middleware. This is how you centralize error handling instead of writing res.status(500) in fifty different places.
One thing that trips people up — never call next() after you've already sent a response:
// dangerous
res.send('Done')
next() // still runs, can cause "cannot set headers after they are sent"
// correct
return res.send('Done') // function exits, nothing else runs
app.use() vs app.get()
These are not the same thing.
app.use(path, middleware) — matches any HTTP method, and matches the path as a prefix. app.use('/api', handler) fires for /api, /api/users, /api/anything/nested. The path is optional — omit it and it defaults to /, meaning it matches every incoming request.
app.get(path, handler) — matches only GET requests, and the path must match exactly.
app.use() is for broad middleware. app.get/post/put/delete is for actual route handling.
How express.json() Works Internally
When a POST request comes in with a JSON body, that body arrives as a raw stream of bytes. Node doesn't collect or parse it automatically. express.json() does three things:
- Checks the
Content-Typeheader. If it's not application/json, it does nothing and callsnext()immediately. - Reads the entire body stream into memory, collecting all the chunks.
- Once the stream ends, it runs
JSON.parse()on the collected data and attaches the result toreq.body.
If the JSON is malformed, it calls next(err) with a 400 error automatically. You don't have to handle that yourself.
app.use(express.json({ limit: '10kb' })) // reject large payloads
Middleware Scope
Middleware doesn't have to be global. Three levels:
Application level — runs for everything:
app.use(express.json())
app.use(logger)
Route level — only runs for specific routes:
app.get('/dashboard', requireAuth, (req, res) => {
res.send('Private')
})
Router level — scoped to a group of routes:
const router = express.Router()
router.use(requireAuth)
router.get('/profile', handler)
router.get('/settings', handler)
app.use('/user', router)
Now every route under /user requires auth, without touching global middleware.
Mutating req — The Pattern You'll See Everywhere
req is just a JavaScript object. It lives for the lifetime of a single request. Any middleware can add properties to it, and anything downstream can read those properties.
function attachUser(req, res, next) {
const token = req.headers.authorization
const decoded = verifyToken(token)
req.user = decoded
next()
}
app.get('/profile', attachUser, (req, res) => {
res.json(req.user) // available because middleware put it here
})
This is how authentication works in every serious Express app. Decode the token, attach the user, every route handler just reads req.user.
Error Middleware
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message })
})
The four-parameter signature isn't a convention — Express checks function.length (the number of declared parameters) to decide if something is error middleware. If it sees 4, it registers it as an error handler. If it sees 3, it's normal middleware.
This is why you must declare all four parameters even if you don't use next in your error handler. And it goes at the very bottom of your file, after all routes.
The Full Picture
Incoming request
|
↓
[express.json()] reads body, attaches to req.body, calls next()
|
↓
[logger] logs method + url, calls next()
|
↓
[requireAuth] checks token
| no token → res.status(401) ← STOPS HERE
↓ token exists → calls next()
[route handler] does the actual work, sends response
If any middleware sends a response, the chain stops. If any middleware calls next(err), it jumps to the error handler. If every middleware calls next() and no route matches, Express sends a default 404.
Middleware isn't magic. It's a function array, a pointer, and a convention. Once you see it that way, debugging Express apps becomes a lot less frustrating — you can always trace exactly where in the chain a request gets stuck or misdirected.
Top comments (0)