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

DEBUG environment variable

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.

debug package in your code

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.

diagnostics_channel

What does the HTTP server itself report, without wrapping it?

Observability libraries and low-level tracing of requests and responses.

morgan

One line per request: method, URL, status, time.

Access logs in development and simple deployments.

response-time

How long did the server take, visible to the client?

An X-Response-Time header for quick checks and browser developer tools.

pino and pino-http

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

The namespaces you will meet are:

Namespace What it prints Provided by

express:application

Application events such as settings defaults, the boot of the app, and the mounting of sub-applications.

Express

express:view

Which template file the view system looked up and where it found, or failed to find, it.

Express

router

The router’s handling of a request: entering the stack, trimming the mounted path, dispatching and finishing. In Express 4 this was express:router.

router package (Express 5)

router:layer

Each layer tried for a request: its path, whether it matched and the parameters it decoded. In Express 4 this was express:router:layer.

router package (Express 5)

router:route

Dispatch inside a single route: which method handlers run, and next('route') jumps. In Express 4 this was express:router:route.

router package (Express 5)

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

DEBUG_COLORS

Forces colours on or off. They are enabled by default when the output stream is a terminal.

DEBUG_HIDE_DATE

Removes the ISO date from lines when the output is not a terminal. Useful when another tool already timestamps them.

DEBUG_DEPTH

The object inspection depth used by the %o and %O formatters.

DEBUG_SHOW_HIDDEN

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'));

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

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.locals and 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);

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

Predefined formats

Format Output

combined

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.

common

The Apache common log format, combined without the referrer and user agent.

dev

Method, URL, status (coloured red for server errors, yellow for client errors, cyan for redirects, green for success), response time and content length.

short

Remote address, method, URL, HTTP version, status, content length and response time.

tiny

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);

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 returns true for requests that should not be logged, such as health checks and static assets, or res.statusCode < 400 to 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'));

A few details deserve attention:

  • pino-http attaches req.log and res.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. Use req.log everywhere inside a request so every line carries the same ID.

  • redact is part of pino, not of pino-http, and it works on the paths of the object that is logged. The default serializers include request headers, which is exactly why authorization and cookie have to be listed.

  • The err key is the one that pino serializes with the message, type and stack; use that key name for errors.

  • pino-pretty is 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. error for failures that need attention, warn for client mistakes you want to be able to count, info for normal events and debug for detail you switch on temporarily with LOG_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() from node:crypto when none is acceptable, and send it back as X-Request-Id.

  • Forward the same ID on outgoing calls to other services, so their logs join yours. The W3C traceparent header 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, Authorization and Cookie headers, Set-Cookie responses, 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

express:router, express:router:layer, express:router:route, all covered by DEBUG=express:*.

router, router:layer and router:route from the router package; list them besides express:*.

Rejected promises in handlers

Not forwarded. A rejection needs a try/catch or a wrapper before it reaches your error logging.

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 env setting is test.

The same.

morgan, response-time, debug, pino-http

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 console.log calls through handlers to follow a request.

Express 4-era tutorials in general

Use the DEBUG variable and debug namespaces, or the inspector with breakpoints, and a real logger in production.

Debugging with the node debug command, the --debug flag or the separate node-inspector package.

Node.js 0.x and early tutorials

Removed. The inspector is built into Node.js: node --inspect or --inspect-brk, then chrome://inspect or an editor.

Restarting the process by hand, or with nodemon, after every change while debugging.

Express 4-era tutorials

node --watch is built into Node.js 18 and later and combines with --inspect.

Using morgan('dev') or morgan('combined') as the only logging, in every environment.

Express generator output and tutorials

Keep morgan for development and simple access logs; use a structured logger such as pino-http where logs are processed by machines.

Logging with Winston or Bunyan and hand-built per-request context.

Node.js blueprints-era projects

Both libraries still exist, but pino is the faster default today, and AsyncLocalStorage replaces passing a request logger through every call.

Mounting the whole Connect logger middleware that earlier Express versions bundled.

Express 3 and earlier material

Removed from Express itself in version 4; its successor is the separate morgan package.

Treating app.get('env') and NODE_ENV as the only way to switch logging on and off.

Express generator output

Still valid, but explicit variables such as LOG_LEVEL and DEBUG give finer control without changing the environment name that libraries also read.