Senior 5 min · March 05, 2026

Express.js Middleware: Missing next() = 30-Second Timeouts

Missing next() in audit middleware caused 30-second 504 timeouts at 500+ req/s.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Middleware functions run in the order they are registered: sequential pipeline, not random.
  • Every middleware must either call next() or send a response — failure to do so hangs the request.
  • Use app.use() for global middleware, router-level for scoped concerns, and error handlers with 4 parameters.
  • req is a shared object across the chain — attach user data there, not in global variables.
  • In Express 4, async errors must be caught and forwarded with next(err); Express 5 will handle them automatically.
✦ Definition~90s read
What is Middleware in Express.js?

Express.js middleware is the core architectural pattern that makes Express tick. Every request that hits an Express server passes through a pipeline of functions — middleware — each of which can inspect, modify, or short-circuit the request-response cycle.

Imagine you're at an airport.

A middleware function receives three arguments: req, res, and next. It either ends the response (by calling res.send(), res.json(), etc.) or passes control to the next middleware by calling next(). If you forget to call next() and don't send a response, the request hangs until Express's default 30-second timeout kills it — a silent, maddening bug that's bitten every Express developer at least once.

Middleware exists because it decouples cross-cutting concerns (logging, auth, parsing, validation) from your route handlers, letting you compose behavior declaratively rather than repeating boilerplate in every endpoint.

In the Express ecosystem, middleware is the primary mechanism for extending the framework. Built-in middleware like express.json() parses incoming JSON bodies; third-party packages like morgan handle logging, cors manages cross-origin requests, and helmet sets security headers.

You can also write your own — and you will, for things like authentication checks, request timing, or input sanitization. The order you register middleware with app.use() or app.METHOD() is the order it executes. Put auth middleware after a public route handler, and you've accidentally locked down your login page.

Put a body parser after a route that reads req.body, and you'll get undefined. Middleware ordering isn't a style choice — it's a correctness requirement.

Alternatives to Express's middleware pattern exist. Fastify uses a plugin system with encapsulated contexts, and Koa leverages async functions with await next() for cleaner control flow. But Express's callback-based middleware remains the most widely understood and deployed pattern in Node.js, powering everything from tiny APIs to enterprise systems handling millions of requests daily.

When you shouldn't use Express middleware: if you need strict type safety, GraphQL subscriptions over WebSockets, or a framework that enforces architectural boundaries (like NestJS's modules), Express's free-form pipeline can become a liability. For most REST APIs and server-rendered apps, though, it's the right tool — provided you understand that next() isn't optional.

Plain-English First

Imagine you're at an airport. Before you reach your gate, you walk through check-in, then security, then passport control — each stop does one specific job before passing you along. Middleware in Express is exactly that: a series of checkpoint functions that every HTTP request walks through before it reaches your route handler. Each checkpoint can inspect the request, modify it, block it entirely, or wave it through to the next stop. That's it — middleware is just a pipeline of airport checkpoints for your web requests.

Every production Node.js API you've ever hit — whether it's logging your request, checking your auth token, or parsing the JSON body you sent — runs that logic through middleware. It's not a nice-to-have feature; it's the backbone of how Express applications are structured. Skip understanding middleware properly and you'll spend hours debugging why your route can't read req.body, or why your auth check fires on every route except the one that matters.

What Middleware Actually Is — The Anatomy of a Middleware Function

A middleware function in Express has a specific signature: it receives three arguments — req (the incoming request), res (the outgoing response), and next (a function that hands control to the next middleware in the chain). That third argument, next, is the key that separates middleware from a regular route handler. If you don't call next(), the request just hangs there — the client waits forever and nothing happens downstream.

Express processes middleware in the exact order you register it with app.use(). This isn't alphabetical, it isn't by route priority — it's purely sequential, top to bottom in your file. That means the order you write app.use() calls is a critical architectural decision, not just style.

There are five flavours of middleware you'll use in practice: application-level (app.use), router-level (router.use), error-handling (four arguments: err, req, res, next), built-in (like express.json()), and third-party (like morgan or helmet). Understanding which flavour to reach for — and when — separates junior from senior Express developers.

