Senior 6 min · March 05, 2026

Express.js Framework Explained — Routing, Middleware and Real-World Patterns

Express.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Express.js is a minimal Node.js web framework providing routing and middleware over raw HTTP
  • Routing maps HTTP methods + URL patterns to handler functions, matched in registration order
  • Middleware pipeline: functions with (req, res, next) that process requests sequentially
  • Order is critical — specific routes before wildcards, error handlers last
  • Performance overhead is ~50µs per request — negligible for 99% of APIs
  • Biggest production mistake: missing next() call in middleware causes silent request hang
Plain-English First

Imagine Node.js is a blank kitchen — you have raw ingredients and appliances, but nothing is set up yet. Express.js is the fully organised kitchen: the counters are labelled, the knives are sharp, and there's a clear assembly line from raw ingredient to plated dish. When a customer order (an HTTP request) comes in, Express knows exactly which chef (route handler) should handle it, and it passes the order through a series of stations (middleware) — like the prep station, the grill, the plating area — before the finished dish (response) goes back out. Without Express, you'd be building all those stations from scratch every single time.

Every production Node.js application you've ever used — from GitHub's API to Shopify's backend services — needs to answer the same basic questions: Which URL maps to which logic? How do we authenticate a request before it reaches sensitive data? What do we send back when something breaks? Answering those questions by hand in raw Node.js means writing hundreds of lines of low-level HTTP boilerplate that has nothing to do with your actual business logic.

Express.js was created specifically to eliminate that boilerplate. It wraps Node's built-in http module in a clean, minimal layer that gives you routing, middleware, and response helpers without forcing any opinions about your database, template engine, or project structure. That deliberate minimalism is why Express has stayed the most downloaded Node.js framework for over a decade — it solves the core problem and gets out of your way.

By the end of this article you'll understand not just how to write Express routes and middleware, but why the middleware pipeline is designed the way it is, how to structure a real multi-route API, how to handle errors properly so they never leak stack traces to users, and the patterns senior engineers actually use in production. You'll leave with runnable code you can drop into a real project today.

How Express Routing Actually Works Under the Hood

A route in Express is a combination of an HTTP method, a URL pattern, and one or more handler functions. When a request comes in, Express walks through every registered route in the order you defined them and checks: does this method match, and does this URL match? The first route that matches wins.

This ordering matters enormously. If you define a wildcard route (app.get('*', ...)) before your specific routes, none of the specific routes will ever fire. This surprises a lot of developers because it looks like Express should be smarter about specificity — but Express is deliberately simple here. Order is explicit and predictable, which is actually a feature.

Route parameters (:userId) give you dynamic URL segments automatically parsed into req.params. Query strings (?page=2) live in req.query. The request body (for POST/PUT) lives in req.body — but only after you've attached the right body-parsing middleware, which is a common gotcha we'll cover shortly.

Express Router objects let you split routes across multiple files, each acting like a mini Express application. This is the pattern every real codebase uses — one router per resource (/users, /products, /orders), mounted on a base path in the main app file.

userRouter.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
// userRouter.js — a self-contained router for all /users endpoints
const express = require('express');
const router = express.Router(); // create a mini-app just for user routes

// In-memory store for demo purposes — swap this for a real DB call
const users = [
  { id: 1, name: 'Alice Nguyen', role: 'admin' },
  { id: 2, name: 'Ben Okafor',  role: 'member' },
];

// GET /users — return all users
router.get('/', (req, res) => {
  // req.query.role lets callers filter: GET /users?role=admin
  const { role } = req.query;
  const result = role
    ? users.filter(u => u.role === role)
    : users;
  res.json({ success: true, data: result });
});

// GET /users/:userId — return one user by id
router.get('/:userId', (req, res) => {
  const id = parseInt(req.params.userId, 10); // params are always strings — parse them!
  const user = users.find(u => u.id === id);

  if (!user) {
    // Always use the correct HTTP status code — 404, not 200 with an error message
    return res.status(404).json({ success: false, message: 'User not found' });
  }

  res.json({ success: true, data: user });
});

// POST /users — create a new user
router.post('/', (req, res) => {
  const { name, role } = req.body; // requires express.json() middleware in app.js

  if (!name || !role) {
    return res.status(400).json({ success: false, message: 'name and role are required' });
  }

  const newUser = { id: users.length + 1, name, role };
  users.push(newUser);

  // 201 Created is semantically correct for a successful resource creation
  res.status(201).json({ success: true, data: newUser });
});

module.exports = router;

// -----------------------------------------------------------
// app.js — the main entry point that wires everything together
// -----------------------------------------------------------
const express = require('express');
const userRouter = require('./userRouter');

