0x55aa
Back to Blog

🎭 Express Middleware: The Assembly Line Your API Didn't Know It Needed

|
6 min read

Every HTTP request that hits your Express server goes on a little journey. It doesn't just magically appear in your route handler — it runs a gauntlet first. That gauntlet? Middleware.

Think of middleware as a factory assembly line. The raw request comes in one end, each station does something to it (attaches a user, logs a timestamp, validates a body), and eventually a polished request reaches your route handler. Or it gets rejected mid-line and sent back early. Either way, the assembly line metaphor holds.

If you've been writing Express apps by just pasting app.use(express.json()) at the top without really understanding what's happening, this one's for you.

What Actually IS Middleware?

A middleware function has three ingredients: req, res, and next. It's just a function.

function myMiddleware(req, res, next) {
  // do something to the request or response
  console.log(`${req.method} ${req.url}`);
  next(); // pass control to the next middleware
}

app.use(myMiddleware);

That next() call is the key. If you forget it, your request will hang forever — the assembly line stalls, the worker just stands there, and your user's browser starts a countdown to timeout. Don't be that worker.

The beauty is that middleware runs in the order you register it. This matters enormously. Registering your auth middleware after your route handler is like checking IDs after letting everyone into the club. Chaos.

The Three Flavors You'll Actually Use

1. Application-Level Middleware

This runs on every incoming request, no exceptions. Perfect for logging, parsing bodies, adding CORS headers, or attaching request IDs.

const { v4: uuidv4 } = require('uuid');

// Tag every request with a unique ID for tracing
app.use((req, res, next) => {
  req.requestId = uuidv4();
  res.setHeader('X-Request-Id', req.requestId);
  next();
});

// Log every request with its ID
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`[${req.requestId}] ${req.method} ${req.url} ${res.statusCode} - ${duration}ms`);
  });
  next();
});

Register these at the very top. They need to run before everything else.

2. Route-Level Middleware

Sometimes you don't want middleware running on every single route — just specific ones. Authentication is the classic example: your /health endpoint doesn't need a JWT check, but /api/user/profile absolutely does.

function requireAuth(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    req.user = verifyToken(token); // attaches decoded user to req
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Only the protected routes get the middleware
app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ user: req.user });
});

app.get('/health', (req, res) => {
  res.json({ status: 'ok' }); // no auth needed here
});

You can pass middleware directly as a second argument, or chain multiple middleware functions: app.post('/route', mw1, mw2, mw3, handler). They execute left to right.

3. Error-Handling Middleware

This is where most developers drop the ball. Express has a special type of middleware for errors — it takes four arguments instead of three: err, req, res, next. That extra err parameter is how Express knows it's an error handler.

// Regular route that might throw
app.get('/data', async (req, res, next) => {
  try {
    const data = await fetchFromDatabase();
    res.json(data);
  } catch (err) {
    next(err); // forward error to the error handler
  }
});

// Error handler — MUST be registered LAST
app.use((err, req, res, next) => {
  console.error(`[${req.requestId}] Error:`, err.message);

  const statusCode = err.statusCode || 500;
  const message = statusCode === 500 ? 'Internal server error' : err.message;

  res.status(statusCode).json({ error: message });
});

Two critical rules: always register error handlers last, and always call next(err) instead of throwing in async route handlers. An uncaught async throw bypasses Express error handling entirely and will crash your process (or silently fail, which is somehow worse).

The Order Problem That Bites Everyone

Here's a sneaky gotcha. Say you have this setup:

app.use(express.json());

app.post('/webhook', rawBodyMiddleware, handleWebhook); // needs raw body!

app.use(bodyParser.urlencoded({ extended: true }));

Your webhook route needs the raw, unparsed body (for signature verification), but express.json() has already consumed it by the time the request gets there. The fix is to either apply express.json() only to routes that need it, or save the raw body before parsing:

app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf; // stash raw bytes before parsing
  }
}));

Small detail, huge debugging session if you miss it.

Practical Patterns Worth Stealing

Middleware factories let you configure behavior at registration time:

function rateLimit(maxRequests, windowMs) {
  const counts = new Map();
  
  return (req, res, next) => {
    const key = req.ip;
    const now = Date.now();
    const entry = counts.get(key) || { count: 0, resetAt: now + windowMs };
    
    if (now > entry.resetAt) {
      entry.count = 0;
      entry.resetAt = now + windowMs;
    }
    
    entry.count++;
    counts.set(key, entry);
    
    if (entry.count > maxRequests) {
      return res.status(429).json({ error: 'Too many requests' });
    }
    
    next();
  };
}

app.use('/api', rateLimit(100, 60_000)); // 100 req/min on all API routes

This pattern — a function that returns middleware — is how packages like cors, helmet, and express-rate-limit work under the hood.

The Mental Model That Makes It Click

Think of req as a shopping cart. Each middleware can add items to it (req.user, req.requestId, req.parsedBody). By the time the cart reaches your route handler, it's fully loaded with everything you need.

The order of the assembly line determines what's in the cart. Log first, parse body second, authenticate third, authorize fourth — then handle the request. Flip that order and you're debugging at 2am wondering why req.user is undefined.

The Takeaway

Middleware is where the real power of Express lives. It keeps your route handlers lean (they just handle business logic) and pushes cross-cutting concerns — logging, auth, validation, error handling — into reusable, composable units.

Start with three rules: register application middleware early, register error handlers last, and never forget next(). Everything else is a variation on those fundamentals.

Now go refactor that 200-line route handler into something you'd actually want to read at 9am on a Monday.


What's your go-to middleware setup? Drop it in the comments — I'm especially curious what people are doing for request validation.

Thanks for reading!

Back to all posts