0x55aa
Back to Blog

🏭 Node.js Middleware: The Assembly Line Your API Didn't Know It Needed

|
6 min read

🏭 Node.js Middleware: The Assembly Line Your API Didn't Know It Needed

Imagine a car factory. Raw steel goes in one end, a finished vehicle rolls out the other. In between, dozens of stations each do one job: weld this panel, paint that door, bolt on the wheels. No station tries to do everything — each just receives the car in its current state, does its work, and passes it along.

That's Express middleware. Your HTTP request is the raw steel. Your route handler is the finished car. And every middleware function in between is one station on the assembly line.

Most Node.js developers use middleware daily without thinking much about it — app.use(express.json()), slap on some auth check, done. But once you really get the mental model, you start writing APIs that are cleaner, easier to test, and genuinely satisfying to maintain.

What Middleware Actually Is

Here's the thing nobody tells you plainly: middleware is just a function with three parametersreq, res, and next.

function myMiddleware(req, res, next) {
  // Do something with the request
  console.log(`${req.method} ${req.path}`);
  
  // Pass control to the next function in the chain
  next();
}

That's it. That's the whole secret. The next() call is what moves the request down the assembly line to the next station. Forget to call next() and the request just... sits there. Like a car stuck at the welding station while the rest of the factory waits. Your client eventually gets a timeout and files a complaint with HR.

Express processes middleware in the order you register it with app.use(). Order matters enormously — register your auth middleware after your route handlers and you've just shipped a car with no engine.

Building Real Middleware That Earns Its Keep

Let's build three middleware functions that solve real problems.

Station 1: Request Logging

const requestLogger = (req, res, next) => {
  const start = Date.now();

  // Hook into the response finish event to log after the request completes
  res.on('finish', () => {
    const duration = Date.now() - start;
    const logLevel = res.statusCode >= 400 ? 'ERROR' : 'INFO';
    console.log(
      `[${logLevel}] ${req.method} ${req.path}${res.statusCode} (${duration}ms)`
    );
  });

  next(); // Don't forget this! The car needs to keep moving.
};

app.use(requestLogger);

Notice we hook into res.on('finish') rather than logging before next(). That way we capture the actual response status code instead of always logging 200 before the route handler even runs. Small detail, massive difference in debugging usefulness.

Station 2: Auth Checking

const requireAuth = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Attach user data for downstream handlers
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid or expired token' });
  }
};

// Apply only to protected routes — not everything needs a badge
app.use('/api/dashboard', requireAuth);
app.use('/api/admin', requireAuth);

The key move here is req.user = decoded. You're enriching the request object as it passes through the assembly line. By the time the route handler gets it, the user is already attached — no need to decode the token again.

Station 3: Rate Limiting (the simple version)

const rateLimiter = (() => {
  const requests = new Map();
  const WINDOW_MS = 60_000; // 1 minute
  const MAX_REQUESTS = 100;

  return (req, res, next) => {
    const ip = req.ip;
    const now = Date.now();
    const windowStart = now - WINDOW_MS;

    // Clean up old entries
    const timestamps = (requests.get(ip) || []).filter(t => t > windowStart);
    
    if (timestamps.length >= MAX_REQUESTS) {
      return res.status(429).json({ error: 'Too many requests. Slow down.' });
    }

    timestamps.push(now);
    requests.set(ip, timestamps);
    next();
  };
})();

app.use('/api/', rateLimiter);

Yes, in production you'd use Redis for this so it works across multiple server instances. But understanding the in-memory version first makes the Redis version make sense — same logic, different storage backend.

The Error Middleware Special Case

Express has a special flavor of middleware for error handling: four parameters instead of three.

// Normal middleware: 3 params
app.use((req, res, next) => { /* ... */ });

// Error middleware: 4 params — Express detects this signature
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);
  res.status(err.status || 500).json({
    error: err.message || 'Something went wrong',
  });
});

Register this one last, after all your routes. It only gets called when you invoke next(err) from somewhere upstream — passing an argument to next tells Express to skip ahead to the error handler. Think of it as the quality control station at the end of the line that catches defects before the car ships.

The Composability Superpower

The real payoff of thinking in middleware is composability. You can stack multiple functions on a single route:

app.post(
  '/api/admin/users',
  requireAuth,          // Must be logged in
  requireRole('admin'), // Must be an admin
  validateBody(userSchema), // Body must match schema
  asyncHandler(createUser)  // Finally, do the actual work
);

Each function is small, single-purpose, and independently testable. Want to test requireRole? Just call it with mock req, res, and next objects. No database needed. No full server needed.

This is what "separation of concerns" actually looks like in practice — not a theoretical principle, but a pattern that makes your 2am debugging sessions significantly less painful.

Common Mistakes to Avoid

Forgetting return before res.json(): Without return, execution continues after sending the response and will likely call next() too, causing "headers already sent" errors that are annoying to track down.

Putting error middleware before routes: It'll never be reached. Always last.

Making every middleware global: Not every route needs auth, logging, and validation. Apply middleware surgically — either at the route level or to specific path prefixes.

The Takeaway

Middleware is Express's killer feature, and it's deceptively simple. Once you internalize the "assembly line" mental model — each function does one job, enriches the request, and passes it along — you'll start seeing opportunities to extract messy route handler logic into clean, reusable middleware everywhere.

Your future self, debugging a production issue at midnight, will thank you.

What middleware patterns have saved your sanity? Drop your favorites in the comments — especially if you've built something clever with the error handling pattern. I'm always looking for new ideas to steal.

Thanks for reading!

Back to all posts