Conditional Middleware in Express: Why runMiddleware Exists (and When You Should Remove It)

 

Conditional Middleware in Express: Why runMiddleware Exists (and When You Should Remove It)

Express Middleware Flow

Introduction

In many real-world Express.js applications, especially authentication flows, developers encounter a subtle but important problem:

How do you execute an Express middleware conditionally inside a route handler and still keep the code readable and safe?

This blog explains:

  • Why runMiddleware(req, res, middleware) is sometimes introduced

  • What problem it actually solves

  • Why res.headersSent checks appear alongside it

  • When this pattern is justified

  • When it is unnecessary and should be removed

The goal is not abstraction for its own sake, but clarity, correctness, and debuggability.


The Scenario

Consider a login endpoint that supports two actions:

  • Generate OTP (rate-limited)

  • Login using OTP (not rate-limited)

Both actions share the same route:

POST /admin

The OTP action must be protected by a rate limiter, but the login action should not be throttled. This makes route-level middleware insufficient.


Why You Cannot await Express Middleware Directly

Express middleware follows this signature:

(req, res, next) => {}

It is callback-based, not promise-based. Therefore, the following does not work:

await customLimiter(req, res);

There is no promise to await, and Express provides no built-in way to pause execution until next() is called.


The Purpose of runMiddleware

The runMiddleware helper is a Promise adapter. It converts a callback-style middleware into something that can be awaited.

function runMiddleware(req, res, fn) {
  return new Promise((resolve, reject) => {
    fn(req, res, (err) => {
      if (err) return reject(err);
      resolve();
    });
  });
}

This allows middleware to be executed inside a route handler, conditionally:

await runMiddleware(req, res, customLimiter);

At this point, execution pauses until the middleware either:

  • Calls next() → promise resolves

  • Sends a response → route must stop


Why res.headersSent Is Mandatory

Rate limiters often terminate the request themselves:

res.status(429).json({ message: "Too many requests" });

When this happens:

  • The response is already sent

  • Any further attempt to write to res will crash

  • Database connections must be released

That is why this guard exists:

if (res.headersSent) {
  client.release();
  return;
}

Without it, your application risks:

  • ❌ "Cannot set headers after they are sent" errors

  • ❌ Database connection leaks

  • ❌ Unpredictable runtime failures

This check is not defensive coding—it is required for correctness.


When This Pattern Is Justified

Using runMiddleware is appropriate only when all of the following are true:

  • Middleware must run conditionally

  • The same route handles multiple actions

  • Some actions require throttling, others do not

  • You want explicit control over execution flow

In such cases, this pattern is valid and professional.


When You Should Remove It

If any of the following is true, the pattern is unnecessary:

  • The entire route can be rate-limited

  • OTP and login are separate routes

  • Simplicity is preferred over micro-optimization

Preferred Alternative (Simpler)

router.post("/admin", customLimiter, async (req, res) => {
  // entire route is protected
});

This approach:

  • Eliminates runMiddleware

  • Removes res.headersSent checks

  • Is easier to read and debug

  • Is sufficient for most applications


Professional Recommendation

Do not introduce runMiddleware unless you truly need conditional middleware execution.

In backend systems, fewer abstractions often mean:

  • Easier debugging

  • Clearer control flow

  • Lower cognitive load

Use runMiddleware deliberately, not defensively.


Summary

ConcernExplanation
Why runMiddleware exists        To await callback-based middleware
Why res.headersSent is checked        Middleware may already send a response
Is it mandatory?        Only for conditional middleware
Best default choice        Route-level middleware

Closing Thoughts

Understanding why a pattern exists is more important than memorizing it. Once you understand the execution model of Express, decisions like these become straightforward engineering trade-offs—not magic.

If your goal is clarity and maintainability, always start simple and introduce complexity only when it is justified.

Comments