DEV Community

Chinwuba
Chinwuba

Posted on

Express Middleware — What It Actually Is and How It Really Works

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
  })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

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:

  1. Checks the Content-Type header. If it's not application/json, it does nothing and calls next() immediately.
  2. Reads the entire body stream into memory, collecting all the chunks.
  3. Once the stream ends, it runs JSON.parse() on the collected data and attaches the result to req.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
Enter fullscreen mode Exit fullscreen mode

Middleware Scope

Middleware doesn't have to be global. Three levels:

Application level — runs for everything:

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

Route level — only runs for specific routes:

  app.get('/dashboard', requireAuth, (req, res) => {
    res.send('Private')
  })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
  })
Enter fullscreen mode Exit fullscreen mode

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 })
  })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)