Introduction

app.use(express.json()) after your route handlers means every req.body is undefined. That one line in the wrong place breaks everything.

Middleware order is the whole game in Express. Not the concept of middleware — that is just a function with req, res, next. The interesting part is all the ways ordering goes wrong. Error handlers that never catch errors because they have three parameters instead of four. Auth middleware that runs on public routes. Rate limiters registered ahead of static file serving that throttle your CSS.

So instead of walking through middleware concepts bottom-up, this is organized around the mistakes. What breaks, why, and what the fix looks like.

How Middleware Works Internally

A middleware function gets three arguments: req, res, and next. Registration order is execution order. Each function can modify the request, send a response, or call next().

basic-middleware.js
const express = require('express');
const app = express();

// A simple logging middleware
const requestLogger = (req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);

  // Attach a custom property to the request
  req.requestTime = timestamp;

  // Pass control to the next middleware
  next();
};

// Register it globally -- runs for every request
app.use(requestLogger);

app.get('/', (req, res) => {
  res.json({
    message: 'Hello from Express!',
    requestedAt: req.requestTime
  });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});
Terminal running node basic-middleware.js and curl on /: the response includes requestedAt 2026-10-27T11:51:44.095Z and the server logs the same timestamp with GET /.
Real output. requestLogger logs the request and attaches req.requestTime, which the route handler then returns in its JSON.

Register logging after your route handler and it never runs for that route. Register it after express.static() and it misses every static file request. Obvious in theory. Less obvious when you are staring at empty logs.

The other trap: forget to call next() and also forget to send a response. The request hangs forever. Express does not throw an error or warn you. It just sits there. Silently. Check for missing next() calls first whenever a request hangs.

Mistakes in Scoping

Your file upload middleware should not run on authentication endpoints. Your admin checks should not fire on public pages. But with app.use(), everything is global by default. Router-level middleware fixes this by binding to an express.Router() instance instead of the app.

router-middleware.js
const express = require('express');
const app = express();

// Create separate routers for different concerns
const apiRouter = express.Router();
const adminRouter = express.Router();

// This middleware only runs for /api/* routes
apiRouter.use((req, res, next) => {
  console.log('API request received');
  req.isApiRequest = true;
  next();
});

apiRouter.get('/users', (req, res) => {
  res.json({ users: ['Alice', 'Bob', 'Charlie'] });
});

apiRouter.get('/posts', (req, res) => {
  res.json({ posts: ['Hello World', 'Second Post'] });
});

// This middleware only runs for /admin/* routes
adminRouter.use((req, res, next) => {
  console.log('Admin area accessed');
  // You could add admin-specific auth checks here
  next();
});

adminRouter.get('/dashboard', (req, res) => {
  res.json({ dashboard: 'Admin stats here' });
});

// Mount the routers at their respective paths
app.use('/api', apiRouter);
app.use('/admin', adminRouter);

app.listen(3000);

Each router gets its own middleware stack. A new developer opens a router file and sees exactly what middleware applies. No tracing through the entire app setup. And this is the opinion I will push: if your Express app has more than five routes and you are not using routers, you are making a mistake. Global middleware is a footgun at scale.

Mistakes in Body Parsing

Express ships three built-in middleware functions. You do not need body-parser separately anymore — bundled since 4.16.

express.json() parses JSON bodies. Without it, req.body is undefined on POST requests. It enforces a size limit: the default is 100kb, and larger bodies are rejected with a 413 before your handler runs. That default is fine for most JSON APIs. Lower it if your payloads are small (the example uses 10kb), and raise it only on the specific routes that need bigger bodies instead of globally.

express.urlencoded() handles form submissions. express.static() serves files from a directory.

built-in-middleware.js
const express = require('express');
const path = require('path');
const app = express();

// Parse JSON bodies with a 10kb limit
app.use(express.json({
  limit: '10kb',
  strict: true // Only accept arrays and objects
}));

// Parse URL-encoded form data
app.use(express.urlencoded({
  extended: true,
  limit: '10kb'
}));

