Debugging

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 Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. Debugging in Node.js is built on one mechanism: the V8 inspector, a small server inside the process that speaks the Chrome DevTools Protocol (CDP) over a WebSocket. Chrome DevTools, VS Code, JetBrains IDEs and the command-line node inspect client are all just different front ends for that one protocol, so once you understand how to switch the inspector on, where it listens and who may talk to it, every tool works the same way. The node:inspector module and the node inspect command-line debugger are Stability 2 (Stable); the new non-interactive probe mode of node inspect is Stability 1 (Experimental); network inspection is Stability 1 (Experimental) and the node:inspector helpers that feed it are Stability 1.1 (Active development); --enable-source-maps is stable while the helper functions in node:module are still Stability 1 (Experimental). The --trace-* flags and NODE_DEBUG are long-standing command-line and environment features that carry no stability index. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture; this page is about stopping a program and looking inside it. Measuring where the time goes is the subject of Profiling & Performance, finding leaked memory is in Memory & Leaks, and post-mortem evidence from production is in Diagnostic Reports, Logging & Observability.

The stability and version map

Table 1. APIs and flags covered on this page
API or flag Stability (Node.js 26 docs) Version notes

--inspect, --inspect-brk, --inspect-port, --inspect-publish-uid (CLI flags)

No stability index

--inspect since v6.3.0, --inspect-brk and --inspect-port since v7.6.0.

--inspect-wait

No stability index

Added in v22.2.0 and v20.15.0, so it works on all three lines covered here.

node:inspector and node:inspector/promises (open(), close(), url(), waitForDebugger(), Session)

2 Stable

inspector.open() returns a Disposable since v20.6.0; inspector.close() is available in worker threads since v18.10.0.

node inspect (interactive command-line debugger)

2 Stable

Replaces the legacy node debug client (the --debug and --debug-brk switches and the old V8 debugging protocol on port 5858), which the Learn guide says was deprecated as of Node.js 7.7.0; since Node.js 8 SIGUSR1 activates the Inspector API.

node inspect --probe (probe mode)

1 Experimental

Added in v26.1.0 and v24.16.0; not present on Node.js 22. --max-hit arrived in v26.4.0 and v24.19.0, --cond in v26.6.0 and v24.20.0.

--disable-sigusr1

No stability index (no longer experimental)

Added in v23.7.0 and v22.14.0; no longer experimental since v24.8.0 and v22.20.0.

--enable-source-maps

No stability index (no longer experimental)

Added in v12.12.0; no longer experimental since v15.11.0 and v14.18.0.

module.setSourceMapsSupport(), module.getSourceMapsSupport(), module.findSourceMap(), module.SourceMap

1 Experimental (the whole source-map section of node:module)

setSourceMapsSupport() and getSourceMapsSupport() added in v23.7.0 and v22.14.0; findSourceMap() and SourceMap in v13.7.0 and v12.17.0.

--trace-warnings, --trace-deprecation, --trace-uncaught, --trace-sync-io, --trace-exit, --trace-sigint

No stability index

--trace-warnings since v6.0.0, --trace-sync-io v2.1.0, --trace-uncaught v13.1.0, --trace-exit v13.5.0, --trace-sigint v13.9.0.

--stack-trace-limit (V8 option) and Error.stackTraceLimit

No stability index

Default is 10 frames; 0 disables stack collection.

NODE_DEBUG, NODE_DEBUG_NATIVE, util.debuglog()

util is 2 Stable

NODE_DEBUG since v0.1.32; util.debuglog().enabled since v14.9.0.

--experimental-network-inspection and the inspector.Network.* helpers

1 Experimental (flag); 1.1 Active development (helpers)

Flag added in v22.6.0 and v20.18.0; WebSocket events in the helpers added in v24.7.0.

--experimental-worker-inspection

1.1 Active development

Added in v24.1.0 and v22.17.0.

--allow-inspector (permission model)

1.0 Early development

Added in v25.0.0 and v24.12.0; there is no such flag on Node.js 22 (observed below).

How the inspector works

When you start Node.js with --inspect, the process opens a TCP server (by default on 127.0.0.1:9229) and registers itself as a debug target. A client first makes a plain HTTP request to learn the target’s address, then upgrades to a WebSocket whose URL ends with a random UUID, and from then on exchanges JSON messages: commands such as Debugger.setBreakpointByUrl or Runtime.evaluate, and events such as Debugger.paused. Everything a debugger can do, from stepping to evaluating an expression in a paused frame, is one of those messages, which is why the protocol is also the foundation for the profilers described in Profiling & Performance. The UUID is not a password in the cryptographic sense, but a client must know host, port and UUID to connect, and by default Node.js prints the full URL on stderr and lists it at the /json/list endpoint of the same port.

Starting the inspector on a free port and asking the target for its details
node --inspect=127.0.0.1:0 hang.js &
curl -s http://127.0.0.1:<port>/json/list
Output on Node.js 26 (the port, UUID and path differ on every run; the file URL is shortened here)
Debugger listening on ws://127.0.0.1:50175/c5025569-b627-4be5-8f65-8a7f42ecb830
For help, see: https://nodejs.org/learn/getting-started/debugging
[ {
  "description": "node.js instance",
  "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:50175/c5025569-...",
  "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:50175/c5025569-...",
  "faviconUrl": "https://nodejs.org/static/images/favicons/favicon.ico",
  "id": "c5025569-b627-4be5-8f65-8a7f42ecb830",
  "title": "hang.js",
  "type": "node",
  "url": "file:///project/hang.js",
  "webSocketDebuggerUrl": "ws://127.0.0.1:50175/c5025569-b627-4be5-8f65-8a7f42ecb830"
} ]

Node.js 24 and 22 print the same two lines and the same JSON shape. Port 0 asks the operating system for a free port, which is what makes the example safe to run next to other services and is the habit to adopt for anything automated; use the fixed default 9229 only when a human is going to type the address into a tool.

Turning the inspector on

The Learn guide and the CLI reference describe the same family of flags. They differ in what happens to your program while it waits for a client, and that difference decides which one to use.

Table 2. The three ways to start the inspector
Flag What happens to your code Use it when

--inspect[=[host:]port]

Listens and keeps running. The program starts immediately; a debugger that attaches later pauses it only at breakpoints.

You debug a long-running server and attach when something goes wrong.

--inspect-wait[=[host:]port]

Listens and waits for a debugger to attach before running any user code, but does not stop on the first line.

You need a client attached from the start but want your own breakpoints, not an automatic pause (typical for scripts that finish quickly).

--inspect-brk[=[host:]port]

Listens, waits for a debugger, then breaks on the first line of user code.

You want to step from the very beginning, for example to debug start-up code or module loading.

The next program prints one line. Run with each flag and a two-second alarm, it shows the difference without any debugger attached.

A program that prints one line, started with each flag
// hello.js
console.log('user code is running');
Run on Node.js 26, 24 and 22 with a two-second alarm and no debugger attached
$ node --inspect=127.0.0.1:0 hello.js
Debugger listening on ws://127.0.0.1:<port>/<uuid>
user code is running