const app = express();
const PORT = 3000;

// Middleware: parse incoming JSON bodies BEFORE any route that needs req.body
app.use(express.json());

// Mount the user router — every route inside userRouter is now prefixed with /users
app.use('/users', userRouter);

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});
Output
Server running at http://localhost:3000
# curl http://localhost:3000/users
{ "success": true, "data": [{"id":1,"name":"Alice Nguyen","role":"admin"},{"id":2,"name":"Ben Okafor","role":"member"}] }
# curl http://localhost:3000/users?role=admin
{ "success": true, "data": [{"id":1,"name":"Alice Nguyen","role":"admin"}] }
# curl http://localhost:3000/users/1
{ "success": true, "data": {"id":1,"name":"Alice Nguyen","role":"admin"} }
# curl http://localhost:3000/users/99
{ "success": false, "message": "User not found" } (HTTP 404)
# curl -X POST http://localhost:3000/users -H 'Content-Type: application/json' -d '{"name":"Carla Souza","role":"member"}'
{ "success": true, "data": {"id":3,"name":"Carla Souza","role":"member"} } (HTTP 201)
Pro Tip: Route Order Is Your Logic
Always register specific routes before generic ones. Define router.get('/me', ...) before router.get('/:userId', ...), otherwise the string 'me' gets parsed as a userId param and your dedicated route never fires.
Production Insight
Route ordering is a frequent source of silent bugs. In one incident, a catch-all handler for 404 pages was placed before the API routes, causing all API requests to return HTML instead of JSON.
The fix: keep API routes in a separate file mounted before any fallback routes.
Rule: mount specific resource routers first, then generic middleware, then fallback/error handlers.
Key Takeaway
Express matches routes in registration order.
Specific routes MUST come before parameterised or wildcard routes.
Router objects are the modular unit — one per resource.

The Middleware Pipeline — Why It's the Heart of Every Express App

Middleware is any function with the signature (req, res, next). That next argument is the key — calling it tells Express to move on to the next function in the chain. Not calling it means the request stalls there forever, which is one of the most common bugs in Express apps.

Think of middleware as a conveyor belt. Every request starts at one end and travels through each function you've attached. Each function can read and modify the request or response objects, do async work like checking a database, or terminate the chain early by calling res.send(). This design means you can compose behaviour from small, focused functions rather than cramming everything into one giant handler.

There are three types of middleware you'll use constantly: application-level middleware (attached with app.use()), router-level middleware (attached to a specific Router instance), and error-handling middleware (four arguments: err, req, res, next). The error handler is special — Express only routes to it when a previous middleware either calls next(error) or throws inside an async function that you've caught and forwarded.

Authentication is the canonical real-world middleware use case. You write it once, attach it to the routes that need protection, and every protected route automatically gets user identity on req.currentUser without repeating any logic.

middlewareChain.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
// middlewareChain.js — demonstrates a real-world middleware pipeline
const express = require('express');
const app = express();
const PORT = 3000;

// ── 1. APPLICATION-LEVEL MIDDLEWARE ──────────────────────────────────────────

// Parses incoming JSON bodies and adds the parsed object to req.body
app.use(express.json());

// Custom request logger — fires for EVERY request, regardless of route
app.use((req, res, next) => {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next(); // MUST call next() or the request hangs here
});

// ── 2. REUSABLE AUTH MIDDLEWARE ───────────────────────────────────────────────

// In a real app this would verify a JWT. Here we check a simple header for demo.
function requireAuth(req, res, next) {
  const token = req.headers['x-api-token'];

  if (!token || token !== 'secret-token-123') {
    // Terminate the chain immediately — send 401 and do NOT call next()
    return res.status(401).json({ success: false, message: 'Unauthorised' });
  }

  // Attach user identity to req so downstream handlers can use it
  req.currentUser = { id: 42, name: 'Alice Nguyen', role: 'admin' };
  next(); // pass control to the next middleware or route handler
}

// ── 3. ROUTE-LEVEL MIDDLEWARE ─────────────────────────────────────────────────

// Public route — no auth needed
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Protected route — requireAuth runs first, then the handler
// Only if requireAuth calls next() does the second function execute
app.get('/dashboard', requireAuth, (req, res) => {
  res.json({
    message: `Welcome, ${req.currentUser.name}`,
    role: req.currentUser.role,
  });
});

// ── 4. ASYNC ERROR HANDLING PATTERN ──────────────────────────────────────────

// Simulate an async operation (e.g., a database call) that might fail
app.get('/report', requireAuth, async (req, res, next) => {
  try {
    // Pretend this throws when the report data is missing
    const reportData = await fetchReport(req.currentUser.id);
    res.json({ data: reportData });
  } catch (error) {
    // Forward the error to Express's error handler — do NOT handle it here
    next(error);
  }
});