// Serve static files from the 'public' directory
// Files will be accessible at the root URL path
// e.g., public/styles.css -> http://localhost:3000/styles.css
app.use(express.static(path.join(__dirname, 'public'), {
  maxAge: '1d', // Cache files for 1 day
  etag: true, // Enable ETag headers
  lastModified: true, // Enable Last-Modified headers
  index: 'index.html' // Default file for directories
}));

app.post('/api/data', (req, res) => {
  // req.body is now available because of express.json()
  console.log(req.body);
  res.json({ received: true, data: req.body });
});

app.listen(3000);
Terminal showing a JSON POST echoed back with received true, then a 12 KB POST rejected with HTTP 413 Payload Too Large and a PayloadTooLargeError with limit 10240 in the server log.
Real output. express.json() fills req.body for the small payload and rejects the 12 KB one with a 413 before the handler runs.

Read the code.

Mistakes in Error Handling

Express identifies error handlers by argument count. Four parameters means error handler. Three means regular middleware. Name your function errorHandler but give it three parameters and Express will never route errors to it. No warning. No error. It just silently skips it.

This is honestly a terrible API decision. The framework distinguishes behavior based on Function.length, which means arrow functions with destructured defaults can silently change the argument count. But it is the API we have, so you need to know the 4-argument signature before looking at any code.

The next two snippets are pieces of the same app rather than standalone files: they reuse app from the earlier examples, and User and Post stand in for your data models (Mongoose, Prisma or similar). auth-middleware.js also reuses AppError and asyncHandler from error-handling.js.

error-handling.js
// Fragment: assumes `app` from earlier and a `User` model
// Custom error class for operational errors
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// Async handler wrapper -- eliminates try/catch blocks
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Route that might throw an error
app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new AppError('User not found', 404);
  }
  res.json({ user });
}));

// Handle 404 for unmatched routes (must come after all routes)
app.use((req, res, next) => {
  next(new AppError(`Cannot find ${req.originalUrl}`, 404));
});

// Centralized error handler (must have 4 parameters!)
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const message = err.isOperational
    ? err.message
    : 'Something went wrong on our end';

  // Log the full error in development
  if (process.env.NODE_ENV === 'development') {
    console.error('Error:', err);
  }

  res.status(statusCode).json({
    status: 'error',
    statusCode,
    message,
    ...(process.env.NODE_ENV === 'development' && {
      stack: err.stack
    })
  });
});

AppError separates operational errors from programming errors. Operational errors get their message sent to the client. Programming errors get a generic "Something went wrong." You do not want to leak stack traces.

The asyncHandler wrapper is an Express 4 fix. In Express 4, a rejected promise in an async route handler never reaches your error middleware. It becomes an unhandled rejection, and on Node 15 and later that crashes the process by default. The wrapper catches the rejection and forwards it to next().

Express 5 Handles Async Errors for You

Express 5 has been the latest release on npm since 5.1.0 (March 2025). Its router forwards a rejected promise or a thrown error from an async handler or middleware to next(err) on its own, so new Express 5 apps can drop the wrapper:

express5-async-errors.js
const express = require('express'); // express@5
const app = express();

// Stand-in for a database call that fails
const findUser = async (id) => {
  throw new Error(`Database unavailable while loading user ${id}`);
};

// No wrapper: Express 5 passes the rejected promise to next(err)
app.get('/users/:id', async (req, res) => {
  const user = await findUser(req.params.id);
  res.json({ user });
});

app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

app.listen(3000);
Terminal showing curl -i on /users/42 returning HTTP 500 with JSON error Database unavailable while loading user 42, then a second request still answered with 500.
Real output. Express 5 forwards the rejected promise to the error handler, so the client gets a 500 with the message and the server keeps serving requests.

Requesting /users/42 returns a 500 with the error message from your handler, and the process keeps running. The asyncHandler calls in the rest of this article still work on Express 5; they just become redundant.

Ordering: 404 handler after all routes, error handler after that. Nope, not before. After.

Mistakes in Auth Middleware