$ node --inspect-wait=127.0.0.1:0 hello.js
Debugger listening on ws://127.0.0.1:<port>/<uuid>
(nothing else: the process is waiting for a debugger)

$ node --inspect-brk=127.0.0.1:0 hello.js
Debugger listening on ws://127.0.0.1:<port>/<uuid>
(nothing else: the process is waiting, and would pause on the first line once attached)

The flags take an optional [host:]port; other flags and variables that touch the inspector are summarised below.

Table 3. Other options that control the inspector
Option Meaning

--inspect-port=[host:]port

Sets the address to use when the inspector is activated later, for example by SIGUSR1 (below) or by inspector.open(). Port 0 picks a random free port.

--inspect-publish-uid=stderr,http

Chooses where the WebSocket URL is published: on stderr, at /json/list, or both (the default). Observed on Node.js 26: with --inspect-publish-uid=http the process prints nothing on stderr, so the URL must be read from the HTTP endpoint.

--disable-sigusr1

Removes the ability to start the inspector by sending SIGUSR1. Available on Node.js 22.14+, 24 and 26.

process.debugPort

The port used by the debugger when it is enabled. The API page shows it being assigned, and --inspect-port is the documented way to choose it from the command line.

NODE_OPTIONS

Can carry --inspect…​ for every Node.js process started from that environment, including children. A flag given on the command line overrides the same flag in NODE_OPTIONS.

NODE_INSPECT_RESUME_ON_START=1

Read by node inspect: run until the first breakpoint instead of breaking on the first line.

Switching the inspector on in a running process

You do not have to decide at start-up. On POSIX systems a Node.js process starts its inspector when it receives SIGUSR1; the signal does not exist on Windows. The address comes from --inspect-port (or the default 127.0.0.1:9229). A program can also open the inspector itself with inspector.open(), which is equivalent to --inspect=[[host:]port], and inspector.url() reports the active URL or undefined.

Activating the inspector with SIGUSR1 on a process that was not started with --inspect
node --inspect-port=127.0.0.1:0 hang.js &
kill -USR1 $!
Output on Node.js 26 (the same on 24 and 22; nothing was printed before the signal)
Debugger listening on ws://127.0.0.1:50175/ef33135d-6051-4046-a66d-97cc4fd293c5
For help, see: https://nodejs.org/learn/getting-started/debugging

Started with --disable-sigusr1, the same process stayed alive and printed nothing when it received SIGUSR1, on all three lines. The flag is therefore the way to make sure nobody can switch the inspector on from outside the process, which matters in the signal handling of a production service.

Opening the inspector from inside the program and talking to it
// open.js
import inspector, { Session } from 'node:inspector/promises';