async function fetchReport(userId) {
  // Simulating a failed DB query
  throw new Error(`Report data unavailable for user ${userId}`);
}

// ── 5. GLOBAL ERROR HANDLER — must have exactly 4 params ─────────────────────
// Express identifies this as an error handler because of the `err` first param
app.use((err, req, res, next) => {
  console.error(`[ERROR] ${err.message}`);

  // Never leak stack traces to clients in production
  const isDev = process.env.NODE_ENV === 'development';
  res.status(err.status || 500).json({
    success: false,
    message: err.message,
    ...(isDev && { stack: err.stack }), // only include stack in dev mode
  });
});

app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
Output
Server on http://localhost:3000
# curl http://localhost:3000/health
[2024-03-15T10:00:00.000Z] GET /health
{ "status": "ok" }
# curl http://localhost:3000/dashboard
[2024-03-15T10:00:01.000Z] GET /dashboard
{ "success": false, "message": "Unauthorised" } (HTTP 401)
# curl http://localhost:3000/dashboard -H 'x-api-token: secret-token-123'
[2024-03-15T10:00:02.000Z] GET /dashboard
{ "message": "Welcome, Alice Nguyen", "role": "admin" }
# curl http://localhost:3000/report -H 'x-api-token: secret-token-123'
[2024-03-15T10:00:03.000Z] GET /report
[ERROR] Report data unavailable for user 42
{ "success": false, "message": "Report data unavailable for user 42" } (HTTP 500)
Watch Out: Async Errors Don't Auto-Forward
If you use an async route handler and an error is thrown, Express 4.x does NOT automatically catch it — you must wrap in try/catch and call next(error). Express 5 (currently in beta) fixes this, but in Express 4 forgetting the try/catch causes the server to hang or crash silently.
Production Insight
In one production incident, a team used async handlers for all routes but forgot try/catch in one. An upstream DB failure threw inside that handler, causing an unhandled promise rejection that brought down the entire Node process.
The fix: install a global process.on('unhandledRejection') handler and use an asyncHandler wrapper.
Rule: Never trust async handlers — always wrap or use a helper that catches automatically.
Key Takeaway
Middleware pipeline runs in registration order.
Every function must either call next(), send a response, or call next(error).
Async errors in Express 4 require explicit try/catch + next(error).

Structuring a Production-Ready Express API — Beyond the Tutorial

A single app.js file works fine for demos. It becomes a maintenance nightmare at scale. The structure senior engineers actually use separates concerns into distinct layers: routes, controllers, services, and middleware — each with a single responsibility.

Routers handle URL mapping only. Controllers handle request/response logic. Services contain the business logic and data access — they know nothing about Express, which makes them independently testable. Middleware handles cross-cutting concerns like auth, logging, and rate limiting.

This separation isn't bureaucracy for its own sake. It means you can unit-test your service layer without spinning up an HTTP server. It means swapping your data layer (say, from a mock to a real database) requires changes in exactly one place. It means a new developer can open the routes/ folder and immediately understand every endpoint the app exposes.

Environment configuration should never be hardcoded. Use dotenv to load a .env file in development, and real environment variables in production. The Express app should read from process.env exclusively — no magic strings scattered through files.

Finally, always set NODE_ENV=production in prod. Express enables several performance and security optimisations automatically when it detects this, including disabling detailed error output and enabling response caching for static files.

productionApiStructure.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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// ─── Folder structure ────────────────────────────────────────────────────────
// project/
// ├── app.js              ← Express app setup (no listen here)
// ├── server.js           ← starts the HTTP server
// ├── .env                ← PORT, DB_URL, JWT_SECRET (never commit this)
// ├── routes/
// │   └── productRoutes.js
// ├── controllers/
// │   └── productController.js
// ├── services/
// │   └── productService.js
// └── middleware/
//     └── validateBody.js
// ─────────────────────────────────────────────────────────────────────────────

// services/productService.js — pure business logic, no Express imports
const productStore = [
  { id: 1, name: 'Mechanical Keyboard', price: 129.99, stock: 14 },
  { id: 2, name: 'USB-C Hub',           price: 49.99,  stock: 0  },
];

const productService = {
  getAllProducts() {
    return productStore;
  },

  getProductById(id) {
    return productStore.find(p => p.id === id) || null;
  },

  getInStockProducts() {
    return productStore.filter(p => p.stock > 0);
  },
};

module.exports = productService;

// ─────────────────────────────────────────────────────────────────────────────