A common mistake: applying auth middleware globally and then carving out exceptions for public routes. Do it the other way around. Keep routes unprotected by default, apply auth per-router or per-route. Otherwise every new public endpoint requires remembering to add it to the exclusion list, and the one time you forget is the one time it matters.

auth-middleware.js
// Fragment: reuses app, AppError and asyncHandler from error-handling.js
const jwt = require('jsonwebtoken');

// JWT verification middleware
const authenticate = asyncHandler(async (req, res, next) => {
  // 1. Extract the token from the Authorization header
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    throw new AppError('No authentication token provided', 401);
  }

  const token = authHeader.split(' ')[1];

  // 2. Verify the token
  const decoded = jwt.verify(token, process.env.JWT_SECRET);

  // 3. Check if user still exists in the database
  const user = await User.findById(decoded.id);
  if (!user) {
    throw new AppError('User no longer exists', 401);
  }

  // 4. Attach user to the request object
  req.user = user;
  next();
});

// Role-based authorization middleware (factory function)
const authorize = (...allowedRoles) => {
  return (req, res, next) => {
    if (!req.user) {
      return next(
        new AppError('Authentication required', 401)
      );
    }

    if (!allowedRoles.includes(req.user.role)) {
      return next(
        new AppError('Insufficient permissions', 403)
      );
    }

    next();
  };
};

// Usage: chain authenticate and authorize together
app.get('/api/users',
  authenticate,
  authorize('admin', 'manager'),
  asyncHandler(async (req, res) => {
    const users = await User.find();
    res.json({ users });
  })
);

// Public route -- no auth required
app.get('/api/public/posts', asyncHandler(async (req, res) => {
  const posts = await Post.find({ published: true });
  res.json({ posts });
}));

authorize is a factory function. Pass in the roles you need, get back a middleware function. No separate middleware per role. And look at how the middleware chains on the route: authenticate, then authorize('admin', 'manager'), then the handler. If authentication fails, nothing downstream runs.

Worth it.

Rate Limiting and Security Middleware

A bot hits your API with thousands of requests per minute. Node runs out of memory. The process crashes. Rate limiting would have stopped this.

rate-limiter.js
// Simple in-memory rate limiter (good for learning)
const createRateLimiter = (windowMs, maxRequests) => {
  const requests = new Map();

  // Clean up expired entries every minute
  setInterval(() => {
    const now = Date.now();
    for (const [key, data] of requests) {
      if (now - data.windowStart > windowMs) {
        requests.delete(key);
      }
    }
  }, 60000);

  return (req, res, next) => {
    const clientIp = req.ip;
    const now = Date.now();
    const clientData = requests.get(clientIp);

    if (!clientData || now - clientData.windowStart > windowMs) {
      // New window for this client
      requests.set(clientIp, {
        windowStart: now,
        count: 1
      });
      return next();
    }

    if (clientData.count >= maxRequests) {
      const retryAfter = Math.ceil(
        (clientData.windowStart + windowMs - now) / 1000
      );
      res.set('Retry-After', retryAfter);
      return res.status(429).json({
        error: 'Too many requests',
        retryAfter: `${retryAfter} seconds`
      });
    }

    clientData.count++;
    next();
  };
};

// Apply different limits for different routes
app.use('/api/', createRateLimiter(60000, 100)); // 100 req/min
app.use('/auth/', createRateLimiter(900000, 10)); // 10 req/15min
Terminal running a check script: 100 responses with status 200 and one 429 with Retry-After 60 and body Too many requests, retryAfter 60 seconds.
Real output. The first 100 requests in the window pass; the 101st gets a 429 with a Retry-After header and the JSON error body.

In-memory rate limiting works for single-server setups. For multiple instances, back it with Redis. The express-rate-limit package with a Redis store handles this.

Other security middleware: helmet (HTTP headers), cors (cross-origin), input sanitization (NoSQL injection). Each roughly one line. Watch where the limiter sits relative to express.static(). Middleware runs in registration order, so a global limiter registered before express.static() counts every CSS, JS and image request, and legitimate API calls get throttled. Register express.static() first (static hits end there and never reach the limiter), or scope the limiter to your API paths as the example does with app.use('/api/', ...).