console.log('before:', inspector.url());          // undefined: not listening yet
inspector.open(0, '127.0.0.1');                    // port 0 = pick a free port
console.log('after:', inspector.url()?.replace(/:\d+\//, ':<port>/').replace(/[0-9a-f-]{36}$/, '<uuid>'));

const session = new Session();
session.connect();                                  // same-thread session
const { result } = await session.post('Runtime.evaluate', {
  expression: 'process.version',
});
console.log('evaluated in-process:', result.value);

session.disconnect();
inspector.close();
console.log('closed:', inspector.url());
Output on Node.js 26 (24 and 22 print their own version on the fourth line)
before: undefined
Debugger listening on ws://127.0.0.1:50842/d4ac12c7-b063-4f7e-8bb3-c0b406bbcb62
For help, see: https://nodejs.org/learn/getting-started/debugging
after: ws://127.0.0.1:<port>/<uuid>
evaluated in-process: v26.10.0
closed: undefined

An in-process Session is for evaluating expressions and collecting profiles, as in the CPU and heap profiler examples of the node:inspector page. The API documentation warns against setting breakpoints through a same-thread session, because the paused program is the debugger itself; connect to the main thread from a worker with session.connectToMainThread(), or attach from outside over the WebSocket. inspector.waitForDebugger() blocks until a client has sent Runtime.runIfWaitingForDebugger, which is how you add the behaviour of --inspect-wait to a program that decides at run time whether it is being debugged.

Chrome DevTools

Chrome DevTools is the most capable front end, and the one to reach for first. There are two ways to attach, both from the Learn guide.

Table 4. Attaching Chrome DevTools (or Microsoft Edge) to a Node.js process
Method Steps

Built-in UI

Open chrome://inspect (edge://inspect in Edge), click Configure and make sure the target host and port are listed. The Node.js application should then appear in the Remote Target list.

Manual

Visit http://localhost:<inspect-port>/json/list, copy the devtoolsFrontendUrl value from the response and paste it into the address bar.

Once attached you get the Sources panel that you know from the browser, documented in the Chrome DevTools JavaScript debugging reference (call stack, scope, watch expressions, the console and an ignore list for skipping library code while stepping). The breakpoint types that make sense in Node.js are the line-of-code breakpoint, the conditional line-of-code breakpoint, the logpoint (which logs to the console without pausing), the exception breakpoint (pause on the line that throws a caught or uncaught exception) and the function breakpoint; the DOM, XHR and event-listener breakpoints of the browser documentation have no Node.js equivalent. The same window also hosts the profilers used in Profiling & Performance and Memory & Leaks.

What DevTools actually sends

It is worth seeing the protocol once, because it demystifies every front end and gives you a way to script a debugger. The next program is a complete, dependency-free CDP client: it starts a target with --inspect-brk, reads the WebSocket URL from stderr, connects with the global WebSocket class, waits for the automatic pause on the first line, resumes, waits for the debugger; statement inside price() and reads a local variable from the paused frame.

The target
// app.js - the target we want to debug
function price(book, discount) {
  const total = book.price * (1 - discount);
  debugger; // pauses only when a debugger is attached
  return Math.round(total * 100) / 100;
}

console.log(price({ title: 'Dune', price: 12.5 }, 0.2));
A minimal CDP client using only built-ins
// cdp.mjs
import { spawn } from 'node:child_process';

const child = spawn(process.execPath, ['--inspect-brk=127.0.0.1:0', 'app.js'], {
  stdio: ['ignore', 'inherit', 'pipe'],
});

// Read the WebSocket URL that the inspector prints on stderr.
const url = await new Promise((resolve) => {
  child.stderr.setEncoding('utf8');
  child.stderr.on('data', (text) => {
    const match = /ws:\/\/\S+/.exec(text);
    if (match) resolve(match[0]);
  });
});

// Speak JSON-RPC over the global WebSocket client.
const ws = new WebSocket(url);
let nextId = 0;
const pending = new Map();
const pauses = [];
ws.onmessage = ({ data }) => {
  const message = JSON.parse(data);
  if (message.id) pending.get(message.id)?.(message.result);
  else if (message.method === 'Debugger.paused') pauses.shift()?.(message.params);
};
const send = (method, params = {}) =>
  new Promise((resolve) => {
    const id = ++nextId;
    pending.set(id, resolve);
    ws.send(JSON.stringify({ id, method, params }));
  });
const nextPause = () => new Promise((resolve) => pauses.push(resolve));

await new Promise((resolve) => (ws.onopen = resolve));
await send('Debugger.enable');
const first = nextPause();
await send('Runtime.runIfWaitingForDebugger');

// --inspect-brk pauses before the first line of user code.
let paused = await first;
console.log('paused at start:', paused.reason);

// Resume; the debugger statement pauses us again inside price().
const second = nextPause();
await send('Debugger.resume');
paused = await second;
const frame = paused.callFrames[0];
console.log('paused in:', frame.functionName, 'line', frame.location.lineNumber + 1);
const { result } = await send('Debugger.evaluateOnCallFrame', {
  callFrameId: frame.callFrameId,
  expression: 'total',
});
console.log('total =', result.value);

await send('Debugger.resume');
ws.close();
Output (identical on Node.js 26, 24 and 22, apart from the "Debugger listening" lines that stderr forwards)
paused at start: Break on start
paused in: price line 4
total = 10
10

Line numbers in the protocol are zero-based, which is why the client adds one. The final 10 is the program’s own output, printed after the last resume; running node app.js without any debugger prints just that line, because the debugger; statement is ignored when no debugger is attached.

Conditional breakpoints and logpoints are just expressions

The conditional breakpoint and the logpoint of the Sources panel are not special features of Node.js: the front end sets an ordinary breakpoint with a condition expression. The condition is evaluated in the paused frame; if it is falsy, execution continues. A logpoint is the same thing with a condition that logs and then evaluates to false, so the program never actually stops. The client below sets both on a small loop and prints what the conditional breakpoint saw.

The target, a loop over book prices
// loop.js
const books = [12.5, 8, 30, 4.25];
let total = 0;
for (const price of books) {
  total += price;
}
console.log('total', total);
Setting a conditional breakpoint and a logpoint with Debugger.setBreakpointByUrl
// breakpoints.mjs
import { spawn } from 'node:child_process';

const child = spawn(process.execPath, ['--inspect-brk=127.0.0.1:0', 'loop.js'], {
  stdio: ['ignore', 'inherit', 'pipe'],
});
const url = await new Promise((resolve) => {
  child.stderr.setEncoding('utf8');
  child.stderr.on('data', (t) => {
    const m = /ws:\/\/\S+/.exec(t);
    if (m) resolve(m[0]);
  });
});

const ws = new WebSocket(url);
let id = 0;
const waiting = new Map();
const send = (method, params = {}) =>
  new Promise((resolve) => {
    waiting.set(++id, resolve);
    ws.send(JSON.stringify({ id, method, params }));
  });
const evaluate = async (frame, expression) =>
  (await send('Debugger.evaluateOnCallFrame', { callFrameId: frame.callFrameId, expression })).result.value;

ws.onmessage = async ({ data }) => {
  const m = JSON.parse(data);
  if (m.id) return waiting.get(m.id)?.(m.result);
  if (m.method !== 'Debugger.paused') return;
  const [top] = m.params.callFrames;
  if (m.params.reason === 'Break on start') return send('Debugger.resume');
  console.log(`conditional breakpoint hit: price=${await evaluate(top, 'price')} total=${await evaluate(top, 'total')}`);
  send('Debugger.resume');
};

await new Promise((resolve) => (ws.onopen = resolve));
await send('Debugger.enable');
// Line numbers are 0-based in the protocol: line 5 of loop.js is lineNumber 4.
await send('Debugger.setBreakpointByUrl', {
  urlRegex: 'loop\\.js$',
  lineNumber: 4,
  condition: 'price > 10',                      // a conditional breakpoint
});
await send('Debugger.setBreakpointByUrl', {
  urlRegex: 'loop\\.js$',
  lineNumber: 6,
  condition: "console.log('logpoint: total is', total), false", // a logpoint never pauses
});
await send('Runtime.runIfWaitingForDebugger');
child.on('exit', () => ws.close());
Output on Node.js 26, 24 and 22
conditional breakpoint hit: price=12.5 total=0
conditional breakpoint hit: price=30 total=20.5
logpoint: total is 54.75
total 54.75

The practical consequence is that a condition or logpoint can contain any JavaScript, including side effects, and that it runs inside your process on every hit, so a heavy expression on a hot line slows the program down.

The VS Code debugger

VS Code has built-in debugging for Node.js, and it is the front end most day-to-day work uses. It speaks the same protocol, so everything above applies; what it adds is project integration. The editor offers three ways in, listed on the official page: auto attach (the debugger attaches automatically to Node.js processes started from the integrated terminal, with smart, always and onlyWithFlag modes), the JavaScript debug terminal (created with the "Debug: Create JavaScript Debug Terminal" command, where every Node.js process you run is debugged), and a launch.json file in .vscode/ that either launches your program or attaches to one that is already running.

A .vscode/launch.json with launch, local attach and container attach configurations
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Bookshelf",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/src/server.js",
      "envFile": "${workspaceFolder}/.env",
      "skipFiles": ["<node_internals>/**"]
    },
    {
      "name": "Attach to local process",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "restart": true
    },
    {
      "name": "Attach to container",
      "type": "node",
      "request": "attach",
      "address": "127.0.0.1",
      "port": 9229,
      "localRoot": "${workspaceFolder}",
      "remoteRoot": "/app"
    }
  ]
}

This file was checked to be valid JSON, and the attribute names were checked against the VS Code documentation, but the debugging sessions themselves were not driven from VS Code for this page (the protocol exchanges they rely on are the ones run above).

Table 5. launch.json attributes used above and a few more that you will meet
Attribute Meaning

program

The entry point to launch. ${workspaceFolder} is the folder you opened in VS Code.

skipFiles