basicMiddlewarePipeline.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const express = require('express');
const app = express();

// ─── Middleware 1: Request Logger ───────────────────────────────────────────
// Runs on EVERY incoming request regardless of route
app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] Incoming: ${req.method} ${req.url}`);

  // Attach a custom property to req so later middleware/routes can use it
  req.requestTime = timestamp;

  // CRITICAL: call next() or the request dies here
  next();
});

// ─── Middleware 2: Parse JSON request bodies ─────────────────────────────────
// Without this, req.body is undefined for POST/PUT requests
app.use(express.json());

// ─── Middleware 3: Simple API Key Guard ─────────────────────────────────────
// Only runs if the two middleware above have already called next()
app.use((req, res, next) => {
  const apiKey = req.headers['x-api-key'];

  if (!apiKey || apiKey !== 'my-secret-key-123') {
    // Short-circuit: respond directly, do NOT call next()
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'A valid x-api-key header is required'
    });
  }

  next(); // Key is valid — pass control to the route handler
});

// ─── Route Handler: only reached if all middleware above called next() ────────
app.get('/products', (req, res) => {
  res.json({
    message: 'Here are your products!',
    requestReceivedAt: req.requestTime // set by our logger middleware above
  });
});

app.listen(3000, () => console.log('Server running on http://localhost:3000'));
Output
[2024-07-15T10:23:01.452Z] Incoming: GET /products
// If x-api-key header is missing:
{ "error": "Unauthorized", "message": "A valid x-api-key header is required" }
// If x-api-key: my-secret-key-123 is present:
{ "message": "Here are your products!", "requestReceivedAt": "2024-07-15T10:23:01.452Z" }
Watch Out: The Hanging Request
If your middleware function neither calls next() nor sends a response (res.json, res.send, etc.), the client's request will hang until it times out. This is one of the most common silent bugs in Express apps. Every middleware function must do one of two things: call next() or end the response.
Production Insight
In production, hanging middleware often goes unnoticed until a user reports a timeout. A single middleware that fails to call next() under a specific condition can bring down a whole endpoint. Always audit middleware for exhaustive next() calls — especially in conditional branches, early returns, or error handling.
Key Takeaway
Middleware is a sequential pipeline where each function must call next() or send a response.
Failing to do either creates a silent hanging request, the most common Express bug in production.
Always ensure every code path in your middleware either calls next() or terminates the response.
Express.js Middleware: Missing next() = 30-Second Timeouts THECODEFORGE.IO Express.js Middleware: Missing next() = 30-Second Timeouts Flow from request entry through middleware chain to response Request Enters Middleware Stack First middleware receives req, res, next Execute Middleware Logic Process, validate, or transform request Call next() to Pass Control Without next(), request hangs until timeout Next Middleware in Chain Sequence matters: order of app.use() Error-Handling Middleware Four-argument (err, req, res, next) catches errors Response Sent or Timeout Missing next() leads to 30-second default timeout ⚠ Missing next() call blocks the entire chain Always call next() unless terminating the request-response cycle THECODEFORGE.IO
thecodeforge.io
Express.js Middleware: Missing next() = 30-Second Timeouts
Middleware Expressjs

Order Is Everything — Why Middleware Sequence Is an Architectural Decision

Here's a scenario that trips up almost every developer learning Express: you add express.json() after your route definitions and suddenly req.body is undefined. Or you put your authentication middleware after the route it's supposed to protect. Both are the same root problem — you misunderstood that Express is a sequential pipeline, not a declarative config system.

Think of it as a waterfall. Water only flows downward. A request enters at the top, hits each app.use() in the order it was registered, and continues down until something sends a response. The moment any middleware calls res.send() or res.json() without also calling next(), the waterfall stops — everything below that point is ignored.

This has real architectural implications. Your logging middleware should always be first — you want to log even failed requests. Your body-parsing middleware (express.json()) must come before any route that reads req.body. Your authentication middleware must come before the routes it protects, but after body parsing (because auth might need to read a token from the request body). Error-handling middleware — the special four-argument version — must always be last.

middlewareOrderDemo.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const express = require('express');
const app = express();

// ✅ CORRECT ORDER — this is the pattern used in production apps

// STEP 1: Logging first — captures every request including failures
app.use((req, res, next) => {
  console.log(`LOG: ${req.method} ${req.path} at ${Date.now()}ms`);
  next();
});

// STEP 2: Body parsing before any route that needs req.body
app.use(express.json());
app.use(express.urlencoded({ extended: true })); // for HTML form submissions

// STEP 3: Authentication — after body parsing, before protected routes
const authenticateUser = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1]; // Bearer <token>

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

  // In real apps you'd verify a JWT here. We'll simulate it.
  req.currentUser = { id: 42, name: 'Alex', role: 'admin' };
  next();
};

// STEP 4: Mount routes — these only run after the above middleware
app.post('/orders', authenticateUser, (req, res) => {
  // req.body is available because express.json() ran first
  // req.currentUser is available because authenticateUser ran
  const { productId, quantity } = req.body;

  res.status(201).json({
    message: `Order created by ${req.currentUser.name}`,
    order: { productId, quantity }
  });
});

// STEP 5: Error handler MUST be last and MUST have 4 parameters
// Express identifies error middleware by the 4-argument signature
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Something went wrong on our end' });
});

app.listen(3000);
Output
// POST /orders with Authorization: Bearer mytoken123
// Body: { "productId": "SKU-99", "quantity": 3 }
LOG: POST /orders at 1721036581000ms
{
"message": "Order created by Alex",
"order": { "productId": "SKU-99", "quantity": 3 }
}
Pro Tip: Route-Specific vs Global Middleware
You don't have to apply middleware globally with app.use(). Pass it directly as a second argument to a specific route: app.get('/admin', authenticateUser, adminHandler). This gives you surgical control — your public /health check route won't hit your auth middleware at all, which is both more performant and more correct.
Production Insight
Misordering middleware is a classic production incident. One team placed the CORS middleware after the routes, causing all preflight OPTIONS requests to fail. The fix: move CORS middleware to the very top of the stack. Another team put the rate-limiter after authentication, making the auth endpoint vulnerable to brute force. Rule: security middleware must always run before the protected logic.
Key Takeaway
Order determines what data and security is available at each step.
Body parser before routes, auth before protected endpoints, error handler last.
Write middleware registration like a waterfall — not a random list.

Writing Real-World Custom Middleware — Patterns You'll Actually Ship

There are three middleware patterns you'll reach for constantly in production: the guard (blocks requests that don't meet a condition), the enricher (attaches data to req for downstream handlers), and the transformer (modifies the response before it's sent). Let's build all three in a realistic context.

The enricher pattern is particularly powerful and underused. Instead of fetching a user from the database inside every single route handler, you write one middleware that fetches the user and attaches them to req. Every route downstream gets req.currentUser for free. Single responsibility, no code duplication.

The transformer pattern — often used with res.json() overriding — lets you wrap all API responses in a consistent envelope ({ success: true, data: ... }) without touching each route handler. This is how large teams enforce a consistent API contract across hundreds of endpoints written by dozens of developers.

The guard pattern is your security layer. Rate limiting, IP blocking, role-based access control — these all live in guard middleware that either calls next() or terminates the request with an appropriate HTTP status code.

realWorldMiddlewarePatterns.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
const express = require('express');
const app = express();
app.use(express.json());

// ─── PATTERN 1: The Enricher ─────────────────────────────────────────────────
// Simulates a DB lookup and attaches the user to every request
const attachUserToRequest = async (req, res, next) => {
  const userId = req.headers['x-user-id'];

  if (!userId) {
    return next(); // No user ID? Fine — downstream routes can handle it
  }

  try {
    // Simulate async database call
    const fakeDbUser = await Promise.resolve({
      id: userId,
      name: 'Jordan Lee',
      role: 'editor',
      plan: 'pro'
    });

    req.currentUser = fakeDbUser; // Attach once, use everywhere
    next();
  } catch (dbError) {
    next(dbError); // Pass errors to the error-handling middleware
  }
};

// ─── PATTERN 2: The Guard ────────────────────────────────────────────────────
// Role-based access control — only 'admin' users can proceed
const requireAdminRole = (req, res, next) => {
  if (!req.currentUser) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  if (req.currentUser.role !== 'admin') {
    return res.status(403).json({
      error: 'Forbidden',
      message: `Your role '${req.currentUser.role}' cannot access this resource`
    });
  }

  next();
};

// ─── PATTERN 3: The Transformer ──────────────────────────────────────────────
// Wraps ALL JSON responses in a consistent { success, data } envelope
const responseEnvelope = (req, res, next) => {
  const originalJson = res.json.bind(res); // Save the original method

  res.json = (payload) => {
    // If the payload already has an 'error' key, don't wrap it
    if (payload && payload.error) {
      return originalJson(payload);
    }
    return originalJson({ success: true, data: payload });
  };

  next();
};

// ─── Wire everything together ────────────────────────────────────────────────
app.use(attachUserToRequest); // Runs globally — enriches req for all routes
app.use(responseEnvelope);    // Runs globally — wraps all success responses

// Public route — no guard needed
app.get('/articles', (req, res) => {
  const greeting = req.currentUser
    ? `Hello ${req.currentUser.name}!`
    : 'Hello, guest!';

  res.json({ articles: ['Intro to Node', 'Express Deep Dive'], greeting });
});

// Protected route — guard middleware applied only here
app.delete('/articles/:id', requireAdminRole, (req, res) => {
  res.json({ deleted: true, articleId: req.params.id });
});

// Error handler (always last)
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(3000, () => console.log('Running on port 3000'));
Output
// GET /articles with header x-user-id: 42
{ "success": true, "data": { "articles": ["Intro to Node", "Express Deep Dive"], "greeting": "Hello Jordan Lee!" } }
// DELETE /articles/7 with x-user-id: 42 (role: 'editor', not 'admin')
{ "error": "Forbidden", "message": "Your role 'editor' cannot access this resource" }
// DELETE /articles/7 with x-user-id: 42 (role changed to 'admin')
{ "success": true, "data": { "deleted": true, "articleId": "7" } }
Interview Gold: Passing Data Between Middleware
The req object is shared across the entire middleware chain for a single request. This makes it the perfect place to attach data that multiple middleware or route handlers need — like req.currentUser or req.startTime. Never use a module-level variable to share per-request data; that causes race conditions under concurrent load.
Production Insight
The transformer pattern is powerful but risky — overriding res.json affects all responses, including error responses. Always check if the payload already has an error property to avoid double-wrapping. In production, one team forgot this check and error responses came back nested like { success: true, data: { success: true, data: { error: ... } } }. Debugging that was a nightmare.
Key Takeaway
Custom middleware patterns: guard (block decision), enricher (attach data to req), transformer (modify response).
Use enricher to avoid repeated DB calls in every route.
Override res.json carefully — always respect existing error structures.

Middleware for Input Validation and Data Sanitization

One of the most common middleware patterns you'll write is input validation. Instead of validating request bodies inside every route handler, you create a reusable middleware that checks required fields, data types, and constraints — and returns a 400 error immediately if something's off. This keeps route handlers clean and prevents bad data from reaching your business logic.

A well-designed validation middleware uses a schema or a set of rules. You can pass the validation rules as arguments to a middleware factory function. That way, each route gets its own validation rules, but the validation logic is written once. This pattern is a major reason why validation libraries like Joi, express-validator, or Zod are so popular in the Node.js ecosystem.

Validation middleware should run after body parsing (so req.body exists) and before route handlers (to short-circuit invalid requests). It should also handle nested objects, array items, and optional fields with default values. In production, express-validator is a battle-tested choice because it integrates directly with Express and provides chainable validation rules.

validationMiddleware.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const express = require('express');
const app = express();
app.use(express.json());

// ─── Validation Middleware Factory ───────────────────────────────────────────
// Returns a middleware that validates req.body against a schema object
function validate(schema) {
  return (req, res, next) => {
    const errors = [];
    
    for (const [field, rules] of Object.entries(schema)) {
      const value = req.body[field];
      
      if (rules.required && (value === undefined || value === null)) {
        errors.push(`${field} is required`);
        continue;
      }
      
      if (value === undefined) continue; // optional field, skip further checks
      
      if (rules.type && typeof value !== rules.type) {
        errors.push(`${field} must be of type ${rules.type}`);
      }
      
      if (rules.minLength && typeof value === 'string' && value.length < rules.minLength) {
        errors.push(`${field} must be at least ${rules.minLength} characters`);
      }
      
      if (rules.maxLength && typeof value === 'string' && value.length > rules.maxLength) {
        errors.push(`${field} must be at most ${rules.maxLength} characters`);
      }
      
      if (rules.pattern && typeof value === 'string' && !rules.pattern.test(value)) {
        errors.push(`${field} does not match required pattern`);
      }
    }
    
    if (errors.length > 0) {
      return res.status(400).json({ error: 'Validation failed', details: errors });
    }
    
    next();
  };
}

// ─── Define validation schemas per route ─────────────────────────────────────
const createUserSchema = {
  name: { required: true, type: 'string', minLength: 2, maxLength: 100 },
  email: { required: true, type: 'string', pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
  age: { required: false, type: 'number' }
};

// ─── Route with validation middleware ───────────────────────────────────────
app.post('/users', validate(createUserSchema), (req, res) => {
  // At this point, req.body is guaranteed to be valid
  const { name, email, age } = req.body;
  res.status(201).json({ message: `User ${name} created`, email });
});

// ─── Error handler (last) ───────────────────────────────────────────────────
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(3000);
Output
// POST /users with body: { "name": "Al", "email": "bad" }
{ "error": "Validation failed", "details": ["name must be at least 2 characters", "email does not match required pattern"] }
// POST /users with body: { "name": "Alice", "email": "alice@example.com" }
{ "message": "User Alice created", "email": "alice@example.com" }
// POST /users with body: {}
{ "error": "Validation failed", "details": ["name is required", "email is required"] }
Mental Model: Validation as an Early Exit
  • Validation middleware runs after body parsing, before business logic.
  • It short-circuits invalid requests with a 400 response — no wasted DB calls.
  • Use a factory function to keep validation reusable across routes.
  • Combine with a schema library (Joi, Zod) for complex validations.
Production Insight
In production, validation middleware prevents entire classes of bugs. A team once shipped an endpoint that accepted arbitrary MongoDB query strings in the request body, leading to a NoSQL injection vulnerability. Adding a validation middleware that rejected non-string fields blocked the attack. Rule: validation isn't just for user experience — it's a security boundary.
Key Takeaway
Validation middleware moves check logic out of route handlers for cleaner code.
Use a factory pattern to define per-route schemas without repeating the validation logic.
Validation is a security boundary — always validate at the API gateway or middleware layer.

Error-Handling Middleware — The Safety Net Every Express App Needs

Express has a special type of middleware dedicated to error handling, and it's identified by one thing alone: having exactly four parameters (err, req, res, next). Even if you never use next inside it, you must declare it — otherwise Express treats it as regular middleware and your errors fall into a black hole.

The way you trigger error-handling middleware is by calling next(error) with any argument inside a regular middleware or route. The moment Express sees next called with an argument, it skips all remaining regular middleware and jumps straight to the nearest error-handling middleware.

In async route handlers, errors thrown in try/catch must be explicitly forwarded with next(err). But with Express 5 (currently in release candidate), async errors are caught automatically. For Express 4 — which the vast majority of production apps still run — you need to either wrap async functions yourself or use a helper like express-async-errors.

A mature Express app typically has layered error handlers: one for known operational errors (validation failures, not-found resources) and one final catch-all for unexpected programmer errors.

errorHandlingMiddleware.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
const express = require('express');
const app = express();
app.use(express.json());

// ─── Custom Error Class ──────────────────────────────────────────────────────
// Create typed errors so your error handler can respond intelligently
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true; // Distinguishes expected errors from bugs
  }
}

// ─── Route that deliberately throws a known error ────────────────────────────
app.get('/users/:id', async (req, res, next) => {
  try {
    const userId = parseInt(req.params.id, 10);

    if (isNaN(userId) || userId <= 0) {
      // Throw a known operational error — not a bug, just bad input
      throw new AppError('User ID must be a positive integer', 400);
    }

    // Simulate: user not found in database
    if (userId > 100) {
      throw new AppError(`No user found with ID ${userId}`, 404);
    }

    res.json({ id: userId, name: 'Sam Rivera', email: 'sam@example.com' });

  } catch (err) {
    // Forward ALL errors to the error-handling middleware below
    next(err);
  }
});

// ─── 404 Handler — catches requests for routes that don't exist ──────────────
// Must come AFTER all your real routes
app.use((req, res, next) => {
  next(new AppError(`Route ${req.method} ${req.path} not found`, 404));
});

// ─── Global Error Handler — MUST have 4 parameters ──────────────────────────
// MUST be registered last, after all routes and other middleware
app.use((err, req, res, next) => {
  // Log everything — even handled errors are worth knowing about
  console.error(`[ERROR] ${err.message}`, {
    statusCode: err.statusCode,
    path: req.path,
    stack: err.isOperational ? undefined : err.stack
  });

  // Operational errors: send specific, safe message to client
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: err.message
    });
  }

  // Programmer errors (bugs): never leak stack traces to clients
  res.status(500).json({
    error: 'An unexpected error occurred. Our team has been notified.'
  });
});

app.listen(3000);
Output
// GET /users/abc
{ "error": "User ID must be a positive integer" } // 400 Bad Request
// GET /users/999
{ "error": "No user found with ID 999" } // 404 Not Found
// GET /users/42
{ "id": 42, "name": "Sam Rivera", "email": "sam@example.com" } // 200 OK
// GET /this-route-does-not-exist
{ "error": "Route GET /this-route-does-not-exist not found" } // 404
Watch Out: Async Errors in Express 4 Are Silent
In Express 4, if an async function throws and you don't catch it and call next(err), Express never sees the error. The request hangs or crashes the process. Always wrap async route handlers in try/catch and forward errors with next(err). Alternatively, install the express-async-errors package which monkey-patches Express to handle this automatically.
Production Insight
A staggering number of production crashes come from unhandled promise rejections in Express 4. One infamous outage at a major food delivery service occurred because an async route handler threw an error that wasn't caught — the Node.js process crashed, taking down all active connections. The fix: global process.on('unhandledRejection') handler plus express-async-errors. Rule: never assume async errors bubble up to Express's error handler — they don't in Express 4.
Key Takeaway
Error-handling middleware is identified by its 4-parameter signature and must be registered last.
Always forward async errors with next(err) in Express 4 — unhandled rejections crash the process.
Use custom error classes to distinguish operational errors (bad input) from programmer bugs (null reference).

Middleware Chaining — Why Your App Needs a Pipeline, Not a Pile

You don't stack middleware. You chain it. Each function is a link in a pipeline that transforms the request or response before passing it along. The mistake I see most often is treating middleware like a bucket of functions that all run simultaneously. They don't. They run sequentially, and the order you register them is the order they execute. Break that chain — forget to call next() — and your request dies silently. No error. No response. Just a hanging connection and a confused frontend. The WHY here is control. Chaining lets you enforce rules at specific stages: validate before auth, auth before routing, routing before response. Your pipeline is your contract. Violate the sequence and you violate the contract.

MiddlewareChainExample.jsJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge
import express from 'express';

const app = express();

// Link 1: Parse body
app.use(express.json());

// Link 2: Log request
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
  next();
});

// Link 3: Validate API key
app.use('/api', (req, res, next) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) {
    return res.status(401).json({ error: 'Invalid key' });
  }
  next();
});

// Link 4: Route handler
app.get('/api/users', (req, res) => {
  res.json({ users: ['alice', 'bob'] });
});

app.listen(3000);
Output
[2025-03-15T10:30:00.000Z] GET /api/users
[2025-03-15T10:30:00.001Z] GET /api/invalid
Production Trap:
Forgetting next() in a middleware that doesn't send a response creates a silent hang. Your client times out. Your logs show nothing. Always ensure every branch either calls next() or ends the response.
Key Takeaway
Middleware order is your pipeline contract. Break the sequence, break the app.

Third-Party Middleware — Don't Build What's Already Shipped

Stop writing cookie parsers and rate limiters from scratch. Express has a rich ecosystem of battle-tested third-party middleware. Morgan for logging. Helmet for security headers. Cors for cross-origin requests. Express-rate-limit for brute-force protection. The WHY is obvious: these packages have been hammered by thousands of production apps. Your hand-rolled version will have edge cases you never considered. Install what you need, trust the community patches, and focus your energy on business logic that differentiates your product. One rule: always pin your versions. A breaking update in helmet or cors can silently open a security hole you won't catch until the audit.

ThirdPartyMiddlewareExample.jsJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';

const app = express();

// Security headers — always first
app.use(helmet());

// CORS — allow only your frontend origin
app.use(cors({ origin: 'https://app.thecodeforge.io' }));

// Request logging — standard Apache combined format
app.use(morgan('combined'));

// Rate limiting — 100 requests per 15 minutes per IP
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api', limiter);

app.get('/api/data', (req, res) => {
  res.json({ message: 'Throttled, but safe.' });
});

app.listen(3000);
Output
::1 - - [15/Mar/2025:10:30:00 +0000] "GET /api/data HTTP/1.1" 200 29
Avoiding the Legacy Trap:
Express 5 deprecates some built-in middleware (e.g., body-parser is no longer needed). Use express.json() and express.urlencoded() directly. Third-party packages like cookie-parser are still valid but check their Express 5 compatibility.
Key Takeaway
Third-party middleware is free battle testing. Use it. Pin it. Don't reinvent the wheel that's already rolling in production.
● Production incidentPOST-MORTEMseverity: high

The Silent Hanging Request: A Production Outage Caused by Missing next()

Symptom
POST /orders endpoint intermittently hangs for 30 seconds before returning a 504 Gateway Timeout. No error in application logs, but some orders never completed. The issue correlated with high traffic (500+ req/s).
Assumption
The team assumed the database query was slow under load. They optimised the database indexing, but the hanging persisted. They increased the database connection pool, but the issue remained.
Root cause
A middleware function for audit logging was written with a conditional branch that forgot to call next() when the request already had an audit entry. Under specific conditions (when an x-audit-active header was present), the middleware returned early without calling next() and without sending a response. The request stayed open until the default timeout kicked in.
Fix
Add an else branch that always calls next(). The corrected middleware now has a single exit path that guarantees next() is called unless a response is sent. Added a linter rule to enforce that every middleware must call next() or return a response.
Key lesson
  • Every middleware must be exhaustive in its control flow: every branching path must either call next() or send a response. No exceptions.
  • Use a linter plugin like eslint-plugin-express to flag missing next() calls in middleware functions.
  • Set an aggressive timeout (e.g., 5 seconds) in production and monitor timeout errors as a leading indicator of hanging middleware.
Production debug guideCommon scenarios and the exact steps to diagnose them3 entries
Symptom · 01
req.body is undefined in a POST/PUT route handler
Fix
1. Check that express.json() middleware is registered BEFORE the route definition. 2. Verify the request Content-Type is application/json. 3. If using body-parser separately, ensure it's imported and used correctly.
Symptom · 02
Client request hangs with no response (timeout after 30 seconds)
Fix
1. Identify which middleware is the last to run before the hang — add a log before each next() call in suspect middleware. 2. Look for conditional returns that skip next() without sending a response. 3. Use app.use((req, res, next) => { console.log('Passing through'); next(); }) as a trace middleware to isolate the block.
Symptom · 03
Error handler never triggers, even when next(err) is called
Fix
1. Count the parameters of your error-handling middleware — must have exactly 4: (err, req, res, next). 2. Ensure the error handler is registered LAST, after all app.use() and routes. 3. Verify that async errors are caught and passed to next(err) — Express 4 doesn't catch thrown promise rejections.
★ Cheat Sheet: Quick Middleware DebuggingCommands and fixes for the three most common middleware problems in production
req.body undefined
Immediate action
Check middleware order — body parser must come before routes
Commands
app.use(express.json()); // must be before app.use(routes)
console.log('req.body:', req.body); // add inside route handler
Fix now
Move app.use(express.json()) to the very top of your middleware stack
Request hangs with no response+
Immediate action
Add a global middleware that logs every request passing through
Commands
app.use((req, res, next) => { console.log('Passing through:', req.path); next(); });
Check each middleware for missing next() — especially in conditional branches
Fix now
Ensure every middleware calls next() or sends a response (res.json(), res.send()) on all code paths
Error handler not catching errors+
Immediate action
Verify error handler has 4 parameters and is registered last
Commands
app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: err.message }); });
Check async routes: use try/catch and next(err) — Express 4 doesn't catch promise rejections
Fix now
Install express-async-errors package to automatically catch async errors
Application-Level vs Router-Level Middleware
AspectApplication-Level Middleware (app.use)Router-Level Middleware (router.use)
ScopeApplies to every request hitting the entire appApplies only to routes mounted on that specific router
Typical use caseLogging, body parsing, global auth, CORS headersFeature-specific auth, resource-specific rate limiting
Registrationapp.use(middlewareFn)const router = express.Router(); router.use(middlewareFn)
ModularityLower — tightly coupled to the main app fileHigher — encapsulated within a feature module
Performance impactEvery request pays the costOnly requests matched by router prefix pay the cost
Best forCross-cutting concerns (logging, security headers)Domain-specific concerns (admin routes, API versioning)

Key takeaways

1
Middleware is a sequential pipeline
Express processes every app.use() call in the order it was registered, top to bottom. Order isn't a style choice; it's logic.
2
Every middleware function must either call next() to continue the chain or send a response to terminate it
doing neither causes the client request to hang silently.
3
Error-handling middleware is identified solely by its four-parameter signature (err, req, res, next). Register it last in your file, after all routes and regular middleware.
4
The req object is the shared data bus for a single request's journey through the pipeline. Attach per-request context (like the authenticated user) to req in middleware, and every downstream handler gets it for free.
5
Validation middleware is a security boundary
run it after body parsing and before route handlers to prevent bad input from reaching your business logic.

Common mistakes to avoid

4 patterns
×

Forgetting to call next() in middleware

Symptom
The client request hangs indefinitely with no response and no error in the console. The connection eventually times out after 30 seconds, but the server logs show nothing useful.
Fix
Every middleware must either call next() to continue the chain, or send a response using res.json()/res.send(). Add a linter rule or code review checklist item specifically for this.
×

Registering express.json() after the routes that need req.body

Symptom
req.body is undefined inside your POST/PUT route handlers even though you're sending a JSON body with correct Content-Type.
Fix
Always place app.use(express.json()) before any route definitions in your file. A simple rule: body parsers go at the top, routes go at the bottom.
×

Writing an error-handling middleware with only 3 parameters

Symptom
Express treats it as regular middleware, so errors passed via next(err) are silently ignored and the client either hangs or gets an unhandled rejection crash.
Fix
Error-handling middleware must always declare exactly four parameters: (err, req, res, next). Even if you never use next, include it. Express uses the function's arity (parameter count) to identify error handlers.
×

Using sync middleware for expensive async operations without error handling

Symptom
An async database call in middleware throws an error, but it's not caught. The request hangs or the process crashes because the promise rejection is unhandled.
Fix
Wrap async code in try/catch and forward errors via next(err). Alternatively, use express-async-errors to automatically catch async errors.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between app.use() and app.get() in Express, and h...
Q02SENIOR
How does Express identify an error-handling middleware function differen...
Q03SENIOR
If you have async middleware that fetches data from a database, and that...
Q01 of 03JUNIOR

What is the difference between app.use() and app.get() in Express, and how does each interact with the middleware pipeline?

ANSWER
Both are middleware registration methods. app.use() mounts middleware for all HTTP methods and matches any path that starts with the given path (default '/'). app.get() (and app.post(), etc.) mounts middleware only for a specific HTTP method and exact path. The pipeline processes all registered middleware in order, regardless of method, but only app.get() and similar will match specific routes. app.use() is typically used for global middleware (logging, CORS, parsing).
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is middleware in Express.js and what does it do?
02
Why is the order of middleware important in Express.js?
03
What's the difference between middleware and a route handler in Express?
04
How do I handle async errors in Express 4 middleware?
05
Can I use middleware only for specific routes?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Node.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
REST API with Express.js
5 / 18 · Node.js
Next
Node.js with MongoDB