Getting It Right

Factory functions. Accept configuration, return middleware. Be explicit about what you modify on req and res. Handle errors instead of swallowing them. Do one thing.

custom-middleware.js
// 1. Request validation middleware factory
const validate = (schema, source = 'body') => {
  return (req, res, next) => {
    const dataToValidate = req[source];
    const { error, value } = schema.validate(dataToValidate, {
      abortEarly: false,
      stripUnknown: true
    });

    if (error) {
      const errors = error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message
      }));
      return res.status(400).json({
        status: 'validation_error',
        errors
      });
    }

    // Replace with sanitized/validated data
    req[source] = value;
    next();
  };
};

// Usage with Joi
const Joi = require('joi');

const createUserSchema = Joi.object({
  name: Joi.string().min(2).max(50).required(),
  email: Joi.string().email().required(),
  age: Joi.number().min(13).max(120)
});

app.post('/api/users',
  validate(createUserSchema),
  asyncHandler(async (req, res) => {
    // req.body is guaranteed to be valid here
    const user = await User.create(req.body);
    res.status(201).json({ user });
  })
);
response-time-middleware.js
// 2. Response time and request ID middleware
const crypto = require('crypto');
const onHeaders = require('on-headers'); // npm install on-headers

const requestMetrics = (options = {}) => {
  const {
    headerName = 'X-Response-Time',
    idHeaderName = 'X-Request-Id',
    logSlowRequests = true,
    slowThreshold = 1000 // milliseconds
  } = options;

  return (req, res, next) => {
    // Generate a unique request ID
    const requestId = crypto.randomUUID();
    req.id = requestId;
    res.set(idHeaderName, requestId);

    // Track response time
    const start = process.hrtime.bigint();
    const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1e6;

    // Headers can only be set before they are sent, so add
    // the timing header just before they go out
    onHeaders(res, () => {
      res.setHeader(headerName, `${elapsedMs().toFixed(2)}ms`);
    });

    // 'finish' fires after the response is sent: log only, no headers
    res.on('finish', () => {
      const durationMs = elapsedMs();
      if (logSlowRequests && durationMs > slowThreshold) {
        console.warn(
          `[SLOW] ${req.method} ${req.url}`,
          `${durationMs.toFixed(2)}ms`,
          `ID: ${requestId}`
        );
      }
    });

    next();
  };
};

// Register with custom configuration
app.use(requestMetrics({
  slowThreshold: 500,
  logSlowRequests: process.env.NODE_ENV === 'production'
}));

The validation middleware takes a schema and an optional source (body, params, or query). It strips unknown fields and returns structured errors with field paths. Much easier for a frontend than a generic 400.

The metrics middleware assigns a unique request ID and times the request. The timing header is written from an on-headers hook, which runs just before the headers are sent. Setting it inside the finish event would be too late: by then the headers are gone and res.set() throws ERR_HTTP_HEADERS_SENT. So finish is used only for logging, which measures the full cycle. The response-time package does the same job if you only need the header. When someone reports a bug, you ask for the request ID from their response headers and trace it through your logs.

Think about what happens when your custom middleware throws. If it does anything async, catch errors and forward them with next(err). Middleware that crashes silently is worse than middleware that does not exist.

Putting It All Together

A production middleware stack, top to bottom:

  1. Security headers (Helmet, CORS) — before any response goes out.
  2. Request metrics (IDs, timing) — wraps everything.
  3. Body parsing (express.json, express.urlencoded) — before anything reads req.body.
  4. Static file serving — before the rate limiter, so asset requests never count against it.
  5. Rate limiting, ideally scoped to /api.
  6. Router-level middleware (auth, validation) per route group.
  7. Route handlers.
  8. 404 handler for unmatched routes.
  9. Error handler — always last.

This is not convention. It is correctness. Security headers after a route handler means some responses ship without them. Body parsing after a POST handler means req.body is undefined. Error handler before routes means it never catches anything. On Express 4, wrap every async handler with asyncHandler; on Express 5 the router does it for you. Get these right and most middleware bugs just stop happening.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.