Glob patterns of files the debugger steps over, so stepping does not wander into Node.js internals; <node_internals>/** is the pattern for the core library.

envFile

Loads variables from a file instead of putting them in launch.json. Keep secrets out of the committed configuration, as in CLI Options, Environment & Config File.

port, address

Where an attach configuration connects. Port 9229 is the default port of --inspect and --inspect-brk, as the VS Code page notes.

restart

Reconnects automatically when the target restarts, which suits node --watch development.

localRoot, remoteRoot

Map paths between your workspace and a remote file system (a container, another machine) so that breakpoints bind to the editable local files.

stopOnEntry, continueOnAttach

Break when the program launches; or continue if the process is paused on attach. continueOnAttach is the one to use with --inspect-brk.

outFiles, resolveSourceMapLocations

Where the generated JavaScript and its source maps live, for transpiled projects (see Source maps).

processId

Attach to an already running process by sending SIGUSR1 first. Do not also set a port.

Other tools are listed on the Learn page, which names Visual Studio 2017 and later, JetBrains WebStorm and other JetBrains IDEs (a Node.js debug configuration uses the inspector by default), the Eclipse IDE with the Wild Web Developer extension, and the chrome-remote-interface library for scripting. They all connect to the same endpoint, so a flag that works for one works for all.

The command-line debugger: node inspect

node inspect is a small CLI client for the same inspector. It is not a full debugger, but it needs no browser or editor, which makes it the tool for a bare server, a container shell or an SSH session. It starts your script in a child process under --inspect, attaches to it and gives you a debug> prompt. Besides node inspect script.js it accepts host:port to attach to a process that is already listening and -p <pid> to attach by process id (the client sends SIGUSR1, so on POSIX it can attach to a process that was not started in debug mode).

An interactive session on app.js (condensed: blank lines and the source listing echoed after sb(4) were removed)
$ node inspect app.js
< Debugger listening on ws://127.0.0.1:9229/<uuid>
< For help, see: https://nodejs.org/learn/getting-started/debugging
connecting to 127.0.0.1:9229 ... ok
< Debugger attached.
Break on start in app.js:1
> 1 // app.js - the target we want to debug
  2 function price(book, discount) {
  3   const total = book.price * (1 - discount);
debug> sb(4)
debug> cont
break in app.js:4
  2 function price(book, discount) {
  3   const total = book.price * (1 - discount);
> 4   debugger; // pauses only when a debugger is attached
  5   return Math.round(total * 100) / 100;
  6 }
debug> exec total
10
debug> exec book.title
'Dune'
debug> bt
#0 price app.js:4:2
#1 (anonymous) app.js:8:12
#2 run node:internal/modules/esm/module_job:569:24
debug> watch("discount")
debug> next
step in app.js:5
Watchers:
  0: discount = 0.2

  3   const total = book.price * (1 - discount);
* 4   debugger; // pauses only when a debugger is attached
> 5   return Math.round(total * 100) / 100;
  6 }
debug> .exit

The session was run on Node.js 26 with the commands piped in; on Node.js 24 and 22 the same commands stopped at the same line and exec total printed 10. The port in the first line is the default 9229: node inspect uses it unless you pass --port, so two sessions on one machine need different ports. Attaching by pid worked too: node inspect -p <pid> printed "Debugger listening", connected, evaluated process.pid in the target and printed "Debugger ending" on exit.

Table 6. Common node inspect commands
Command Effect

cont, c; next, n; step, s; out, o; pause

Continue, step over, step into, step out, pause running code.

setBreakpoint(line), sb(…​); sb('script.js', 1, 'num < 4'); clearBreakpoint(…​), cb(…​)

Set a breakpoint on the current line, a line, a function, or a line of another file with an optional condition. A breakpoint in a module that is not loaded yet works and prints a warning.

exec expr, p expr; repl

Evaluate one expression in the paused frame, or open a REPL in the debugged script’s context (Ctrl+C leaves it).

watch(expr), unwatch(expr), watchers

Evaluate expressions at every stop and print them before the source listing.

backtrace, bt; list(5); scripts; version

Print the call stack, list source with context, list loaded scripts, show the V8 version.

run, restart, kill

Run, restart or kill the debugged script.

profile, profileEnd, profiles; takeHeapSnapshot()

Record CPU profiles and write heap snapshots from the prompt; see Profiling & Performance and Memory & Leaks.

Pressing Enter repeats the previous command. NODE_INSPECT_RESUME_ON_START=1 makes the session run until the first debugger; statement or breakpoint instead of breaking on the first line.

Probe mode: printf debugging without editing the code

Probe mode turns a debugging session into a one-line command. You name a source location and an expression; Node.js starts your script under the inspector, evaluates the expression every time execution reaches that location, and prints one report when the program ends. It is Stability 1 (Experimental), it only launches a new process from an entry-point script (it cannot attach), and it exists on Node.js 26 and Node.js 24 (24.16.0 and later) but not on Node.js 22, where the flag is rejected. It is also designed for tools and coding agents, because --json produces a structured report.

Probing a loop for the value of total, only when price is greater than 10
node inspect --probe loop.js:5 --expr 'total' --cond 'price > 10' loop.js
Output on Node.js 26 and 24 (paths shortened; Node.js 22 prints "bad option: --probe")
Hit 1 at file:///project/loop.js:5:3
  total = 0
Hit 2 at file:///project/loop.js:5:3
  total = 20.5
Completed

The same command with --json printed (on Node.js 26) a single line starting {"v":2,"probes":[...],"results":[...]}, with one hit event per evaluation and a final terminal event such as completed. The --cond, --max-hit and --json runs were executed; the remaining rows come from the API page:

Table 7. Probe mode options and behaviour
Option or rule Behaviour

--probe <file>:<line>[:<col>] then --expr <expr>

A strict pair. The file is matched as a path suffix against every loaded script, so utils.js:10 binds to every utils.js that is loaded; give a fuller path to narrow it.

--cond <expr>

Optional, written after its pair; the probe records a hit only when the expression is truthy at that location. A condition that throws counts as false.

--max-hit <n>

Optional and per probe. In a run with --max-hit 2 on the loop above, the report contained the first two hits (price = 12.5, price = 8) and Completed; per the API page the probed process carries on running.

--json, --preview, --timeout=<ms>, --port=<port>

Global options: structured output (schema version 2), property previews for objects, a wall-clock deadline (default 30000 ms), and the inspector port (default 0, a random port).

Probing a let or const at its declaration line

Produces a ReferenceError recorded as an [error] line (temporal dead zone); probe a later line instead.

Missing a probe and exit status

A probe that is never reached is reported as missed; probe_failure and probe_timeout end the session with a non-zero exit code. Invalid arguments exit with an error and no report.

When the child needs its own Node.js flags, separate them with --, as in node inspect --probe app.js:10 --expr user — --no-warnings app.js.

The debugger statement

debugger; is a standard JavaScript statement, not a Node.js feature: it is a breakpoint written into the source. With a debugger attached (DevTools, VS Code or node inspect) execution pauses there; with none attached it does nothing, which is why the app.js target above printed 10 when run plainly and paused when run under the inspector. That is convenient for a bug that appears only after a long run: put the statement where you want to look, start the process with --inspect, attach when the process reaches it. The cost is that you must not forget to delete it; a linter rule such as no-debugger (see ESLint & Prettier) catches it before commit.

For a program that starts without any flag but should wait on a debugger; statement only when asked, combine it with inspector.open() and inspector.waitForDebugger() (shown earlier) behind an environment variable of your own.

Debugging a test

Debug a single test file the way you debug any script: a file that uses node:test is an ordinary program, so node --inspect-brk test/app.test.js waits for a debugger and pauses before the first line; this was checked on Node.js 26. The test runner is the subtle case: the CLI reference says --test cannot be combined with the inspector, but on Node.js 26, 24 and 22 the combination started and printed a warning instead (observed): "Using the inspector with --test forces running at a concurrency of 1", with a pointer to the inspectPort option. That option of run() sets the inspector port of each test child process (a nullish value gives each its own port, counting up from the primary’s process.debugPort), and it is ignored when isolation is 'none'. Treat the whole-suite combination as supported-by-observation only, and prefer debugging one file.

Source maps

Debuggers and stack traces show positions in the JavaScript that actually runs. When that JavaScript is generated (bundled, minified or transpiled from TypeScript), the positions mean nothing to you. A source map is a JSON file, referenced by a //# sourceMappingURL= comment at the end of the generated file, that maps positions in the output back to the original sources. Node.js supports the ECMA-426 source map format, and there are two separate consumers of it: the debugger front ends (DevTools and VS Code read the map themselves, so breakpoints and stepping work in your .ts files with no Node.js flag), and the runtime, which needs --enable-source-maps before it rewrites the stack traces of errors. The example bundles two tiny TypeScript files with esbuild (installed in a scratch project; the same idea applies to tsc --sourceMap) and throws from the first one.

src/isbn.ts
export function checkDigit(isbn: string): number {
  const digits = isbn.replaceAll('-', '');
  if (digits.length !== 13) {
    throw new RangeError(`ISBN must have 13 digits, got ${digits.length}`);
  }
  return Number(digits[12]);
}
src/main.ts
import { checkDigit } from './isbn.ts';

interface Book {
  title: string;
  isbn: string;
}

const book: Book = { title: 'Dune', isbn: '978-0-441-17271' };
console.log(checkDigit(book.isbn));
Bundling with a source map, then running with and without --enable-source-maps
npx esbuild src/main.ts --bundle --platform=node --format=esm --sourcemap --outfile=dist/main.js
node dist/main.js
node --enable-source-maps dist/main.js
Output on Node.js 26, 24 and 22 (only the internal frames differ; paths shortened to /project)
$ node dist/main.js
file:///project/dist/main.js:5
    throw new RangeError(`ISBN must have 13 digits, got ${digits.length}`);
          ^
RangeError: ISBN must have 13 digits, got 12
    at checkDigit (file:///project/dist/main.js:5:11)
    at file:///project/dist/main.js:12:13
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)

$ node --enable-source-maps dist/main.js
/project/src/isbn.ts:4
    throw new RangeError(`ISBN must have 13 digits, got ${digits.length}`);
          ^
RangeError: ISBN must have 13 digits, got 12
    at checkDigit (/project/src/isbn.ts:4:11)
    at <anonymous> (/project/src/main.ts:9:13)
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)

The second trace points at isbn.ts line 4 and main.ts line 9, the lines you wrote, instead of positions in the bundle. The flag has costs and limits that the CLI reference spells out: it caches source maps and makes a best effort; reading Error.stack becomes slower, so a program that builds many stack traces should measure before enabling it in production; and overriding Error.prepareStackTrace can stop the mapping unless your override calls and returns the original function. Setting the flag in NODE_OPTIONS is allowed, which is the usual way to enable it for every process of a service.

Enabling source maps from code, with the options that the flag does not expose
// boot.js
import { setSourceMapsSupport, getSourceMapsSupport } from 'node:module';

setSourceMapsSupport(true, { nodeModules: false, generatedCode: false });
console.log(getSourceMapsSupport());

await import('./dist/main.js'); // loaded after the call, so its map is parsed
Output on Node.js 26, 24 and 22 (then the mapped stack trace as above)
[Object: null prototype] {
  enabled: true,
  nodeModules: false,
  generatedCode: false
}

setSourceMapsSupport() has the same effect as the flag, plus two options: nodeModules (also map files inside node_modules, default false) and generatedCode (map eval and new Function code, default false). Only modules loaded after the call have their maps parsed, so the documentation recommends the command-line flag. module.findSourceMap(path) returns the SourceMap for a file, and NODE_V8_COVERAGE also turns on source map parsing. These functions are Stability 1 (Experimental) even though the flag is not, so keep calls to them behind a small wrapper.

TypeScript run directly needs no source map

Since Node.js strips types by replacing them with whitespace, the line and column numbers of the running code already equal those of the .ts file. The Node.js TypeScript documentation says so explicitly, and it was visible when node src/main.ts was run on Node.js 26, 24 and 22: the trace pointed at src/isbn.ts:4 without --enable-source-maps and without producing any map. The flag matters again only when you compile (esbuild, tsc, SWC) or bundle. See TypeScript in Node.js and the compiler options in TSConfig & Compiler Options.

A debugger front end that cannot fetch the map itself (for example because the map is served by a build step) can be given the resource: inspector.NetworkResources.put(url, content) answers the front end’s Network.loadNetworkResource request, and needs the flag --experimental-inspector-network-resource (Stability 1.1, added in v24.5.0 and v22.19.0). It is documented but was not run for this page.

The --trace-* flags

The --trace-* flags change what Node.js prints when something happens that normally leaves no trail: a warning with no origin, a hidden exit, a synchronous call on a hot path. They are for investigation, not for production defaults, and each is a one-line change you can try without touching the code.

--trace-warnings and --trace-deprecation

By default a process warning is one line and says nothing about where it was raised. --trace-warnings adds the stack trace for every warning (deprecations included) and --trace-deprecation only for deprecations. The program below triggers a real deprecation, DEP0005 (the Buffer() constructor, an application-level runtime deprecation since v10.0.0), and a custom warning.

A deprecation and a custom warning
// warn.js
import { Buffer } from 'node:buffer';

function legacyCode() {
  return new Buffer(8); // DEP0005: runtime deprecation
}
legacyCode();
process.emitWarning('catalogue is stale', { code: 'BOOKSHELF_STALE' });
Plain run, then with --trace-warnings, on Node.js 26 (24 and 22 differ only in internal line numbers)
$ node warn.js
(node:74998) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), ...
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:74998) [BOOKSHELF_STALE] Warning: catalogue is stale

$ node --trace-warnings warn.js
(node:74999) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. ...
    at showFlaggedDeprecation (node:buffer:238:11)
    at new Buffer (node:buffer:318:3)
    at legacyCode (file:///project/warn.js:5:10)
    at file:///project/warn.js:7:1
(node:74999) [BOOKSHELF_STALE] Warning: catalogue is stale
    at file:///project/warn.js:8:9

With --trace-deprecation the deprecation got its stack but the custom warning was still one line, followed by the hint to use --trace-warnings. Three related flags control what happens instead of just printing: --throw-deprecation turns a deprecation into a thrown error (run on Node.js 26 it raised DeprecationWarning from showFlaggedDeprecation and ended the process, which makes it a good CI setting to fail on use of deprecated APIs), --no-deprecation silences deprecations, and --pending-deprecation also reports deprecations that are still documentation-only. The --disable-warning=<code-or-type> flag silences one warning by code or type and has been Stability 2 (Stable) since v26.7.0. In your own code, process.on('warning', …​) receives every warning object, which is the place to route them to a logger.

--trace-uncaught

An uncaught Error already carries the stack from where it was created. When you throw something that is not an Error, or an error that was created elsewhere, you lose where it was thrown. --trace-uncaught prints that second stack under a "Thrown at:" heading. The CLI reference warns that enabling it may affect garbage collection negatively, so it is an investigation flag.

Throwing a plain object leaves no stack behind
// uncaught.js
function save(isbn) {
  if (isbn.length !== 13) throw { code: 'E_ISBN', isbn };
}

setTimeout(() => save('123'), 10);
Without and with --trace-uncaught, on Node.js 26 (24 and 22 identical apart from timer line numbers)
$ node uncaught.js
file:///project/uncaught.js:3
  if (isbn.length !== 13) throw { code: 'E_ISBN', isbn };
                          ^
{ code: 'E_ISBN', isbn: '123' }

Node.js v26.10.0

$ node --trace-uncaught uncaught.js
file:///project/uncaught.js:3
  if (isbn.length !== 13) throw { code: 'E_ISBN', isbn };
                          ^
{ code: 'E_ISBN', isbn: '123' }
Thrown at:
    at save (file:///project/uncaught.js:3:27)
    at file:///project/uncaught.js:6:18
    at listOnTimeout (node:internal/timers:685:17)
    at processTimers (node:internal/timers:618:7)

Node.js v26.10.0

The fix for the code is to throw Error objects (or subclasses with a code), as described in Errors & Error Handling and in JavaScript exception handling; the flag is how you find the offender in code you do not control.

--trace-sync-io

A Node.js server stays fast only while the event loop is free (see Don’t Block the Event Loop). --trace-sync-io prints a stack trace whenever synchronous I/O is detected after the first turn of the event loop, which is exactly the allowance for start-up code: reading a config file synchronously at load is fine, doing it in a request handler is not.

One sync read at start-up (ignored) and one later (reported)
// sync.js
import { readFileSync } from 'node:fs';

const config = readFileSync(new URL(import.meta.url)); // before the first turn: ignored

setTimeout(() => {
  const again = readFileSync(new URL(import.meta.url)); // later: reported
  console.log(config.length === again.length);
}, 10);
Output of node --trace-sync-io sync.js on Node.js 26 (shortened; 24 and 22 differ only in internal line numbers)
(node:94911) WARNING: Detected use of sync API
    at openSync (node:fs:778:18)
    at readFileSync (node:fs:624:35)
    at file:///project/sync.js:7:17
    at listOnTimeout (node:internal/timers:685:17)
    at processTimers (node:internal/timers:618:7)

(node:94911) WARNING: Detected use of sync API
    at tryStatSync (node:fs:520:25)
    ...
true

One readFileSync() produced four warnings in this run (observed: the top frames were openSync, tryStatSync, readSync and closeSync), all pointing at the same line of your code, so read the first frame that belongs to you. Nothing was reported for the read before the first turn.

--trace-exit and --trace-sigint

Two flags answer the questions "who is ending my process?" and "where is it stuck?". --trace-exit prints a stack trace whenever the environment is exited proactively, that is by process.exit(); --trace-sigint prints the JavaScript stack when the process receives SIGINT, even if the event loop is blocked.

A helper that quietly calls process.exit()
// exit.js
import { setTimeout as sleep } from 'node:timers/promises';

function shutdownHelper(code) {
  process.exit(code); // buried in "library" code
}

await sleep(10);
shutdownHelper(3);
Output of node --trace-exit exit.js on Node.js 26 (24 and 22 print the same frames from your code)
(node:94914) WARNING: Exited the environment with code 3
    at exit (node:internal/process/per_thread:241:13)
    at shutdownHelper (file:///project/exit.js:5:11)
    at file:///project/exit.js:9:1
A busy loop to interrupt
// spin.js
function spin() { for (;;) { /* never yields */ } }
spin();
Output after sending SIGINT to node --trace-sigint spin.js (Node.js 26)
KEYBOARD_INTERRUPT: Script execution was interrupted by `SIGINT`
    at spin (file:///project/spin.js:2:19)
    at file:///project/spin.js:3:1
    at run (node:internal/modules/esm/module_job:569:25)

For the shutdown side of this story see Process, Signals & Exit Codes.

Stack depth and other trace flags

--stack-trace-limit=<n> (or Error.stackTraceLimit in code) sets how many frames V8 keeps per error; the default is 10 and 0 disables collection. On a 31-call recursive function, one run printed 10 frames by default and 35 (the 31 calls plus module-loading frames) with --stack-trace-limit=50, so raise it when the interesting frame is deeper than the default shows (async stack traces through await are handled by V8 and need no flag).

Table 8. Other flags worth knowing
Flag What it does

--trace-require-module=mode

Prints where require() loads an ES module (all or no-node-modules). Added in v23.5.0, v22.13.0 and v20.19.0; see ESM & CommonJS Interop.

--trace-env

Prints accesses to process.env (added in v23.4.0 and v22.13.0), with --trace-env-js-stack and --trace-env-native-stack for stacks.

--trace-tls

Prints TLS packet trace information to stderr, for connection problems; see HTTPS, TLS & HTTP/2.

--trace-promises

Present in node --help on 26, 24 and 22, so the book-era habit still works, but it is not on the CLI reference page as of 2026-10-04 (observed). It prints a line for every promise created and resolved, including Node.js internals, so the output is large.

--abort-on-uncaught-exception

Aborts instead of exiting so that the operating system writes a core file for post-mortem analysis with lldb, gdb or mdb. --report-uncaught-exception writes a diagnostic report instead; see Diagnostic Reports, Logging & Observability.

NODE_DEBUG and util.debuglog

NODE_DEBUG is a comma-separated list of core module names that should print internal debug information to stderr. The names are those of the internal sections, such as fs, net, tls and http. The same mechanism is available to your own code through util.debuglog(section), which returns a logging function that is a no-op unless the section name appears in NODE_DEBUG. It supports a trailing * wildcard, and .enabled tells you whether the section is on, to guard expensive formatting. The line is prefixed with the upper-cased section name and the process id.

A server that logs on its own bookshelf section
// debuglog.js
import { createServer, get } from 'node:http';
import { debuglog } from 'node:util';

const log = debuglog('bookshelf');

const server = createServer((req, res) => {
  log('request %s %s', req.method, req.url);
  res.end('ok');
});
server.listen(0, '127.0.0.1', () => {
  const { port } = server.address();
  get({ port, host: '127.0.0.1', path: '/books' }, (res) => {
    res.resume();
    res.on('end', () => server.close());
  });
});
Output on Node.js 26, 24 and 22 (only the pid differs)
$ node debuglog.js
$ NODE_DEBUG=bookshelf node debuglog.js
BOOKSHELF 7806: request GET /books
$ NODE_DEBUG='book*' node debuglog.js
BOOKSHELF 8676: request GET /books
$ NODE_DEBUG=http node debuglog.js
HTTP 7810: createConnection 127.0.0.1:50601:
HTTP 7810: http createConnection should use proxy for 127.0.0.1:50601: false
HTTP 7810: sockets 127.0.0.1:50601: 1 1
HTTP 7810: outgoing message end.
(node:7810) Warning: Setting the NODE_DEBUG environment variable to 'http' can expose sensitive data (such as passwords, tokens and authentication headers) in the resulting log.
...

Three things were observed beyond the documentation. First, NODE_DEBUG=http prints a warning that the log can contain passwords, tokens and authentication headers, so never switch it on in a shared log pipeline. Second, NODE_DEBUG=http,net produced 57 lines for a single local request, so combine sections sparingly. Third, the built-in fetch() is traced under the sections fetch and undici, not http: with NODE_DEBUG=fetch a request logged "connecting", "sending request" and "received response to GET …​ HTTP 200" lines. NODE_DEBUG_NATIVE is the documented C counterpart (a list of core C modules). These are diagnostic outputs, not an API, and their wording can change between releases.

Network inspection

Chrome DevTools has a Network panel, and Node.js can feed it. With --experimental-network-inspection (Stability 1, Experimental; added in v22.6.0 and v20.18.0) the outgoing HTTP requests of the process are announced to the inspector as CDP Network.* events, so a connected DevTools window lists requests, headers, timing and responses of your server’s calls to other services. The next example avoids the browser: it starts a target with --inspect-wait and the flag, subscribes to Network.enable over the WebSocket, and prints the events that a Network panel would draw. The target makes one fetch() to a local server.

The target
// net-target.js
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify({ title: 'Dune' }));
}).listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const res = await fetch(`http://127.0.0.1:${port}/books/1`);
  console.log(await res.json());
  server.close();
});
A client that prints the Network events
// net-client.mjs
import { spawn } from 'node:child_process';

