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 endpointsconst 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 callconst 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=adminconst { 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 messagereturn 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.jsif (!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();
constPORT = 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}`);
});
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 pipelineconst express = require('express');
const app = express();
constPORT = 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 = newDate().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.functionrequireAuth(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 missingconst reportData = awaitfetchReport(req.currentUser.id);
res.json({ data: reportData });
} catch (error) {
// Forward the error to Express's error handler — do NOT handle it herenext(error);
}
});
asyncfunctionfetchReport(userId) {
// Simulating a failed DB querythrownewError(`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 productionconst 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}`));
{ "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.
{ "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 ──────────────────────────────────────classAppErrorextendsError {
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 throwif (!user) thrownewAppError('User not found', 404);
res.json({ data: user });
}));
// ── Global error handler (must be last) ───────────────────────────────────────
app.use((err, req, res, next) => {
// Determine status codeconst statusCode = err.statusCode || 500;
// Log the full error server-side
console.error(`[${newDate().toISOString()}] ${err.message}`, err.stack);
// Generate unique error reference for clientconst 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 })
}
});
});
asyncfunctionfindUser(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 APIconst 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 = awaitrequest(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 = awaitrequest(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 = awaitrequest(app)
.get('/api/users/1')
.expect(200);
expect(res.body.data.id).toBe(1);
});
it('returns 404 for non-existent user', async () => {
awaitrequest(app)
.get('/api/users/999')
.expect(404);
});
});
describe('POST /api/users', () => {
it('creates a user with valid data', async () => {
const res = awaitrequest(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 () => {
awaitrequest(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 productionconst res = awaitrequest(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.
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
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 / Aspect
Raw Node.js http module
Express.js
Routing setup
Manual string matching with if/else chains
Declarative app.get/post/put/delete with URL pattern matching
URL parameters
Manual parsing from req.url using split or regex
Automatic via req.params, req.query, req.body
Middleware support
None built-in — must chain functions manually
First-class pipeline with app.use() and next()
Error handling
Try/catch in every handler, no central handler
Centralised 4-argument error middleware with next(err)
JSON body parsing
Manual buffer accumulation and JSON.parse
One line: app.use(express.json())
Router modularity
Not supported natively
express.Router() for file-per-resource organisation
Learning curve
Low to start, high to scale
Low overall — opinionated just enough
Performance overhead
Zero — it's the baseline
Minimal — 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.
Q02 of 05SENIOR
How does Express identify a function as an error-handling middleware rather than a regular middleware function?
ANSWER
Express counts the number of parameters. A regular middleware has 3 parameters: (req, res, next). An error-handling middleware has exactly 4 parameters: (err, req, res, next). The parameter names don't matter — only the count. If you omit the err parameter (even if you don't use it), Express treats it as regular middleware and your errors won't be caught. This is a common source of bugs.
Q03 of 05SENIOR
If an async route handler throws an error in Express 4, does Express catch it automatically? What pattern should you use, and how does Express 5 change this?
ANSWER
No, Express 4 does not automatically catch rejected promises from async handlers. If you throw inside an async function without a try/catch, the promise is rejected but Express never sees it — the request hangs and you get an unhandled promise rejection. The correct pattern is to wrap the handler in try/catch and call next(error), or use a helper like asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). Express 5 (currently in beta) will automatically forward rejected promises to the error handler, removing the need for this wrapper.
Q04 of 05SENIOR
Explain the difference between application-level and router-level middleware in Express, and give a real-world example of each.
ANSWER
Application-level middleware is attached to the app instance with app.use() and runs for every request to the application (unless scoped by path). Example: app.use(express.json()) globally parses JSON bodies for all routes. Router-level middleware is attached to an express.Router() instance and only applies to routes mounted on that router. Example: an adminAuth middleware that checks for admin privileges, attached only to the admin router. This allows you to apply different middleware sets to different resource groups.
Q05 of 05SENIOR
What happens if you forget to call next() in a middleware function that doesn't send a response? How would you debug this in production?
ANSWER
The request stalls indefinitely until the client times out (usually 30 seconds). The server doesn't crash — it just never sends a response. To debug in production, you need a global timeout middleware (e.g., req.setTimeout) that logs any request exceeding a threshold. Also, add request ID middleware at the start and log at each middleware entry and exit. If logs stop after a middle point, that's the culprit. Adding a middleware that measures duration and logs when response is sent (via res.on('finish')) helps identify hanging requests.
01
What is the difference between app.use() and app.get() in Express, and when would you use one over the other?
SENIOR
02
How does Express identify a function as an error-handling middleware rather than a regular middleware function?
SENIOR
03
If an async route handler throws an error in Express 4, does Express catch it automatically? What pattern should you use, and how does Express 5 change this?
SENIOR
04
Explain the difference between application-level and router-level middleware in Express, and give a real-world example of each.
SENIOR
05
What happens if you forget to call next() in a middleware function that doesn't send a response? How would you debug this in production?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
What is Express.js used for in Node.js?
Express.js is a minimal web framework for Node.js that provides routing, middleware support, and HTTP helper methods. It's used to build REST APIs, web servers, and backend services. It handles the boilerplate of URL mapping and request/response management so you can focus on business logic.
Was this helpful?
02
Is Express.js still worth learning in 2026?
Absolutely. Express is the most downloaded Node.js framework by a wide margin and underpins millions of production APIs. Even if you eventually use Fastify or NestJS, understanding Express's middleware model gives you the mental framework to learn any Node.js framework quickly, and it remains the dominant choice in job listings.
Was this helpful?
03
What is the difference between middleware and a route handler in Express?
In Express, there's no structural difference — both are just functions with (req, res, next). The distinction is conceptual: middleware is intended to process or transform the request before it reaches its destination (logging, auth, parsing), while a route handler is the final destination that sends the actual response. A route handler typically does not call next() because the request lifecycle ends there.
Was this helpful?
04
How do I handle CORS in Express?
Install the cors package and add app.use(cors()). For production, configure it with allowed origins, methods, and headers. Example: cors({ origin: 'https://myfrontend.com', methods: ['GET','POST'] }). Do not use cors({ origin: '*' }) in production unless your API is completely public.
Was this helpful?
05
What is the best way to structure a large Express application?
Split your code into routes, controllers, services, and middleware. Use separate files for each resource (e.g., userRoutes.js, userController.js, userService.js). Use express.Router() to create resource-specific route groups. Keep database access in services, not controllers. Separate app setup (app.js) from server startup (server.js) to enable testing. Use dotenv for environment variables.
Was this helpful?
06
How do I test an Express API without starting a server?
Export the app instance from app.js without calling listen(). In your test (using Jest or Mocha), import the app and use supertest: const request = require('supertest'); const res = await request(app).get('/users'). Supertest creates an in-memory server that doesn't bind to a port, so tests run fast and without port conflicts.