Express.js Middleware: Missing next() = 30-Second Timeouts
Missing next() in audit middleware caused 30-second 504 timeouts at 500+ req/s.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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.
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.
A middleware function receives three arguments: req, res, and next. It either ends the response (by calling , res.send(), etc.) or passes control to the next middleware by calling res.json(). 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.next()
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 parses incoming JSON bodies; third-party packages like express.json()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 or app.use()app. is the order it executes. Put auth middleware after a public route handler, and you've accidentally locked down your login page.METHOD()
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 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.next()
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 isn't optional.next()
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
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'));
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.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.next() or send a response.next() or terminates the response.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.
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);
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.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.
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'));
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.
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);
- 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.
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.
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);
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.
// 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);
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.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.
// 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);
express.json() and express.urlencoded() directly. Third-party packages like cookie-parser are still valid but check their Express 5 compatibility.The Silent Hanging Request: A Production Outage Caused by Missing next()
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.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.- 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.
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.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.app.use() and routes.
3. Verify that async errors are caught and passed to next(err) — Express 4 doesn't catch thrown promise rejections.app.use(express.json()); // must be before app.use(routes)console.log('req.body:', req.body); // add inside route handlerexpress.json()) to the very top of your middleware stackapp.use((req, res, next) => { console.log('Passing through:', req.path); next(); });Check each middleware for missing next() — especially in conditional branchesnext() or sends a response (res.json(), res.send()) on all code pathsapp.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| Aspect | Application-Level Middleware (app.use) | Router-Level Middleware (router.use) |
|---|---|---|
| Scope | Applies to every request hitting the entire app | Applies only to routes mounted on that specific router |
| Typical use case | Logging, body parsing, global auth, CORS headers | Feature-specific auth, resource-specific rate limiting |
| Registration | app.use(middlewareFn) | const router = express.Router(); router.use(middlewareFn) |
| Modularity | Lower — tightly coupled to the main app file | Higher — encapsulated within a feature module |
| Performance impact | Every request pays the cost | Only requests matched by router prefix pay the cost |
| Best for | Cross-cutting concerns (logging, security headers) | Domain-specific concerns (admin routes, API versioning) |
| File | Command / Code | Purpose |
|---|---|---|
| basicMiddlewarePipeline.js | const express = require('express'); | What Middleware Actually Is |
| middlewareOrderDemo.js | const express = require('express'); | Order Is Everything |
| realWorldMiddlewarePatterns.js | const express = require('express'); | Writing Real-World Custom Middleware |
| validationMiddleware.js | const express = require('express'); | Middleware for Input Validation and Data Sanitization |
| errorHandlingMiddleware.js | const express = require('express'); | Error-Handling Middleware |
| MiddlewareChainExample.js | const app = express(); | Middleware Chaining |
| ThirdPartyMiddlewareExample.js | const app = express(); | Third-Party Middleware |
Key takeaways
app.use() call in the order it was registered, top to bottom. Order isn't a style choice; it's logic.next() to continue the chain or send a response to terminate itCommon mistakes to avoid
4 patternsForgetting to call next() in middleware
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
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
Using sync middleware for expensive async operations without error handling
Interview Questions on This Topic
What is the difference between app.use() and app.get() in Express, and how does each interact with the middleware pipeline?
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).How does Express identify an error-handling middleware function differently from a regular middleware function, and what happens if you accidentally omit the first parameter?
err parameter (leaving 3), Express treats it as regular middleware. When an error is passed via next(err), Express skips all regular middleware and looks for the next error-handling middleware. If none is found, the error becomes unhandled and may crash the process. Always include all four parameters, even if you don't use next.If you have async middleware that fetches data from a database, and that database call throws an error, how do you ensure Express's error-handling middleware actually receives and processes that error in Express 4?
javascript
app.use(async (req, res, next) => {
try {
const user = await db.findUser();
req.user = user;
next();
} catch (err) {
next(err);
}
});
`
Alternatively, install the express-async-errors` package, which patches Express to automatically catch promise rejections. Without this, unhandled promise rejections will silently crash the Node.js process.Frequently Asked Questions
Middleware in Express.js is a function that runs during the lifecycle of an HTTP request, between the request arriving at the server and the response being sent to the client. Each middleware function receives the request object, the response object, and a next() function. It can read or modify the request, terminate the request/response cycle, or pass control to the next middleware in the chain by calling next().
Express processes middleware strictly in the order it's registered using app.use(). If you register express.json() after your route handlers, those routes will receive an undefined req.body because the body parser never ran before them. Similarly, if an authentication middleware is registered after the routes it's meant to protect, those routes are left unguarded. Order determines what data and behaviour is available at each step.
The core difference is intent and behaviour within the pipeline. A route handler is a middleware that is expected to end the request/response cycle by sending a response. A middleware function is expected to do some work and then call next() to pass control forward. In practice, Express doesn't distinguish them technically — both have the same (req, res, next) signature — but conceptually, middleware is for cross-cutting concerns (auth, logging, parsing) while route handlers are for business logic.
Wrap the async code in a try/catch block and call next(err) in the catch. Express 4 does not catch promise rejections automatically. Without proper handling, unhandled rejections will crash the Node.js process. You can also install the express-async-errors package to automatically patch Express 4 to catch async errors.
Yes, you can pass middleware as a second argument to route definitions, e.g., app.get('/admin', isAdmin, handler). This applies only to that specific route and method. You can also use router-level middleware with express. to scope middleware to a group of routes (like Router()/api/v1/*).
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Node.js. Mark it forged?
4 min read · try the examples if you haven't