const child = spawn(
  process.execPath,
  ['--inspect-wait=127.0.0.1:0', '--experimental-network-inspection', 'net-target.js'],
  { stdio: ['ignore', 'inherit', 'pipe'] },
);
const url = await new Promise((resolve) => {
  child.stderr.setEncoding('utf8');
  child.stderr.on('data', (t) => {
    const m = /ws:\/\/\S+/.exec(t);
    if (m) resolve(m[0]);
  });
});

const ws = new WebSocket(url);
ws.onmessage = ({ data }) => {
  const m = JSON.parse(data);
  if (!m.method?.startsWith('Network.')) return;
  const p = m.params;
  const detail = p.request ? `${p.request.method} ${p.request.url.replace(/:\d+/, ':<port>')}`
    : p.response ? `HTTP ${p.response.status} ${p.response.mimeType}`
    : '';
  console.log(m.method, detail);
};
await new Promise((resolve) => (ws.onopen = resolve));
ws.send(JSON.stringify({ id: 1, method: 'Network.enable' }));
ws.send(JSON.stringify({ id: 2, method: 'Runtime.runIfWaitingForDebugger' }));
child.on('exit', () => ws.close());
Output on Node.js 26, 24 and 22
Network.requestWillBeSent GET http://127.0.0.1:<port>/books/1
Network.responseReceived HTTP 200 application/json
Network.loadingFinished
{ title: 'Dune' }