// middleware/validateBody.js — factory function returns a middleware
// Usage: router.post('/', validateBody(['name','price']), controller.create)
function validateBody(requiredFields) {
  return (req, res, next) => {
    const missing = requiredFields.filter(field => !(field in req.body));

    if (missing.length > 0) {
      return res.status(400).json({
        success: false,
        message: `Missing required fields: ${missing.join(', ')}`,
      });
    }
    next();
  };
}

module.exports = validateBody;

// ─────────────────────────────────────────────────────────────────────────────

// controllers/productController.js — handles req/res, delegates to service
const productService = require('../services/productService');

const productController = {
  listProducts(req, res) {
    // Controller decides WHICH service method to call based on query params
    const products = req.query.inStock === 'true'
      ? productService.getInStockProducts()
      : productService.getAllProducts();

    res.json({ success: true, count: products.length, data: products });
  },

  getProduct(req, res) {
    const id = parseInt(req.params.productId, 10);
    const product = productService.getProductById(id);

    if (!product) {
      return res.status(404).json({ success: false, message: 'Product not found' });
    }
    res.json({ success: true, data: product });
  },
};

module.exports = productController;

// ─────────────────────────────────────────────────────────────────────────────

// routes/productRoutes.js — maps URLs to controller methods
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
const validateBody = require('../middleware/validateBody');

router.get('/',               productController.listProducts);
router.get('/:productId',     productController.getProduct);

module.exports = router;

// ─────────────────────────────────────────────────────────────────────────────

// app.js — wires everything together; does NOT call app.listen()
const express = require('express');
const productRoutes = require('./routes/productRoutes');

const app = express();

app.use(express.json());
app.use('/api/products', productRoutes);

// Global error handler — last middleware registered, catches all forwarded errors
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ success: false, message: 'Internal server error' });
});

module.exports = app; // export app so server.js can start it and tests can import it

// ─────────────────────────────────────────────────────────────────────────────

// server.js — single responsibility: start the server
require('dotenv').config(); // load .env file values into process.env
const app = require('./app');

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`[${process.env.NODE_ENV}] Server listening on port ${PORT}`);
});
Output
[development] Server listening on port 3000
# curl http://localhost:3000/api/products
{ "success": true, "count": 2, "data": [{"id":1,"name":"Mechanical Keyboard","price":129.99,"stock":14},{"id":2,"name":"USB-C Hub","price":49.99,"stock":0}] }
# curl http://localhost:3000/api/products?inStock=true
{ "success": true, "count": 1, "data": [{"id":1,"name":"Mechanical Keyboard","price":129.99,"stock":14}] }
# curl http://localhost:3000/api/products/2
{ "success": true, "data": {"id":2,"name":"USB-C Hub","price":49.99,"stock":0} }
# curl http://localhost:3000/api/products/99
{ "success": false, "message": "Product not found" } (HTTP 404)
Interview Gold: Why Separate app.js from server.js?
Exporting app without calling listen() lets your test suite import the app and use supertest to fire HTTP requests without binding to a real port. This is standard practice in production codebases and a great signal to interviewers that you write testable Node.js applications.
Production Insight
A team shipped an API with all routes in app.js — 4000+ lines. Adding a single endpoint required scrolling through the entire file to find the right place. Code reviews became impossible.
The fix: refactored into the router/controller/service pattern, reducing app.js to 30 lines.
Rule: If app.js exceeds 100 lines, you've already outgrown a single-file structure.
Key Takeaway
Separate app setup (app.js) from server start (server.js) for testability.
Divide code into routes, controllers, services, and middleware.
Never hardcode configuration — use environment variables with dotenv.

Error Handling Patterns That Don't Leak to Clients

Express error handling is a first-class concept, but it's easy to get wrong. The key insight: an error handler is just middleware with four parameters. Express identifies it by counting arguments — exactly 4. If you forget next (even if you don't use it), Express treats it as regular middleware and your errors go uncaught.

Your error handler should be registered LAST. Place it after all routes and other middleware. This ensures every error that bubbles up through the chain ends up there.

For async handlers in Express 4, you must manually catch errors and forward them. The standard pattern is a wrapper function that catches any rejected promise and calls next() automatically. Express 5 will do this natively, but until then, use express-async-errors or a custom wrapper.

Another common mistake: sending the full error object (including stack trace) to the client. Always check NODE_ENV before including stack traces. In production, send a generic message with a unique error ID that your logging system can correlate.

errorHandlingPatterns.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
const express = require('express');
const app = express();

// ── Async handler wrapper (reusable) ─────────────────────────────────────────
// Wraps any async route handler to catch errors and forward them to next()
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// ── Custom error class with HTTP status ──────────────────────────────────────
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}

