Debugging and Logging
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the
official documentation on 2026-10-29. Debugging answers the question "what is the framework doing with this request?"
and logging answers "what happened, to whom and how long did it take, after the fact?". Express covers the first with
the debug package built into its internals and the second with middleware you add yourself. The examples belong to the
Bookshelf API that grows across the section, use ESM and the node: prefix for built-in modules, and contain
placeholders only, never real secrets. For the runtime side of the same topics (the Node.js inspector, diagnostic
reports, metrics and tracing) see Node.js debugging and
diagnostics, logging and observability; this page
owns the Express layer.
Choosing the right tool
Each tool answers a different question, and they are used at different moments of a project’s life:
| Tool | Question it answers | When to use it |
|---|---|---|
|
Which layers and routes did Express try, and in which order? |
While developing, to see why a route is not reached or a middleware does not run. Never as production logging. |
|
What is my own module doing right now? |
Developer-oriented tracing you can switch on and off without a code change. |
Node.js inspector |
What are the values of these variables at this line? |
Breakpoints, stepping, heap and CPU profiles, while developing or on a staging process. |
|
What does the HTTP server itself report, without wrapping it? |
Observability libraries and low-level tracing of requests and responses. |
|
One line per request: method, URL, status, time. |
Access logs in development and simple deployments. |
|
How long did the server take, visible to the client? |
An |
|
A structured, machine-readable event per request and per application event. |
Production logging that a log platform can index, query and alert on. |
The DEBUG environment variable
Express and the packages it is built from use the debug package. It prints nothing
unless the DEBUG environment variable lists a matching namespace, so the instrumentation costs next to nothing when it
is off. The official guide shows the simplest use, enabling every Express namespace:
DEBUG=express:* node server.mjs
The wildcard shown above is the one form the guide documents. In Express 5 the routing code moved out of Express into
the standalone router package, so the router’s namespaces are no longer prefixed with express:. To see the whole
picture in Express 5, list both families:
DEBUG=express:*,router,router:* node server.mjs
Source: Express: Debugging Express
The namespaces you will meet are:
| Namespace | What it prints | Provided by |
|---|---|---|
|
Application events such as settings defaults, the boot of the app, and the mounting of sub-applications. |
Express |
|
Which template file the view system looked up and where it found, or failed to find, it. |
Express |
|
The router’s handling of a request: entering the stack, trimming the mounted path, dispatching and finishing. In
Express 4 this was |
|
|
Each layer tried for a request: its path, whether it matched and the parameters it decoded. In Express 4 this was
|
|
|
Dispatch inside a single route: which method handlers run, and |
|
Namespace names are an implementation detail rather than a public API, so treat the exact lines as a diagnostic aid and
not as something to parse. When a request is not reaching a handler, the router:layer output is the quickest way to see
which layer matched, which did not, and where the chain stopped, which makes it a good companion to the ordering rules in
Middleware Fundamentals.
Selecting and silencing namespaces
The DEBUG value is a comma- or space-separated list of patterns. A * matches any characters and a leading -
excludes a pattern, so you can ask for everything and then remove the noisy parts:
# Everything from the Bookshelf code and from Express, minus the per-layer chatter
DEBUG=bookshelf:*,express:*,router,-router:layer node server.mjs
# Only the database namespace of the application
DEBUG=bookshelf:db node server.mjs
Source: debug-js/debug (wildcards and exclusions)
Further switches are read from the environment by the same package:
| Variable | Effect |
|---|---|
|
Forces colours on or off. They are enabled by default when the output stream is a terminal. |
|
Removes the ISO date from lines when the output is not a terminal. Useful when another tool already timestamps them. |
|
The object inspection depth used by the |
|
Shows hidden properties of inspected objects. |
Output goes to standard error, so DEBUG never mixes with a response body written to standard output. On Windows the
cmd shell sets the variable with set DEBUG=express:*,router,router:* & node server.mjs and PowerShell with
$env:DEBUG = "express:*,router,router:*"; node server.mjs. In a container or a process manager, put the variable in
the service’s environment definition and remove it again once the problem is understood.
The debug package in your own code
The same package is the right way to add developer tracing to your own modules: one namespace per module, a message
format string, and no cost when the namespace is disabled. Install it with npm install debug. The program below traces
the Bookshelf repository and the HTTP layer under separate namespaces; run it with npm install express debug and
DEBUG=bookshelf:* node debug-demo.mjs.
// debug-demo.mjs
import createDebug from 'debug';
import express from 'express';
const debugHttp = createDebug('bookshelf:http');
const debugRepo = createDebug('bookshelf:repo');
const books = new Map([['1', { id: '1', title: 'Dune' }]]);
function findBook(id) {
// %s string, %d number, %j JSON, %o and %O object
debugRepo('looking up book %s', id);
const book = books.get(id) ?? null;
debugRepo('lookup %s -> %O', id, book);
return book;
}
const app = express();
app.use((req, res, next) => {
debugHttp('%s %s', req.method, req.originalUrl);
next();
});
app.get('/books/:id', (req, res) => {
const book = findBook(req.params.id);
if (!book) {
return res.status(404).json({ title: 'Not Found', status: 404 });
}
res.json(book);
});
// Skip the work of building an expensive message when the namespace is off.
if (debugRepo.enabled) {
debugRepo('startup snapshot: %j', [...books.keys()]);
}
app.listen(3000, () => debugHttp('listening on port 3000'));
Source: Express: Debugging and debug-js/debug
Name namespaces with the application first (bookshelf:db, bookshelf:auth), so that one pattern selects all of your
own output and one - pattern removes a single module. The package also lets a library create a child namespace with
debug.extend('query'). Because the messages are not logged by default, anything that must be kept (an audit event, an
error report) belongs in the logger described later and not in debug.
The Node.js inspector
When tracing lines is not enough and you need to stop the program and look at it, use the inspector that ships with Node.js. No Express-specific configuration is involved; an Express app is a normal Node.js process. The commands below come from the Node.js debugging guide.
# Start normally, with the inspector listening on 127.0.0.1:9229
node --inspect server.mjs
# Pause on the first line, to set breakpoints before any code runs
node --inspect-brk server.mjs
# Combine with the built-in watch mode during development
node --watch --inspect server.mjs
Source: Node.js: Debugging Node.js apps
Open chrome://inspect in a Chromium-based browser and select the process under Remote Target, or attach the debugger
of your editor: Visual Studio Code uses a launch configuration of type node with request launch or attach, and the
JetBrains IDEs offer the same through their Node.js run configurations. Useful places to put breakpoints in an Express
application are:
-
the first line of a suspect middleware, to inspect
req,res.localsand the order in which layers arrive; -
the body of the error-handling middleware, to see the original error and the call stack that produced it;
-
the line that follows an
await, where an Express 5 async handler is about to return or throw.
The inspector has a security cost worth stating plainly. Anyone who can connect to the inspector port can execute
arbitrary code in the process with the process’s privileges. The default binding of 127.0.0.1 is therefore the right
one; never use --inspect=0.0.0.0, never publish port 9229 from a container to the network, and reach a remote process
through an SSH tunnel instead. Sending SIGUSR1 to a running Node.js process also starts the inspector on Unix systems,
which is a further reason to restrict who may signal production processes. The legacy debugger invoked with node debug and the --debug flag, from the Node.js 0.x era, no longer exist; the command-line debugger is node inspect.
diagnostics_channel for HTTP events
Node.js publishes events about its own HTTP server through the built-in node:diagnostics_channel module. Express does
not need to be wrapped to use them: you subscribe to a named channel and receive a message for each event. Monitoring
libraries use this to build tracing without patching http. The channel names below are documented by Node.js; check the
version of Node.js you run, because channels were added over time.
// channels.mjs
import diagnosticsChannel from 'node:diagnostics_channel';
import express from 'express';
const started = new WeakMap();
diagnosticsChannel.subscribe('http.server.request.start', ({ request }) => {
started.set(request, process.hrtime.bigint());
});
diagnosticsChannel.subscribe('http.server.response.finish', ({ request, response }) => {
const begin = started.get(request);
if (begin === undefined) return;
const ms = Number(process.hrtime.bigint() - begin) / 1e6;
console.error(`${request.method} ${request.url} ${response.statusCode} ${ms.toFixed(1)} ms`);
});
const app = express();
app.get('/ping', (req, res) => res.json({ pong: true }));
app.listen(3000);
Source: Node.js: diagnostics_channel
The messages carry the raw Node.js request and response objects, so they see the URL as received, before Express
rewrites req.url for mounted routers. For application-level logging, the middleware in the following sections is
simpler; reach for diagnostics_channel when you are writing instrumentation that must work for any framework.
Access logs with morgan
morgan is the HTTP request logger maintained by the Express
team. It writes one line per request, after the response has finished by default, using a format. Install it with
npm install morgan and apply it before the routes you want to log:
import morgan from 'morgan';
app.use(morgan('dev')); // concise, colour-coded by status, for development
Source: Express middleware: morgan
Predefined formats
| Format | Output |
|---|---|
|
The standard Apache combined log: remote address, user, date, request line, status, content length, referrer and user agent. A common choice for production access logs that existing tools can parse. |
|
The Apache common log format, |
|
Method, URL, status (coloured red for server errors, yellow for client errors, cyan for redirects, green for success), response time and content length. |
|
Remote address, method, URL, HTTP version, status, content length and response time. |
|
Method, URL, status, content length and response time. The smallest useful line. |
Tokens and custom formats
A format is a string of tokens that morgan replaces for each request, or a function that returns the line. The tokens
you will use most are :method, :url, :status, :response-time, :total-time, :remote-addr, :remote-user,
:http-version, :user-agent, :referrer, :date, plus :req[header] and :res[header] for any header. You can
register your own with morgan.token(), and the callback receives the request and the response.
// access-log.mjs
import { createWriteStream } from 'node:fs';
import { randomUUID } from 'node:crypto';
import express from 'express';
import morgan from 'morgan';
const app = express();
// A token for the request ID that an earlier middleware assigned.
morgan.token('id', (req) => req.id);
// A token that records only the route pattern, never the query string.
morgan.token('route', (req) => req.route?.path ?? req.baseUrl);
app.use((req, res, next) => {
req.id = randomUUID();
res.set('X-Request-Id', req.id);
next();
});
// Development: a readable line on the console, but not for the health probe.
app.use(
morgan('dev', { skip: (req) => req.path === '/healthz' })
);
// Production-style: a custom format written to a file instead of the console.
const accessLog = createWriteStream(new URL('./access.log', import.meta.url), { flags: 'a' });
app.use(
morgan(':id :remote-addr :method :route :status :res[content-length] - :response-time ms', {
stream: accessLog,
})
);
app.get('/healthz', (req, res) => res.send('ok'));
app.get('/books/:id', (req, res) => res.json({ id: req.params.id, title: 'Dune' }));
app.listen(3000);
Source: Express middleware: morgan
The options that matter are:
-
stream, the writable stream that receives each line, standard output by default. Use it to write to a file, as above, or to pass lines to a logger. -
skip, a function(req, res)that returnstruefor requests that should not be logged, such as health checks and static assets, orres.statusCode < 400to log only failures. -
immediate, which logs on the request instead of the response. The line is written even if the process crashes mid-request, but the status, content length and response time are not available.
Two behaviours explain most surprises. The :url token is req.originalUrl, which includes the query string, so a
request such as /reset?token=... writes the token to the log; log a route pattern as the example does, or strip the
query. And morgan reads headers, so a token that prints :req[authorization] or :req[cookie] copies credentials into
the log. The format should be an allow-list of safe fields.
Response times with response-time
response-time adds an X-Response-Time header to every
response, measured from the moment the request reached the middleware until the headers were written. The measurement
therefore covers the work of the layers registered after it, so register it early.
// timing.mjs
import express from 'express';
import responseTime from 'response-time';
const app = express();
// Default: sets X-Response-Time: 12.345ms
app.use(responseTime());
// Alternatives, shown for comparison; use one of them:
// app.use(responseTime({ digits: 1, header: 'X-Elapsed', suffix: false }));
// Callback form: nothing is written to the response, you decide what to do with the number.
app.use(
responseTime((req, res, time) => {
if (time > 500) {
console.warn(`slow request: ${req.method} ${req.originalUrl} took ${time.toFixed(0)} ms`);
}
})
);
app.get('/books', async (req, res) => {
await new Promise((resolve) => setTimeout(resolve, 20));
res.json([{ id: '1', title: 'Dune' }]);
});
app.listen(3000);
The options are digits (decimal places, three by default), header (the header name, X-Response-Time by default) and
suffix (whether ms is appended, true by default). The callback form takes (req, res, time) and replaces setting
the header, which makes it a lightweight hook for slow-request warnings. The header tells the client about the server’s
processing time and nothing about the network; for a standard header that browsers display in their developer tools, use
Server-Timing, which you can set from the callback form. Exposing exact timings of sensitive operations to anyone who can see
the response can leak information about secrets, so consider keeping the header to internal environments.
Structured logging with pino and pino-http
Access lines like those of morgan are meant for people. Production systems are better served by structured logs, where
each event is one JSON object with a level, a timestamp and named fields, so a log platform can filter by status, user
or request ID without a regular expression. pino is a fast JSON logger for Node.js and
pino-http is its HTTP middleware; neither is part of Express, and both are
installed with npm install pino pino-http. The Express documentation lists logging libraries on its performance page
as the recommended alternative to calling console.log in production.
// logging.mjs
import { randomUUID } from 'node:crypto';
import express from 'express';
import pino from 'pino';
import { pinoHttp } from 'pino-http';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// Remove credentials before a line is written, wherever they appear in the object.
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
censor: '[Redacted]',
},
// Human-readable output only in development; plain JSON everywhere else.
transport: process.env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined,
});
const httpLogger = pinoHttp({
logger,
// Reuse a well-formed incoming ID or create one, and echo it to the client.
genReqId(req, res) {
const incoming = req.headers['x-request-id'];
const id = typeof incoming === 'string' && /^[\w-]{1,64}$/.test(incoming) ? incoming : randomUUID();
res.setHeader('X-Request-Id', id);
return id;
},
customLogLevel(req, res, err) {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
// Do not log the probe that runs every few seconds.
autoLogging: { ignore: (req) => req.url === '/healthz' },
customProps: () => ({ service: 'bookshelf-api' }),
});
const app = express();
app.use(httpLogger);
app.get('/healthz', (req, res) => res.send('ok'));
app.get('/books/:id', (req, res) => {
// req.log is a child logger that already carries the request ID.
req.log.info({ bookId: req.params.id }, 'loading book');
res.json({ id: req.params.id, title: 'Dune' });
});
app.get('/boom', async () => {
throw new Error('simulated failure');
});
// Error-handling middleware: log once, with the serialized error, then answer.
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
req.log.error({ err }, 'unhandled error');
res.status(500).json({ title: 'Internal Server Error', status: 500 });
});
app.listen(3000, () => logger.info('listening on port 3000'));
Source: pinojs/pino-http and Express: Performance best practices
A few details deserve attention:
-
pino-httpattachesreq.logandres.log, child loggers bound to the request, and emits one completion line per request with the status, response time and the serialized request and response. Usereq.logeverywhere inside a request so every line carries the same ID. -
redactis part ofpino, not ofpino-http, and it works on the paths of the object that is logged. The default serializers include request headers, which is exactly whyauthorizationandcookiehave to be listed. -
The
errkey is the one that pino serializes with the message, type and stack; use that key name for errors. -
pino-prettyis a development convenience, installed as a development dependency. Run it as a transport, as above, or pipe the process through it; do not use it in production, where the cost of formatting is wasted and the output is no longer JSON. -
Log at the right level.
errorfor failures that need attention,warnfor client mistakes you want to be able to count,infofor normal events anddebugfor detail you switch on temporarily withLOG_LEVEL=debug.
In Express, an error that reaches the default handler is written to standard error with its stack unless the env
setting is test; a custom error handler that logs through req.log, as above, replaces that output and
should be registered after all routes, as described in Error Handling.
Request IDs and correlation
A request ID turns a flood of lines into a story: every line written while serving one request carries the same identifier, the client receives it in a response header, and a support ticket can quote it. Three rules apply.
-
Accept an incoming ID only when it matches a strict pattern, as the examples do. A header copied unchecked into a log line can carry line breaks that forge entries (log injection), or an unbounded length.
-
Generate an ID with
randomUUID()fromnode:cryptowhen none is acceptable, and send it back asX-Request-Id. -
Forward the same ID on outgoing calls to other services, so their logs join yours. The W3C
traceparentheader is the standard for this when you use distributed tracing.
Passing the logger or the ID through every function argument is tedious. AsyncLocalStorage from node:async_hooks
keeps a value alive for the whole asynchronous call chain of a request, so deep code can log with the right ID without
receiving req:
// correlation.mjs
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import express from 'express';
import pino from 'pino';
const context = new AsyncLocalStorage();
const baseLogger = pino();
// Any module can call this; it falls back to the base logger outside a request.
export function log() {
return context.getStore()?.log ?? baseLogger;
}
const app = express();
app.use((req, res, next) => {
const id = randomUUID();
const requestLog = baseLogger.child({ reqId: id });
res.set('X-Request-Id', id);
// Everything called from next(), including awaited code, sees this store.
context.run({ id, log: requestLog }, next);
});
async function loadBooks() {
await new Promise((resolve) => setTimeout(resolve, 10));
log().info('books loaded from the database');
return [{ id: '1', title: 'Dune' }];
}
app.get('/books', async (req, res) => {
res.json(await loadBooks());
});
app.listen(3000);
What not to log
Logs are copied to more places than databases are: log platforms, backups, support tools and developers' terminals. Treat them as sensitive and log less than you think you need.
-
Never write credentials: passwords, session identifiers,
AuthorizationandCookieheaders,Set-Cookieresponses, API keys and tokens, reset links, or the query strings that contain them. -
Do not log request or response bodies by default. They hold personal data, card details and uploaded files. If you need a body to diagnose something, log selected fields, and only temporarily.
-
Minimise personal data (names, e-mail addresses, IP addresses, precise locations) and prefer an internal user ID. Regulations such as the GDPR treat IP addresses and identifiers as personal data and constrain retention.
-
Never log the full error object of a database or HTTP client when it embeds the connection string or the request that failed; log its type, message and code.
-
Do not let user input choose the log structure: use a logger that serializes values, and keep user data in fields, not concatenated into the message.
-
Do not leave
DEBUG=*set in production. The router output includes URLs and parameters.
Equally, avoid console.log as the production logger: it is synchronous when the output is a file or terminal, has no
levels, and produces lines that cannot be filtered. The Express performance guide recommends a library for this reason.
Express 4 versus Express 5
| Topic | Express 4 | Express 5 |
|---|---|---|
Router namespaces |
|
|
Rejected promises in handlers |
Not forwarded. A rejection needs a |
Forwarded to the error-handling middleware, so a single logging error handler sees async failures too. |
Default error output |
Stack written to standard error unless the |
The same. |
|
Work unchanged. |
Work unchanged; they are plain middleware or plain packages. |
Where the books are outdated
The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20. They handle diagnostics with the tools of their time, and several of those have changed:
| Older practice | Where it appears | Current behaviour |
|---|---|---|
Scattering |
Express 4-era tutorials in general |
Use the |
Debugging with the |
Node.js 0.x and early tutorials |
Removed. The inspector is built into Node.js: |
Restarting the process by hand, or with |
Express 4-era tutorials |
|
Using |
Express generator output and tutorials |
Keep |
Logging with Winston or Bunyan and hand-built per-request context. |
Node.js blueprints-era projects |
Both libraries still exist, but |
Mounting the whole Connect |
Express 3 and earlier material |
Removed from Express itself in version 4; its successor is the separate |
Treating |
Express generator output |
Still valid, but explicit variables such as |