Two observations from running it. Without --experimental-network-inspection the same client received no Network.* events at all, so the flag is required, and a request made with http.get() instead of fetch() produced the same three events, so both client APIs are covered. The node:inspector module also exposes the other direction (Stability 1.1): inspector.Network.requestWillBeSent(), responseReceived(), loadingFinished(), loadingFailed(), dataReceived() and dataSent() (and, since v24.7.0, WebSocket events) let a library broadcast requests that Node.js itself does not instrument, such as those of a custom client, to every connected front end. For TLS problems (--trace-tls) and for a proxy-based view of traffic, see Fetch & HTTP Clients and HTTPS, TLS & HTTP/2.

Workers, child processes and other targets

A worker thread has its own V8 isolate, so it needs its own inspector. Node.js 24.1.0 and 22.17.0 added --experimental-worker-inspection (Stability 1.1, Active development) so that DevTools can list and debug workers of a process started with --inspect; this is taken from the CLI reference and was not run. A Session created in a worker can reach the main thread with connectToMainThread(). Child processes are separate processes and so are separate targets: a flag put in NODE_OPTIONS reaches all of them, and a fixed port collides. With NODE_OPTIONS='--inspect=127.0.0.1:9333' a parent that spawned one child printed "Debugger listening" for the parent and, for the child, "Starting inspector on 127.0.0.1:9333 failed: address already in use" (observed on Node.js 26), so use --inspect-port=0 or give each process its own port. The patterns are described in Worker Threads and Child Processes.