// ── Route using the wrapper ───────────────────────────────────────────────────
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await findUser(req.params.id); // might throw
  if (!user) throw new AppError('User not found', 404);
  res.json({ data: user });
}));

// ── Global error handler (must be last) ───────────────────────────────────────
app.use((err, req, res, next) => {
  // Determine status code
  const statusCode = err.statusCode || 500;
  
  // Log the full error server-side
  console.error(`[${new Date().toISOString()}] ${err.message}`, err.stack);

  // Generate unique error reference for client
  const errorRef = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);

  res.status(statusCode).json({
    error: {
      message: statusCode === 500 ? 'Internal server error' : err.message,
      reference: errorRef,
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    }
  });
});

async function findUser(id) {
  // Simulating DB call that could fail
  return null; // simulate not found
}
Output
# GET /users/42 (user not found)
{
"error": {
"message": "User not found",
"reference": "lq8k2a5f9"
}
} (HTTP 404)
# In development environment
{
"error": {
"message": "User not found",
"reference": "lq8k2a5f9",
"stack": "AppError: User not found\n at ..."
}
} (HTTP 404)
Express 4 Async Trap
If you don't catch an async error, Express 4 will not forward it. The promise rejection becomes an unhandledRejection and the request hangs. Always wrap async handlers or use the express-async-errors package to patch this globally.
Production Insight
A team using Express 4 with async/await didn't wrap one handler. A temporary database failure caused that handler to throw, and the promise rejection went unhandled, crashing the Node process. The entire API was down for 10 minutes.
The fix: added express-async-errors globally and a process.on('unhandledRejection') handler.
Rule: Treat every async handler as a potential uncaught error source until proven otherwise.
Key Takeaway
Error handlers must have exactly 4 parameters: (err, req, res, next).
Register the error handler LAST in the middleware chain.
Never send stack traces to clients in production — use a reference ID.
Choosing an Error Handling Strategy
IfExpress version is 4.x and you use async handlers
UseUse express-async-errors or a wrapper like asyncHandler() to auto-forward errors
IfYou need custom error classes with HTTP status codes
UseCreate a base AppError class extending Error with a statusCode property
IfYou want to return a unique error reference to clients
UseGenerate a short unique ID in the error handler and log it with the full error
IfYour API is in production and you must never leak stack traces
UseCheck NODE_ENV before including stack in the response; always include a generic message for 500 errors

Testing Your Express API — A Practical Approach

Testing an Express API isn't just about unit-testing individual functions. You need integration tests that exercise the full middleware stack, route matching, controller logic, and error handling. The key enabler is separating app.js from server.js — your test file imports app (the Express instance) and uses supertest to make requests against it without binding to a real port.

Supertest works by wrapping your app in a server-like object that handles requests in-memory. This is fast and avoids port conflicts. You can test the exact middleware chain your users will hit, including body parsers, authentication, and error handling.

For unit tests, your controllers should be thin delegators to a service layer that has no Express dependencies. That way you can test business logic directly without HTTP. But for confidence before deployment, the integration test is king.

Important: your test setup must replicate the production middleware order exactly. If you conditionally add middleware based on environment, ensure your test config mirrors it.

api.test.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
// api.test.js — integration tests for the Express API
const request = require('supertest');
const app = require('./app'); // app.js exports the Express instance (no listen())

describe('GET /api/users', () => {
  it('returns a list of users', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect(200);

    expect(res.body.success).toBe(true);
    expect(Array.isArray(res.body.data)).toBe(true);
  });

  it('filters by role query parameter', async () => {
    const res = await request(app)
      .get('/api/users?role=admin')
      .expect(200);

    res.body.data.forEach(user => {
      expect(user.role).toBe('admin');
    });
  });
});

describe('GET /api/users/:id', () => {
  it('returns 200 for an existing user', async () => {
    const res = await request(app)
      .get('/api/users/1')
      .expect(200);

    expect(res.body.data.id).toBe(1);
  });

  it('returns 404 for non-existent user', async () => {
    await request(app)
      .get('/api/users/999')
      .expect(404);
  });
});

describe('POST /api/users', () => {
  it('creates a user with valid data', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Test User', role: 'member' })
      .set('Content-Type', 'application/json')
      .expect(201);

    expect(res.body.data.name).toBe('Test User');
  });

  it('returns 400 when required fields missing', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'No Role' })
      .set('Content-Type', 'application/json')
      .expect(400);
  });
});

