Express.js maps HTTP verbs + URL paths to route handlers
Middleware functions process requests in a pipeline before your route
app.use() order determines execution order – register json parser first, error handler last
Router modules let you group related routes under a prefix, keeping code modular
Production insight: forgetting next() in middleware hangs the request silently – every code path must call next() or send a response
Plain-English First
Imagine a restaurant. You (the customer) sit at a table and place an order. The waiter takes that order to the kitchen, the kitchen prepares the food, and the waiter brings it back. A REST API is exactly that waiter — it sits between your app (the customer) and your database or business logic (the kitchen). Express.js is the training manual that tells the waiter exactly how to behave: which orders to accept, in what format, and what to do when something goes wrong. You're not building the restaurant from scratch — you're hiring a very well-trained waiter.
Every app you use daily — Spotify, GitHub, your bank's mobile app — talks to a server through an API. When Spotify's mobile app asks 'give me this user's playlists', it sends an HTTP request to a REST API, which fetches the data and sends it back as JSON. Express.js is the most popular framework for building those APIs in Node.js, and for good reason: it's minimal, fast, and gives you exactly as much structure as you need without forcing a rigid pattern on you.
Before Express existed, building an HTTP server in raw Node.js meant writing dozens of lines of boilerplate just to read a URL or parse a request body. Express solves that. It wraps Node's built-in http module with a clean, chainable API for defining routes, plugging in middleware, and sending structured responses. The result is that you can go from zero to a working API endpoint in about ten lines of code — but knowing why each of those lines exists is what separates a junior who copies tutorials from an engineer who can debug, scale, and maintain a real service.
By the end of this article, you'll have a fully working RESTful API for a book library — complete with all four CRUD operations, proper HTTP status codes, input validation middleware, and a global error handler. More importantly, you'll understand the mental model behind each decision so you can apply these patterns to any domain, not just this example.
What REST Actually Means and Why Express Fits It Perfectly
REST stands for Representational State Transfer. It's an architectural style — a set of rules about how clients and servers should communicate over HTTP. The key constraint that matters most day-to-day is this: every resource (a user, a book, an order) gets its own URL, and you use HTTP verbs — GET, POST, PUT, DELETE — to describe what action you want to perform on it.
This means GET /books fetches all books. POST /books creates a new one. GET /books/42 fetches the book with ID 42. PUT /books/42 updates it. DELETE /books/42 removes it. The URL is the noun, the HTTP verb is the verb. This predictable contract is why REST APIs are so easy to consume — any client in any language can call them.
Express maps directly onto this mental model. It lets you register a handler for a specific verb + URL combination, called a route. When a request arrives that matches, Express runs your handler. That's the core loop. Everything else — middleware, error handling, routers — is built on top of that simple idea.
Understanding this upfront matters because beginners often think Express is REST. It isn't. Express is a tool. REST is the design philosophy. You could build a badly designed, non-RESTful API with Express just as easily. The goal of this article is to show you what a well-designed one looks like.
server.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
// server.js — the entry point for our Book Library REST API// Run: node server.js// Then visit: http://localhost:3000/api/booksconst express = require('express');
const app = express(); // create an Express application instanceconstPORT = 3000;
// express.json() is middleware that parses incoming request bodies// that have Content-Type: application/json. Without this, req.body is undefined.
app.use(express.json());
// In-memory data store — we'll replace this pattern with a real DB later// but the API shape stays identical, which is the whole point of abstractionlet books = [
{ id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas', year: 1999 },
{ id: 2, title: 'Clean Code', author: 'Robert C. Martin', year: 2008 },
{ id: 3, title: 'You Don\'t Know JS', author: 'Kyle Simpson', year: 2015 },
];
// GET /api/books — retrieve all books// REST rule: GET never modifies data, always returns a collection or single resource
app.get('/api/books', (req, res) => {
res.status(200).json({
success: true,
count: books.length,
data: books,
});
});
// GET /api/books/:id — retrieve one book by its ID// :id is a route parameter — Express captures whatever is in that URL segment
app.get('/api/books/:id', (req, res) => {
const bookId = parseInt(req.params.id, 10); // always parse to a number — URL params are stringsconst book = books.find((b) => b.id === bookId);
if (!book) {
// 404 means the resource doesn't exist — not a server error, a client errorreturn res.status(404).json({ success: false, message: `Bookwith id ${bookId} not found` });
}
res.status(200).json({ success: true, data: book });
});
// POST /api/books — create a new book// The client sends book data in the request body as JSON
app.post('/api/books', (req, res) => {
const { title, author, year } = req.body;
// Basic validation — never trust client inputif (!title || !author || !year) {
return res.status(400).json({
success: false,
message: 'title, author, and year are required fields',
});
}
// Generate a simple incrementing ID (a real DB would handle this)const newBook = {
id: books.length > 0 ? Math.max(...books.map((b) => b.id)) + 1 : 1,
title,
author,
year: parseInt(year, 10),
};
books.push(newBook);
// 201 Created — not 200. The resource didn't exist before; now it does.
res.status(201).json({ success: true, data: newBook });
});
// PUT /api/books/:id — replace/update an existing book
app.put('/api/books/:id', (req, res) => {
const bookId = parseInt(req.params.id, 10);
const bookIndex = books.findIndex((b) => b.id === bookId);
if (bookIndex === -1) {
return res.status(404).json({ success: false, message: `Bookwith id ${bookId} not found` });
}
const { title, author, year } = req.body;
// Merge the existing book data with the incoming updates
books[bookIndex] = { ...books[bookIndex], title, author, year: parseInt(year, 10) };
res.status(200).json({ success: true, data: books[bookIndex] });
});
// DELETE /api/books/:id — remove a book
app.delete('/api/books/:id', (req, res) => {
const bookId = parseInt(req.params.id, 10);
const bookIndex = books.findIndex((b) => b.id === bookId);
if (bookIndex === -1) {
return res.status(404).json({ success: false, message: `Bookwith id ${bookId} not found` });
}
books.splice(bookIndex, 1); // remove the book from our array// 204 No Content — success, but there's nothing to return (the resource is gone)
res.status(204).send();
});
app.listen(PORT, () => {
console.log(`BookLibraryAPI running on http://localhost:${PORT}`);
});
Sending 200 for everything is a red flag in a code review. Use 201 for successful creation, 204 for successful deletion with no response body, 400 for bad client input, 404 for missing resources, and 500 for unexpected server errors. Clients — including your own frontend code — use these codes to branch their logic. If everything is 200, your client has to parse the body to detect errors, which is brittle.
Production Insight
Sending 200 for all responses makes monitoring tools useless. Prometheus counters on HTTP status codes can't differentiate errors from success.
Rule: every status code must be intentional — never default to 200.
Key Takeaway
REST = nouns (URLs) + verbs (HTTP methods)
Express maps routes to verbs + URLs
HTTP status codes are part of your API contract — choose them deliberately.
Middleware — The Assembly Line That Runs Before Your Route Handler
Middleware is Express's killer feature, and it's the concept most beginners underestimate. A middleware function is just a function with three arguments: req, res, and next. When Express receives a request, it runs it through a pipeline of middleware functions in the order they were registered. Each function can read the request, modify it, respond to it, or pass control to the next function by calling next().
Think of it like airport security. Before you reach your gate (the route handler), your bag goes through X-ray (logging middleware), you show your passport (authentication middleware), and you get patted down (validation middleware). If any step fails, the process stops and you don't board the plane.
This pattern is powerful because it separates concerns cleanly. Your route handler shouldn't care about logging or authentication — it should only care about its specific business logic. Middleware handles the cross-cutting concerns that apply across many routes.
There are three types you'll use constantly: application-level middleware (registered with app.use()), router-level middleware (scoped to a specific Router instance), and error-handling middleware (four-argument functions: err, req, res, next). The order of registration is everything — middleware registered later in the file won't intercept requests that were already responded to by earlier handlers.
middleware.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
// middleware.js — custom middleware functions for our Book Library API// These are pure functions: testable, reusable, and single-purpose// ─── REQUEST LOGGER ───────────────────────────────────────────────────────────// Logs every incoming request: method, URL, and response time// This runs for EVERY request because we'll register it with app.use() at the topconst requestLogger = (req, res, next) => {
const startTime = Date.now();
// res.on('finish') fires AFTER the response is sent — so we get accurate timing
res.on('finish', () => {
const duration = Date.now() - startTime;
console.log(`[${newDate().toISOString()}] ${req.method} ${req.originalUrl} — ${res.statusCode} (${duration}ms)`);
});
next(); // CRITICAL: without next(), the request just hangs — no response ever sent
};
// ─── API KEY AUTHENTICATION ────────────────────────────────────────────────────// Checks that the client sent a valid API key in the x-api-key header// In production you'd validate against a database or use JWT — same pattern, more logicconst requireApiKey = (req, res, next) => {
const VALID_API_KEY = process.env.API_KEY || 'dev-secret-key-12345';
const clientKey = req.headers['x-api-key'];
if (!clientKey) {
// 401 Unauthorized — the client didn't provide credentials at allreturn res.status(401).json({
success: false,
message: 'Missing x-api-key header. Include your API key to access this endpoint.',
});
}
if (clientKey !== VALID_API_KEY) {
// 403 Forbidden — credentials were provided but they're wrongreturn res.status(403).json({
success: false,
message: 'Invalid API key. Check your credentials and try again.',
});
}
// Attach the key to the request object for downstream use if needed
req.apiKey = clientKey;
next(); // credentials are valid — continue to the route handler
};
// ─── BOOK BODY VALIDATOR ───────────────────────────────────────────────────────// Validates that POST and PUT requests have the required book fields// By extracting this to middleware, our route handlers stay cleanconst validateBookBody = (req, res, next) => {
const { title, author, year } = req.body;
const errors = [];
if (!title || typeof title !== 'string' || title.trim() === '') {
errors.push('title must be a non-empty string');
}
if (!author || typeof author !== 'string' || author.trim() === '') {
errors.push('author must be a non-empty string');
}
if (!year || isNaN(parseInt(year, 10)) || parseInt(year, 10) < 1000) {
errors.push('year must be a valid 4-digit number');
}
if (errors.length > 0) {
// Pass an Error object to next() — this triggers Express's error handlerconst validationError = newError('Validation failed');
validationError.statusCode = 400;
validationError.details = errors;
return next(validationError); // jumps straight to the error-handling middleware
}
next();
};
// ─── GLOBAL ERROR HANDLER ──────────────────────────────────────────────────────// This MUST have exactly 4 parameters — Express detects it as an error handler by signature// Register this LAST with app.use() — after all routesconst globalErrorHandler = (err, req, res, next) => {
console.error(`[ERROR] ${err.message}`, err.stack);
const statusCode = err.statusCode || 500; // default to 500 if no specific code was setconst response = {
success: false,
message: err.message || 'An unexpected server error occurred',
};
// Include validation details if present (avoids leaking stack traces to clients)if (err.details) {
response.errors = err.details;
}
res.status(statusCode).json(response);
};
module.exports = { requestLogger, requireApiKey, validateBookBody, globalErrorHandler };
Output
# Every request logged automatically:
[2024-01-15T10:23:45.123Z] GET /api/books — 200 (3ms)
[2024-01-15T10:23:51.456Z] POST /api/books — 400 (1ms)
# Missing API key:
{ "success": false, "message": "Missing x-api-key header. Include your API key to access this endpoint." }
# Validation failure:
{ "success": false, "message": "Validation failed", "errors": ["year must be a valid 4-digit number"] }
Watch Out: Forgetting next() Hangs Your Server
If your middleware doesn't call next() AND doesn't send a response, the HTTP request will sit there until the client times out. Express won't throw an error — it just silently hangs. Always make sure every code path in a middleware either calls next(), next(err), or sends a response. A linter rule like eslint-plugin-node can catch this pattern.
Production Insight
Forgetting next() is the #1 cause of silent hangs in Express APIs. It's not a crash — it's a timeout, and it looks like a network issue.
Rule: in every middleware, every branch must end with next(), next(err), or a response method (res.send/res.json/res.end).
Key Takeaway
Middleware pipeline: app.use() order = execution order
Every middleware must call next() or send a response — no exceptions
Error-handling middleware has 4 parameters and MUST be registered last.
Express Router — Organising Routes Like a Real-World Codebase
When your API has more than one resource — say books, authors, and orders — putting every route in a single server.js file becomes unmanageable fast. Express Router solves this by letting you create mini-applications that handle a subset of routes, then mount them onto your main app at a specific path prefix.
Think of it like a post office. The main post office (your app) receives all mail. It then hands packages destined for '42nd Street' to the 42nd Street department (your /api/books Router), packages for '5th Avenue' to a different department, and so on. Each department handles its own internal sorting without the main post office needing to know the details.
This isn't just an organisational preference — it's the pattern that makes your codebase testable and scalable. Each Router module can be imported, tested independently, and even reused. When a new developer joins your team, they can look at routes/books.js to understand everything about the books resource without reading the entire codebase.
The middleware cascade works here too: any middleware registered on the Router only runs for routes within that Router. This is how you can protect your entire /api/admin route group with auth middleware without touching any other route.
routes/books.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
// routes/books.js — all book-related routes live here// This file knows NOTHING about auth or logging — those are app-level concernsconst express = require('express');
const router = express.Router(); // create a Router instance — not a full appconst { validateBookBody } = require('../middleware');
// Simulated data layer — in a real app this would be a service/repository// The key point: swapping this to a real DB doesn't change the routes at alllet books = [
{ id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas', year: 1999 },
{ id: 2, title: 'Clean Code', author: 'Robert C. Martin', year: 2008 },
];
// GET / — maps to GET /api/books when mounted at /api/books// Notice: the path here is '/', not '/api/books' — the prefix is added at mount time
router.get('/', (req, res) => {
// req.query lets you read URL query parameters like ?author=Martinconst { author, year } = req.query;
let results = [...books];
if (author) {
results = results.filter((b) =>
b.author.toLowerCase().includes(author.toLowerCase())
);
}
if (year) {
results = results.filter((b) => b.year === parseInt(year, 10));
}
res.status(200).json({ success: true, count: results.length, data: results });
});
// GET /:id — maps to GET /api/books/:id
router.get('/:id', (req, res) => {
const bookId = parseInt(req.params.id, 10);
const book = books.find((b) => b.id === bookId);
if (!book) {
return res.status(404).json({ success: false, message: `Book ${bookId} not found` });
}
res.status(200).json({ success: true, data: book });
});
// POST / — validateBookBody middleware runs BEFORE the route handler// If validation fails, the route handler never executes — the error bubbles to globalErrorHandler
router.post('/', validateBookBody, (req, res) => {
const { title, author, year } = req.body;
const newBook = {
id: books.length > 0 ? Math.max(...books.map((b) => b.id)) + 1 : 1,
title: title.trim(),
author: author.trim(),
year: parseInt(year, 10),
};
books.push(newBook);
res.status(201).json({ success: true, data: newBook });
});
router.put('/:id', validateBookBody, (req, res) => {
const bookId = parseInt(req.params.id, 10);
const bookIndex = books.findIndex((b) => b.id === bookId);
if (bookIndex === -1) {
return res.status(404).json({ success: false, message: `Book ${bookId} not found` });
}
books[bookIndex] = { id: bookId, ...req.body, year: parseInt(req.body.year, 10) };
res.status(200).json({ success: true, data: books[bookIndex] });
});
router.delete('/:id', (req, res) => {
const bookId = parseInt(req.params.id, 10);
const bookIndex = books.findIndex((b) => b.id === bookId);
if (bookIndex === -1) {
return res.status(404).json({ success: false, message: `Book ${bookId} not found` });
}
books.splice(bookIndex, 1);
res.status(204).send();
});
module.exports = router;
// ─── app.js — main application file that wires everything together ─────────────// (In a real project this would be a separate file)const appSetup = `
const express = require('express');
const { requestLogger, requireApiKey, globalErrorHandler } = require('./middleware');
const bookRouter = require('./routes/books');
const app = express();
app.use(express.json()); // parse JSON bodies — runs for ALL routes
app.use(requestLogger); // log every request — runs for ALL routes
app.use(requireApiKey); // auth gate — runs for ALL routes below this line// Mount the book router at /api/books// All routes in bookRouter are now prefixed with /api/books
app.use('/api/books', bookRouter);
// 404 handler — catches any request that didn't match a route above
app.use((req, res) => {
res.status(404).json({ success: false, message: 'Route not found' });
});
// Error handler — MUST be registered last, MUST have 4 params
app.use(globalErrorHandler);
app.listen(3000, () => console.log('API running on http://localhost:3000'));
`;
console.log('Route structure:\n', appSetup);
{ "success": false, "message": "Validation failed", "errors": ["year must be a valid 4-digit number"] }
# GET /api/books/999
{ "success": false, "message": "Book 999 not found" }
# GET /nonexistent-route
{ "success": false, "message": "Route not found" }
Pro Tip: Route Parameter Order Is a Gotcha
If you define router.get('/:id', ...) before router.get('/featured', ...), Express will match /api/books/featured as a request for book with id 'featured' — not your featured route. Always register specific, literal routes BEFORE parameterised ones. This is a real interview question too — interviewers love to ask why /users/me isn't matching as expected.
Production Insight
Literal routes before parameterised ones — this is the classic Express gotcha. A route like /users/me will never match if /users/:id is defined first.
Rule: order routes from most specific to most generic.
Key Takeaway
Express Router = modular route groups
Mount with app.use(prefix, router)
Literal routes before parameterised routes — always.
Error Handling Patterns — From Validation to Global Catchers
Express gives you a structured way to handle errors that keeps your route handlers clean and your error responses consistent. The pattern is simple: when something goes wrong in a middleware or route handler, you call next(err) with an Error object. Express then skips all remaining non-error middleware and goes straight to the error-handling middleware — the one with four parameters (err, req, res, next).
This is important because it means you don't need try/catch blocks scattered across every route. Instead, you have a single place where all errors are caught, logged, and formatted into a consistent JSON response. The error handler is also where you decide what to expose to the client — never leak stack traces in production.
There's a nuance with async handlers. An async function that throws will result in an unhandled promise rejection — Express won't catch it automatically unless you use a wrapper like express-async-errors or explicitly wrap each handler. In recent Node versions (16+), unhandled rejections cause the process to exit, which is even worse. The safest approach is to install express-async-errors at the top of your entry file — it patches Express to catch async errors for you.
error-handling.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
// error-handling.js — dedicated error patterns for production Express APIs// ─── Wrapper for async route handlers ──────────────────────────────────────────// Without this, an async error crashes the server or causes a silent unhandled rejectionconst asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Usage:// app.get('/api/books', asyncHandler(async (req, res) => {// const books = await db.findMany();// res.json(books);// }));// ─── OR: install express-async-errors at the top of server.js ──────────────────// require('express-async-errors');// This patches Express to catch async rejections everywhere — no wrapper needed.// ─── Custom error class for API errors ──────────────────────────────────────────// Allows you to set HTTP status codes on errors consistentlyclassApiErrorextendsError {
constructor(statusCode, message, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
}
}
// Usage:// throw new ApiError(403, 'You do not have permission to access this resource');// ─── Global error handler (must be registered last) ────────────────────────────const globalErrorHandler = (err, req, res, next) => {
// Log full error internally
console.error(`[ERROR ${newDate().toISOString()}] ${err.message}`, err.stack);
// Determine status codeconst statusCode = err.statusCode || 500;
// Build response bodyconst response = {
success: false,
message: statusCode === 500 ? 'An unexpected error occurred' : err.message,
};
// Include validation errors if present (never leak stack in production)if (err.details) {
response.errors = err.details;
}
// In development, you might include the stack trace for debuggingif (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(statusCode).json(response);
};
module.exports = { asyncHandler, ApiError, globalErrorHandler };
Output
# Async handler wrapping prevents crashes:
# Without wrapper: unhandled promise rejection -> app crashes or hangs
# With wrapper: error bubbles to globalErrorHandler
# Custom ApiError class gives consistent status codes:
# throw new ApiError(400, 'Invalid input', ['title is required']);
In production, never return err.stack in the response body. It exposes file paths, internal variable names, and possibly secrets. Always check NODE_ENV before including stack traces. A malicious actor can use stack traces to map your directory structure.
Production Insight
Without express-async-errors or asyncHandler, a single unhandled rejection in an async route can bring down the entire Node process (Node 15+ default).
Rule: always wrap async routes or patch Express globally at startup.
Key Takeaway
Call next(err) to jump to error-handling middleware
Async routes need explicit error catching — use express-async-errors
Never expose stack traces in production responses
Production Patterns — Config, Logging, and Graceful Shutdown
A REST API that works on your laptop is not a production-ready API. Production means handling environment-specific configuration, structured logging you can actually query, and a graceful shutdown that doesn't drop in-flight requests.
Environment configuration is trivial but often done wrong. Hardcoding things like database URLs or API keys in the codebase is a security incident waiting to happen. Use process.env with sensible defaults for development, and validate that required variables are present on startup.
Logging in production should be structured JSON, not freeform text. Tools like ELK, Datadog, or Grafana expect log lines as JSON objects so they can be filtered and aggregated. Use a library like pino or winston — console.log is fine for development but worthless at scale.
Graceful shutdown is the one most people forget. When you send SIGTERM to your Node process (e.g., during a deployment), any ongoing HTTP requests get aborted. You need to listen for the signal, stop accepting new connections, wait for pending requests to finish, and then close. Express doesn't do this by default — you need to wrap server.close() in the signal handler.
production-setup.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
// production-setup.js — environment configuration and graceful shutdownconst express = require('express');
const pino = require('pino'); // structured JSON loggingconst { ApiError, globalErrorHandler } = require('./error-handling');
// ─── Configuration ─────────────────────────────────────────────────────────────const requiredEnvVars = ['DATABASE_URL', 'API_KEY'];
const missing = requiredEnvVars.filter((v) => !process.env[v]);
if (missing.length > 0) {
console.error(`Missing required environment variables: ${missing.join(', ')}`);
process.exit(1);
}
const config = {
port: parseInt(process.env.PORT, 10) || 3000,
databaseUrl: process.env.DATABASE_URL,
apiKey: process.env.API_KEY,
nodeEnv: process.env.NODE_ENV || 'development',
};
// ─── Structured Logger ─────────────────────────────────────────────────────────const logger = pino({
level: config.nodeEnv === 'production' ? 'info' : 'debug',
formatters: {
// Ensures every log line has a timestamp and service name for correlation
bindings: () => ({ service: 'book-library-api' }),
},
});
// Express middleware to attach logger to each requestconst requestLogger = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
url: req.originalUrl,
status: res.statusCode,
durationMs: Date.now() - start,
});
});
next();
};
// ─── Server and Graceful Shutdown ──────────────────────────────────────────────const app = express();
app.use(express.json());
app.use(requestLogger);
// ... routes ...
app.use(globalErrorHandler);
const server = app.listen(config.port, () => {
logger.info(`Server started on port ${config.port}`);
});
const gracefulShutdown = (signal) => {
logger.info(`${signal} received — shutting down gracefully...`);
server.close(() => {
logger.info('All connections closed. Exiting.');
process.exit(0);
});
// If connections don't close within 10 seconds, force exitsetTimeout(() => {
logger.error('Forcing shutdown after timeout');
process.exit(1);
}, 10000).unref();
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Output
# On startup:
{"level":30,"time":1705321234567,"service":"book-library-api","msg":"Server started on port 3000"}
Environment config = externalise everything that changes between environments.
Structured logging = JSON output that tools can parse; console.log is for debugging only.
Graceful shutdown = listen for SIGTERM, stop accepting, drain requests, then exit.
Health checks = expose a /healthz endpoint that returns 200 so orchestrators know you're alive.
Port configuration = read from environment with a sensible default, and validate on startup.
Production Insight
No graceful shutdown means dropped requests during deployments. Kubernetes sends SIGTERM, then after a grace period, SIGKILL. If your app doesn't close cleanly, you lose in-flight requests.
Rule: always listen for SIGTERM and call server.close() with a timeout.
Graceful shutdown prevents dropped requests during deployments
● Production incidentPOST-MORTEMseverity: high
The Silent Hang: Forgetting next() in a Validation Middleware
Symptom
All POST requests to /api/books would hang for exactly 30 seconds and then return a 504 Gateway Timeout. GET requests worked fine. No errors appeared in the logs.
Assumption
The team assumed the database was slow. They checked connection pools, query times, and even restarted the database. The issue persisted.
Root cause
The validation middleware had a conditional branch: if a field was valid, it proceeded but forgot to call next(). The route handler never executed, and no response was sent. The middleware simply exited, leaving the request hanging.
Fix
Add a default next() call at the end of the middleware function, and ensure every code path either sends a response or calls next(). Also added a linter rule (eslint-plugin-node) to catch missing next() calls.
Key lesson
Never assume every code path in a middleware calls next() — review all branches explicitly.
Use a linter to enforce that middleware functions always either call next() or send a response.
Add a request timeout middleware (e.g. express-timeout) as a safety net so hung requests don't wait forever.
Production debug guideIdentify and fix the most common production issues in Express APIs.4 entries
Symptom · 01
Request hangs indefinitely, no response sent
→
Fix
Check if a middleware or route handler forgot to call next() or res.end(). Use console.log('reached here') at the end of each middleware to see if execution reaches it.
Symptom · 02
Global error handler never runs, Express shows HTML error page
→
Fix
Ensure the error handler is registered LAST (after all routes) and has exactly 4 parameters: (err, req, res, next). If it's above a route, errors from that route won't reach it.
Symptom · 03
req.body is undefined in POST/PUT handlers
→
Fix
Verify that app.use(express.json()) is called BEFORE any route handlers. If it's after a route, that route won't have parsed body.
Symptom · 04
Route returns 404 even though it exists
→
Fix
Check the order of route definitions. Parameterised routes like /:id must come AFTER literal routes like /featured. Also confirm the path prefix matches the mount point (e.g., app.use('/api/books', router) means router.get('/') maps to GET /api/books).
★ Express.js Quick Debug Cheat SheetJump straight to the fix for the most common Express production issues.
POST request hangs — no req.body−
Immediate action
Check if express.json() is registered and placed above routes.
Runs in the order app.use() calls appear in app.js
Runs in order of router.use() calls within that router file
Error handling
Catches errors from all routes below it
Only catches errors from routes within that router
Testability
Harder to unit test in isolation
Easy to test the router module independently with supertest
Key takeaways
1
HTTP verbs are the verb, URLs are the noun
GET /books fetches, POST /books creates, PUT /books/:id updates, DELETE /books/:id removes. Never use verbs in REST URLs like /getBooks or /createUser.
2
Middleware order is execution order
app.use(express.json()) must come before any route that reads req.body, and globalErrorHandler must always be the last app.use() call in your file.
3
Calling next(err) with an Error object is how you route to Express's error-handling middleware
and that error handler must have exactly four parameters (err, req, res, next) or Express won't recognise it as an error handler.
4
Express Router lets you scope routes and middleware to a specific URL prefix
mount a router with app.use('/api/books', bookRouter) and every route inside it is automatically prefixed, keeping your codebase modular and testable.
5
Async route handlers must be wrapped to catch errors
either with an asyncHandler wrapper or by installing express-async-errors.
6
Production Express APIs need environment config validation, structured JSON logging (pino or winston), and graceful shutdown (listen for SIGTERM and call server.close()).
Common mistakes to avoid
3 patterns
×
Not calling next(err) in async route handlers
Symptom
An async operation throws inside a route handler, the error is swallowed silently, and the client gets no response (then a timeout).
Fix
Wrap async handlers in a try/catch and call next(err), or install express-async-errors at the top of your entry file to catch all async rejections automatically.
×
Registering the global error handler before your routes
Symptom
Errors produce no response or fall through to Express's default HTML error page even though you defined a handler.
Fix
Move the error handler to the very end of your middleware chain, after all routes and other app.use() calls.
×
Using 200 for all responses regardless of outcome
Symptom
Clients, proxies, and monitoring tools can't differentiate errors from success. Axios, fetch, and API Gateway all branch on status codes.
Fix
Use 201 for creation, 204 for deletion with no body, 400 for bad input, 401/403 for auth failures, 404 for missing resources, and 500 for unexpected server errors.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between 401 Unauthorized and 403 Forbidden, and w...
Q02SENIOR
Explain the Express middleware pipeline. If I have five app.use() calls ...
Q03SENIOR
How would you handle errors in an async/await route handler in Express, ...
Q04JUNIOR
What is the difference between app.use() and router.use() in Express?
Q05SENIOR
How would you implement a health check endpoint in an Express API for a ...
Q01 of 05JUNIOR
What is the difference between 401 Unauthorized and 403 Forbidden, and when would your Express API return each one?
ANSWER
401 Unauthorized means the client has not provided valid credentials — e.g., no API key header. 403 Forbidden means the client provided credentials but they don't have permission — e.g., invalid API key. In Express, 401 is returned when request.headers['x-api-key'] is missing; 403 is returned when the key doesn't match the expected value.
Q02 of 05SENIOR
Explain the Express middleware pipeline. If I have five app.use() calls and a route handler, what determines the execution order, and what happens if one middleware never calls next()?
ANSWER
Execution order is the order of app.use() calls. Each middleware runs sequentially. If a middleware does not call next() and does not send a response (res.send/res.json), the request hangs until the client times out. Express does not throw an error — it simply waits. This is why you must ensure every branch of a middleware either calls next() or sends a response.
Q03 of 05SENIOR
How would you handle errors in an async/await route handler in Express, given that try/catch boilerplate repeated across 20 routes becomes a maintenance problem? Walk me through at least two approaches.
ANSWER
Approach 1: Create a wrapper function const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);. Wrap each async route: app.get('/route', asyncHandler(async (req, res) => {...})). Approach 2: Install express-async-errors at the top of your entry file. It patches Express to automatically catch promise rejections and forward them to the error handler. No wrapper needed. Both approaches eliminate try/catch duplication.
Q04 of 05JUNIOR
What is the difference between app.use() and router.use() in Express?
ANSWER
app.use() registers middleware that applies to the entire application — every request matches. router.use() registers middleware that applies only to routes within that specific Router instance. For example, if you have a book router mounted at /api/books, router.use(requireApiKey) only protects /api/books endpoints, not other routes.
Q05 of 05SENIOR
How would you implement a health check endpoint in an Express API for a Kubernetes readiness probe?
ANSWER
Add a route at /healthz that returns a 200 status with a simple JSON body, e.g., { status: 'ok' }. No authentication, no database calls — just a lightweight confirmation that the process is alive and listening. For a liveness probe, you might add a lightweight database ping, but keep it fast (< 1 second) or Kubernetes will restart your pod.
01
What is the difference between 401 Unauthorized and 403 Forbidden, and when would your Express API return each one?
JUNIOR
02
Explain the Express middleware pipeline. If I have five app.use() calls and a route handler, what determines the execution order, and what happens if one middleware never calls next()?
SENIOR
03
How would you handle errors in an async/await route handler in Express, given that try/catch boilerplate repeated across 20 routes becomes a maintenance problem? Walk me through at least two approaches.
SENIOR
04
What is the difference between app.use() and router.use() in Express?
JUNIOR
05
How would you implement a health check endpoint in an Express API for a Kubernetes readiness probe?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Do I need Express to build a REST API in Node.js?
No — you can use Node's built-in http module. But you'd manually parse URLs, handle routing logic, parse JSON bodies, and manage every edge case yourself. Express abstracts all of that into a clean API. Most production teams use Express or a framework built on top of it (like NestJS or Fastify) precisely because the boilerplate savings are significant and the patterns are battle-tested.
Was this helpful?
02
What is the difference between req.params, req.query, and req.body in Express?
req.params holds values from URL segments defined with a colon, like the 42 in /books/42 when your route is /books/:id. req.query holds key-value pairs from the URL query string, like ?author=Martin. req.body holds data sent in the HTTP request body — typically JSON sent with a POST or PUT request, available only after you've registered the express.json() middleware.
Was this helpful?
03
Why does my Express error handler never seem to run?
There are two common causes. First, your error handler must be registered AFTER all routes with app.use() — if it's above your routes, requests never reach it before being handled. Second, for async route handlers, errors thrown inside async functions must be explicitly caught and passed to next(err). An uncaught promise rejection doesn't automatically flow into Express's error pipeline unless you're using Node 18+ with unhandledRejection hooks or a wrapper like express-async-errors.
Was this helpful?
04
What's the best way to organise routes in a large Express application?
Use Express Router to create separate files per resource (e.g., routes/books.js, routes/authors.js). In each file, create a router instance and define the resource's routes on it. In your main app.js, mount each router at its prefix: app.use('/api/books', bookRouter), app.use('/api/authors', authorRouter). This keeps your codebase modular, testable, and makes it easy to find where a route is defined.
Was this helpful?
05
How do I handle file uploads in Express?
Express doesn't handle multipart/form-data natively. Use a middleware library like multer. It parses file uploads and attaches them to req.file or req.files. You can configure storage (disk, memory, cloud), validation (file size, type), and error handling. Register multer as middleware on the specific route that accepts uploads.