Debugging remotely and safely

The inspector is a remote-code-execution endpoint by design: a client that can reach it can evaluate arbitrary JavaScript with the privileges of the process. The Node.js documentation is blunt about this, and the rules that follow are the ones in the Learn guide and the CLI reference.

Table 9. The threat model of an open inspector
Exposure What to know

Bound to a public address (--inspect=0.0.0.0)

Any client that can reach the port can connect and run code. The CLI reference calls --inspect=0.0.0.0 insecure unless the port is protected by a firewall. The default binding is 127.0.0.1.

Bound to 127.0.0.1 (the default)

Still open to every local process and every local user that can open the port; the guide says this is by design, so that local debuggers can attach conveniently.

A web page in a browser

The same-origin policy blocks the initial HTTP request that obtains the session id, and Node.js checks that the Host header is an IP address or exactly localhost to resist DNS rebinding. In a check on Node.js 26, a request with Host: debug.example.com to the IP address was answered with HTTP 400, while Host: localhost:<port> returned 200 (observed).

Switched on later by a signal

SIGUSR1 starts the inspector in a running process. Anyone who can signal the process can enable it, so use --disable-sigusr1 where that is not wanted.

Under the permission model

With --permission, using the inspector is denied unless --allow-inspector is passed. Observed: on Node.js 26 and 24, inspector.open() under --permission failed with "Access to this API has been restricted. Use --allow-inspector to manage permissions." and worked with the flag; on Node.js 22 the same failure occurred but the flag is rejected as a "bad option", so there is no way to allow it.

The guidance that follows from this is short. Never bind the inspector to a public interface, and never leave --inspect in a production start-up command, the NODE_OPTIONS of an image or a Kubernetes manifest. When you must debug something that is not on your machine, tunnel to the loopback address of the remote host instead of opening the port; the Learn guide recommends an SSH tunnel and gives an example for illustrative purposes. The command from the guide has this shape and was not run for this page, because the machine used here has no SSH server: ssh -L 9221:localhost:9229 user@remote.example.com, after which a local DevTools or VS Code attaches to localhost:9221, and the tunnel’s encryption and authentication stand in front of the inspector. Attach by IP address or by localhost, since hostnames are rejected by the Host check.

Containers are the common case where the process must listen on 0.0.0.0 inside the container, because the container’s loopback is not reachable from the host. The safe pattern is to let the process listen on all interfaces inside the container and publish the port only to the host’s loopback address.

Debugging a Node.js process in a container without exposing the port to the network
docker run -d --rm --name bookshelf-debug \
  -p 127.0.0.1:9229:9229 \
  -v "$PWD":/app:ro -w /app \
  node:26-slim node --inspect=0.0.0.0:9229 hang.js