describe('Error handling', () => {
  it('returns 500 with generic message in production', async () => {
    // Force the app into production mode for this test
    process.env.NODE_ENV = 'production';
    // Hit an endpoint that is known to throw (e.g., missing DB connection)
    // This assumes you have a route that throws when NODE_ENV is production
    const res = await request(app)
      .get('/api/report') // this route calls fetchReport which throws
      .expect(500);

    expect(res.body.error.message).toBe('Internal server error');
    expect(res.body.error.stack).toBeUndefined();

    // Restore environment
    process.env.NODE_ENV = 'test';
  });
});
Output
PASS api.test.js
GET /api/users
✓ returns a list of users
✓ filters by role query parameter
GET /api/users/:id
✓ returns 200 for an existing user
✓ returns 404 for non-existent user
POST /api/users
✓ creates a user with valid data
✓ returns 400 when required fields missing
Error handling
✓ returns 500 with generic message in production
Tests: 7 passed, 7 total
Supertest Doesn't Bind to a Port
When you pass an Express app (without listen()) to supertest, it creates an in-memory server using the http module. This means no port conflicts, no cleanup needed, and tests run in milliseconds. Always export your app from app.js and import in test files.
Production Insight
Without integration tests, a team accidentally deployed a breaking change: they forgot to mount a new router, but the tests only covered the old routes. The API returned 404 for all new endpoints in production, but monitoring didn't alert because the old endpoints worked.
The fix: add a route inventory test that verifies every expected endpoint returns a non-404 status.
Rule: Every route should have at least one positive and one negative integration test.
Key Takeaway
Separate app.js from server.js — it's the foundation for testability.
Use supertest for integration tests that exercise the full middleware stack.
Test error scenarios: missing fields, invalid auth, async failures.

Performance and Security Middleware Every Production API Needs

Express itself is lightweight, but a production API needs more than routes and json parsing. You need middleware for rate limiting, CORS, request compression, security headers, and request logging with correlation IDs.

Rate limiting prevents brute force and DDoS. express-rate-limit is the standard — configure it globally and optionally per-route.

CORS is mandatory if your API is consumed by a browser. Use the cors package and restrict origins, methods, and headers. Never allow * in production.

Compression with compression middleware reduces response size by up to 80% for text-based responses (JSON, HTML, CSS). Enable it early in the chain before routes to compress all responses.

Security headers via helmet set HTTP headers like X-Content-Type-Options, X-Frame-Options, and Strict-Transport-Security. It's a one-liner that prevents entire categories of attacks.

Finally, request logging with a unique correlation ID lets you trace a single request across your logs. Use uuid to generate an ID at the start of the middleware chain, attach it to req.correlationId, and include it in every log entry and error response.

productionMiddleware.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
const express = require('express');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const compression = require('compression');
const helmet = require('helmet');
const { v4: uuidv4 } = require('uuid');

const app = express();

// 1. Security headers — should be one of the first middleware
app.use(helmet());

// 2. Request compression — compress responses before sending
app.use(compression());

// 3. CORS configuration — restrict to your frontend domain
const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com'];
app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, curl)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// 4. Correlation ID — attach unique request identifier
app.use((req, res, next) => {
  // Use existing header if present (e.g., from a gateway), else generate
  req.correlationId = req.headers['x-correlation-id'] || uuidv4();
  res.setHeader('x-correlation-id', req.correlationId);
  next();
});

// 5. Request logging with correlation ID
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(
      `[${req.correlationId}] ${req.method} ${req.url} ${res.statusCode} ${duration}ms`
    );
  });
  next();
});

// 6. Rate limiting — 100 requests per 15 minutes per IP
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: { error: { message: 'Too many requests, please try again later' } },
  standardHeaders: true,
  legacyHeaders: false,
});
app.use(limiter);

// 7. Body parsing
app.use(express.json({ limit: '1mb' })); // enforce body size limit

// 8. Routes
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', correlationId: req.correlationId });
});

// 9. Error handler (last)
app.use((err, req, res, next) => {
  console.error(`[${req.correlationId}] Error:`, err.message);
  res.status(err.statusCode || 500).json({
    error: {
      message: err.statusCode === 500 ? 'Internal server error' : err.message,
      reference: req.correlationId
    }
  });
});

module.exports = app;
Output
# Request with correlation ID
curl -H "x-correlation-id: my-custom-id" http://localhost:3000/api/health
{
"status": "ok",
"correlationId": "my-custom-id"
}
# Log output:
[my-custom-id] GET /api/health 200 3ms
# Too many requests (after 100 in 15 min):
{
"error": {
"message": "Too many requests, please try again later"
}
} (HTTP 429)
Mental Model: Defence in Depth
  • helmet sets security headers (prevents XSS, clickjacking, etc.)
  • compression reduces bandwidth costs and improves load times
  • cors restricts cross-origin requests to allowed domains only
  • rate limiter blocks abusive traffic before it hits your routes
  • correlation ID ties all logs together for debugging distributed requests
Production Insight
A production API without rate limiting was overwhelmed by a single client sending 10,000 requests per second. The API served all of them until the database connection pool exhausted. All legitimate users saw 503 errors.
The fix: added express-rate-limit with a generous but finite limit, and a separate stricter limit for the auth endpoint.
Rule: Every production API must have rate limiting and CORS configured before any route logic.
Key Takeaway
Install and configure helmet, cors, compression, and express-rate-limit in every production Express app.
Correlation IDs are the foundation of debugging distributed systems.
Order matters: security middleware first, logging early, routes in the middle, error handler last.
Which Middleware Should You Add?
IfYour API is served over HTTP (not HTTPS)
UseAdd helmet to set HSTS header (if you plan HTTPS) and other security headers
IfYou expect clients to pass a trace ID from upstream systems
UseAdd correlation ID middleware that accepts a header with fallback to generated UUID
IfYour API returns JSON responses that are larger than 100KB
UseAdd compression middleware — it reduces JSON size by 4-10x
IfYour API is called from a web browser on a different origin
UseConfigure cors with explicit allowed origins, methods, and headers
● Production incidentPOST-MORTEMseverity: high

Missing next() Causes Production API Timeout

Symptom
A subset of API endpoints (those behind authentication) return no response after 30 seconds, then the client gets a 504 Gateway Timeout. Healthy endpoints without auth work fine.
Assumption
The auth middleware would automatically pass control to the route handler after synchronous token verification.
Root cause
The auth middleware function had early returns for invalid tokens but no next() call for valid tokens. Without next(), Express never moves to the route handler — the request stalls.
Fix
Add next() call after successful authentication. Also add a global timeout middleware using req.setTimeout() as a safety net to prevent resource leaks.
Key lesson
  • Every middleware must call next(), send a response, or forward an error. No exceptions.
  • Use a global request timeout to catch hung connections in production.
  • Test middleware chains with integration tests that verify response delivery.
Production debug guideQuick reference for the most common Express production issues5 entries
Symptom · 01
req.body is undefined on POST requests
Fix
Check that app.use(express.json()) is registered before any route that reads the body. Also verify Content-Type: application/json header in the request.
Symptom · 02
Route handler never executes (request hangs)
Fix
Check all middleware functions in the chain for missing next() calls. Use console.log or a request ID middleware to trace execution flow.
Symptom · 03
Error middleware not catching thrown errors
Fix
Verify the error handler is registered LAST. For async handlers in Express 4, wrap in try/catch and call next(error). Verify the error handler has exactly 4 parameters (err, req, res, next).
Symptom · 04
Specific route returns 404 even though it's defined
Fix
Check route order: if a wildcard or parameterised route is registered before this specific route, it captures the request first. Move specific routes above generic ones.
Symptom · 05
Static files not served (images, CSS, JS)
Fix
Add app.use(express.static('public')) and ensure the directory exists. Verify the path is relative to where the Node process runs.
★ Quick Debug Cheat Sheet for Express.jsFive common Express issues and the commands/config that fix them immediately
req.body undefined
Immediate action
Add app.use(express.json()) before any routes
Commands
curl -X POST http://localhost:3000/api -H 'Content-Type: application/json' -d '{"key":"value"}'
node -e "console.log(require('express').json)" to verify express.json is available
Fix now
Insert app.use(express.json()) as the first middleware in app.js
Request hangs with no response+
Immediate action
Log middleware execution with a request ID
Commands
Add `app.use((req, res, next) => { console.log('MIDDLEWARE', req.url); next(); })` before routes
Check for missing next() in custom middleware: grep -r 'function(req, res' -A 5 *.js
Fix now
Ensure every middleware either calls next(), returns a response, or calls next(error)
Async error causes unhandled promise rejection+
Immediate action
Wrap the handler in try/catch and call next(error)
Commands
Add a global unhandledRejection listener: `process.on('unhandledRejection', (err) => { console.error(err); })`
Check for async handlers without try/catch: grep -r 'async (req, res' *.js
Fix now
Use a wrapper like const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
Error middleware never called+
Immediate action
Verify error handler is the last middleware registered
Commands
Print all middleware in order: `app._router.stack.forEach(r => console.log(r.route || r.name))`
Check if error handler has exactly 4 parameters: `app.use((err, req, res, next) => ...)`
Fix now
Move the 4-argument error handler to the very end of your app.use() chain
Route returns 404 even though defined+
Immediate action
Check if another route with a wildcard or parameter is matching first
Commands
Test with curl: `curl -v http://localhost:3000/your-route` and examine the response headers
Log all registered routes: `app._router.stack.filter(r => r.route).forEach(r => console.log(r.route.path, r.route.methods))`
Fix now
Reorder routes: put static paths like /api/health before parameterised paths like /api/:id
Express.js vs Raw Node.js: Key Differences
Feature / AspectRaw Node.js http moduleExpress.js
Routing setupManual string matching with if/else chainsDeclarative app.get/post/put/delete with URL pattern matching
URL parametersManual parsing from req.url using split or regexAutomatic via req.params, req.query, req.body
Middleware supportNone built-in — must chain functions manuallyFirst-class pipeline with app.use() and next()
Error handlingTry/catch in every handler, no central handlerCentralised 4-argument error middleware with next(err)
JSON body parsingManual buffer accumulation and JSON.parseOne line: app.use(express.json())
Router modularityNot supported nativelyexpress.Router() for file-per-resource organisation
Learning curveLow to start, high to scaleLow overall — opinionated just enough
Performance overheadZero — it's the baselineMinimal — microseconds per request in benchmarks