Output of docker logs bookshelf-debug and docker port bookshelf-debug, then a request to the published port
$ docker logs bookshelf-debug
Debugger listening on ws://0.0.0.0:9229/e4c00a80-7803-454c-984e-0f6c99698af0
For help, see: https://nodejs.org/learn/getting-started/debugging
$ docker port bookshelf-debug
9229/tcp -> 127.0.0.1:9229
$ curl -s http://127.0.0.1:9229/json/version
{
  "Browser": "node.js/v26.10.0",
  "Protocol-Version": "1.1"
}

The -p 127.0.0.1:9229:9229 form is what keeps the container’s debug port off the network; plain -p 9229:9229 publishes it on every host interface. In VS Code, attach with the container configuration shown earlier (localRoot and remoteRoot map your files to /app). The Docker side of this is covered in Debugging Containers, and NestJS projects have the same workflow with framework-specific tips in NestJS Debugging & Devtools.

Production is a different problem

The Learn page on live debugging says that using a live debugger in production is usually not an option: you have limited access to the machine and you cannot interrupt a process that handles business-critical work. Breakpoints stop the whole event loop, so a paused production server stops serving every request. In production, collect evidence without stopping anything: structured logs with correlation ids, a diagnostic report on failure, a CPU profile or heap snapshot taken on demand, and NODE_DEBUG or --trace-* flags on a single canary instance. Those techniques are the subject of Diagnostic Reports, Logging & Observability, Profiling & Performance and Production Deployment. Probe mode, as noted, only launches a new process, so it is for reproducing a problem, not for inspecting the live one.

Common problems

Table 10. Symptoms and fixes
Symptom Likely cause and fix

"Starting inspector on 127.0.0.1:9229 failed: address already in use"

Another process (or a child that inherited --inspect through NODE_OPTIONS) owns the port. Use --inspect=127.0.0.1:0 or --inspect-port=0 and read the printed URL.

The debugger attaches but breakpoints never hit

The code ran before you attached (use --inspect-wait or --inspect-brk), or the breakpoint is in a generated file whose source map is missing (see Source maps; in VS Code check outFiles and resolveSourceMapLocations).

Breakpoints in a TypeScript file turn grey in VS Code

The map does not match the output. Rebuild with --sourcemap, and check that the sourceMappingURL comment points at a file that exists.

DevTools lists the process, but the connection fails when using a hostname

The Host header check allows only IP addresses and localhost. Use the IP address, or an SSH tunnel to localhost.

Stack traces point into a bundle

Run with --enable-source-maps (and set it in NODE_OPTIONS for services), or run the TypeScript sources directly so that no map is needed.

A server pauses and stops answering when you attach

Expected: a breakpoint stops the event loop. Use a logpoint or probe mode to observe without stopping, and do not attach to production.

node inspect hangs at "connecting to 127.0.0.1:9229 …​ failed to connect"

Nothing is listening on the port the client assumes. node inspect -p <pid> and node inspect host:port use the default port unless you pass --port; a target started with --inspect-port=0 has a random port, so use the address that it printed.

--test with --inspect is slow

The runner forces a concurrency of 1 when the inspector is on; debug a single test file instead, or set inspectPort when you use run().

What changed recently

Table 11. Debugging-related changes by version (from the official API pages)
Version Change

v26.7.0

--disable-warning is stable.

v26.6.0 (v24.20.0)

--cond <expr> added to probe mode.

v26.4.0 (v24.19.0)

--max-hit <n> added to probe mode.

v26.1.0 (v24.16.0)

Probe mode (node inspect --probe, Experimental) added.

v25.5.0

--experimental-storage-inspection and the inspector.DOMStorage events (Stability 1.1) added.

v25.0.0 and v24.12.0

--allow-inspector added to the permission model.

v24.8.0 and v22.20.0

--disable-sigusr1 is no longer experimental.

v24.7.0

WebSocket events (webSocketCreated, webSocketClosed, handshake response) added to the inspector.Network helpers.

v24.5.0 and v22.19.0

--experimental-inspector-network-resource and inspector.NetworkResources.put added (serve source maps to a front end).

v24.1.0 and v22.17.0

--experimental-worker-inspection added.

v23.7.0 and v22.14.0

--disable-sigusr1 added; module.setSourceMapsSupport() and module.getSourceMapsSupport() added.

v22.6.0 and v20.18.0

--experimental-network-inspection and the first inspector.Network helpers added.

v22.2.0 and v20.15.0

--inspect-wait added.

Where the books are outdated

Two O’Reilly books touch this topic, and both predate parts of the current tooling. Efficient Node.js by Samer Buna (2025) introduces node inspect, --inspect and --inspect-brk, attaching Chrome DevTools through chrome://inspect, the debugger; statement, and the list of --trace-* flags printed by node --help, and also explains NODE_DEBUG. Node.js Projects by Jonathan Wexler (2025) covers the VS Code debugger in an appendix, as an alternative to console.log(). They remain a sound starting point; these are the places where today’s behaviour goes further, or where the habit they describe should change.

Table 12. Book topics and current practice
Topic in the books Current practice (checked 2026-10-04)

Debugging starts with --inspect or --inspect-brk only

--inspect-wait (v22.2.0 and later) waits for the debugger without stopping on the first line, which is the better choice for short scripts, and --inspect-port plus SIGUSR1 attaches to a process that was not started in debug mode. Probe mode (v24.16.0 and v26.1.0) adds printf-style debugging with no source edits.

The chrome://inspect page is the way in, with the profiler and memory tools beside it

Still correct, but start the inspector on port 0 for automation, never on a public address, and know that the Host header check rejects hostnames. The security model (a debugger is remote code execution, tunnel instead of exposing) is in the official guide; the debugging passages of the books that were consulted do not discuss it.

The node --help listing of --trace-* flags

The set is stable, but the listing includes --trace-promises, which is absent from the CLI reference (observed), and omits flags that came later, such as --trace-env (v22.13.0 and v23.4.0) and --trace-require-module (v22.13.0 and v23.5.0). Node.js 22 also still lists a deprecated --trace-atomics-wait.

--enable-source-maps appears only as one line of the node --help listing

Transpiled and bundled code is the norm: use --enable-source-maps, or module.setSourceMapsSupport(); with native type stripping (stable since v24.12.0 and v25.2.0) a .ts file needs no map at all.

NODE_DEBUG is shown with core modules such as http

It still works the same way, but NODE_DEBUG=http now prints a warning that the log can contain secrets (observed), and fetch() is traced under fetch and undici, not http.

VS Code debugging is a single launch button

The debugger has auto attach, a JavaScript debug terminal, skipFiles, localRoot and remoteRoot for containers, and envFile; keep secrets out of launch.json.

Process restarts use external watchers such as nodemon

node --watch is stable; combine it with the VS Code attach configuration and "restart": true.

An external HTTP client (Postman) is used to exercise your own API

That covers requests into your server. For requests that your process makes, --experimental-network-inspection (v22.6.0 and later) shows fetch() and http client requests in the DevTools Network panel, with the caveat that it is Stability 1 (Experimental).

References