Key takeaways

1
Express route order is execution order
specific routes must always be registered before wildcard or parameterised routes or they'll never fire.
2
Middleware is a pipeline, not magic
every function must call next(), send a response, or forward an error. A missing next() is a silent, hard-to-debug hang.
3
Separate app.js from server.js
exporting the app without calling listen() is what makes your Express application properly testable with tools like supertest.
4
Error-handling middleware requires exactly four parameters (err, req, res, next)
Express uses the argument count to detect it, so removing next even if unused breaks the entire error handling chain.
5
Async errors in Express 4 must be manually caught and forwarded. Use an asyncHandler wrapper or express-async-errors to avoid silent crashes.
6
Production Express APIs need helmet, cors, compression, rate limiting, and correlation IDs. These are not optional
they prevent entire categories of failures.

Common mistakes to avoid

5 patterns
×

Forgetting express.json() before routes that read req.body

Symptom
req.body is undefined even when you POST valid JSON. The route handler throws a TypeError when accessing req.body.field.
Fix
Add app.use(express.json()) at the top of your app.js, before any route definitions. Without it, Express never parses the request body stream.
×

Not calling next() inside middleware

Symptom
The request hangs indefinitely; the client times out with no response. The server appears healthy but never responds to certain endpoints.
Fix
Every middleware function must either call next(), call res.send()/res.json() to end the chain, or call next(error) to forward to the error handler. There is no fourth option.
×

Placing the global error handler before routes

Symptom
Errors are never caught; the app crashes or sends no response. The error handler code is written but execution never reaches it.
Fix
Express error handlers (4-argument functions) must be registered LAST, after all app.use() and route definitions. Express only uses them as a fallback when nothing earlier handled the request.
×

Using async route handlers without catching errors in Express 4

Symptom
An unhandled promise rejection occurs, potentially crashing the Node process. The request never receives a response.
Fix
Wrap async handlers in a try/catch that calls next(error), use a helper like asyncHandler, or install the express-async-errors package.
×

Sending stack traces to clients in production

Symptom
Stack traces containing file paths, line numbers, and possibly sensitive logic are exposed in API responses.
Fix
Check process.env.NODE_ENV before including stack. In production, always return a generic error message with a unique reference ID for logging.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between app.use() and app.get() in Express, and w...
Q02SENIOR
How does Express identify a function as an error-handling middleware rat...
Q03SENIOR
If an async route handler throws an error in Express 4, does Express cat...
Q04SENIOR
Explain the difference between application-level and router-level middle...
Q05SENIOR
What happens if you forget to call next() in a middleware function that ...
Q01 of 05SENIOR

What is the difference between app.use() and app.get() in Express, and when would you use one over the other?

ANSWER
app.use() mounts middleware at a path — it matches any HTTP method and fires for every request under that path. app.get() only matches GET requests to an exact or parameterised URL. Use app.use() for middleware like logging, auth, or CORS that should run for multiple methods. Use app.get() for route handlers that accept only GET requests. Mixing them up can cause unexpected behaviour: using app.get() where app.use() is intended will only apply to GET requests, and vice versa.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is Express.js used for in Node.js?
02
Is Express.js still worth learning in 2026?
03
What is the difference between middleware and a route handler in Express?
04
How do I handle CORS in Express?
05
What is the best way to structure a large Express application?
06
How do I test an Express API without starting a server?
🔥

That's Node.js. Mark it forged?

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

Previous
Node.js Modules and CommonJS
3 / 18 · Node.js
Next
REST API with Express.js