Debugging Node with --inspect and DevTools
`node --inspect` opens a V8 Inspector WebSocket on 127.0.0.1:9229 that DevTools and VS Code attach to; `--inspect-brk` breaks before line one. Keep it on localhost — that port is remote code execution. For cheap logs use util.debuglog gated by NODE_DEBUG.
A service crashed on boot — a config value was undefined before the first request ever landed. The on-call engineer added node --inspect server.js, opened DevTools, set a breakpoint on the failing line, and restarted. The breakpoint never hit: by the time DevTools finished attaching, the process had already thrown and exited. They added more console.log calls, redeployed, waited for the next crash, repeated — an hour gone. The fix was one flag they didn’t know: --inspect-brk, which freezes the process before the first line of user code and waits for the debugger, so a startup bug can’t outrun the attach.
—inspect starts a WebSocket the debugger attaches to
node --inspect app.js doesn’t run a debugger — it starts one server. Node enables the V8 Inspector agent, which opens a WebSocket and prints a line like Debugger listening on ws://127.0.0.1:9229/<uuid>. By default it binds to 127.0.0.1:9229 — localhost only, one well-known port. Anything that speaks the Chrome DevTools Protocol then attaches to that socket: Chrome via chrome://inspect, the standalone DevTools window, or VS Code’s JavaScript debugger. The debugger is the client; your process is the server. That framing explains the next two facts that bite people.
First, attaching is asynchronous and takes real wall-clock time. --inspect lets your code run immediately, so a fast-failing startup bug is over before the client connects — exactly the hook. The fix is --inspect-brk, which enables the agent and breaks before the first line of user code, parking the process until a debugger attaches and tells it to continue. Use --inspect for a long-running server you’ll attach to mid-flight; use --inspect-brk whenever the bug is in startup, module top-level, or anything that runs once. (--inspect-wait is the middle ground: wait for an attach but don’t break.)
Second, the bind address is a deliberate choice. --inspect=0.0.0.0:9229 or --inspect=<public-ip> exposes the agent on the network — keep reading, because that single change is the difference between a debugger and a remote shell.
# attach mid-flight to a running server (localhost:9229)
node --inspect server.js
# freeze before line 1 and wait for the debugger — for startup bugs
node --inspect-brk server.js
# custom bind (still localhost here; see the security section before going wider)
node --inspect=127.0.0.1:9230 server.jsBreakpoints, the debugger statement, and source maps
Once attached, DevTools gives you the full toolkit: line breakpoints, conditional breakpoints (hit only when userId === 42), logpoints (print an expression without halting — a breakpoint that logs instead of pausing), step over/into/out, a live watch list, and a Console REPL evaluated in the paused frame’s scope, so you can inspect and even mutate locals at the breakpoint. A bare debugger; statement in your source is a programmatic breakpoint: it pauses if a debugger is attached and is a no-op otherwise.
The trap for TypeScript and any compiled/bundled code: the debugger sees the emitted JavaScript, so breakpoints and stack frames land in dist/app.js, not your src/app.ts. Run with --enable-source-maps so Node consumes the .js.map files and remaps stack traces — and the debugger’s line positions — back to the original source. Without it, a production stack trace points at minified line 1, column 9000, and your breakpoint sits in code you never wrote.
# emitted JS + source maps → stacks and breakpoints map back to .ts
node --enable-source-maps --inspect-brk dist/app.js▸Why this works
Why does the debugger run in a separate client at all, instead of being baked into the runtime? Because the V8 Inspector exposes the same Chrome DevTools Protocol the browser uses — so the exact DevTools you already know (Sources, Console, Memory, Profiler) work unchanged against a server process, and tools like VS Code, WebStorm, and chrome://inspect all speak one protocol to one WebSocket. The cost of that power is the security surface in the next section: a protocol that can evaluate arbitrary expressions in your process is, by definition, a code-execution channel.
util.debuglog: zero-cost logging without a debugger
Sometimes you don’t want to halt — you want a trace you can switch on in one environment and pay nothing for everywhere else. That’s util.debuglog(section). It returns a logging function that is a no-op unless the NODE_DEBUG environment variable names that section — when disabled it does no string formatting and writes nothing, so it’s effectively free to leave in hot paths. Enable it per-run with NODE_DEBUG=mysection, comma-separate several (NODE_DEBUG=db,http), or use wildcards (NODE_DEBUG=db*). Output goes to stderr tagged with the section and PID: DB 3245: query took 14ms. Node’s own core uses this exact mechanism — NODE_DEBUG=http,net,tls lights up the runtime’s internal traces.
import { debuglog } from "node:util";
const log = debuglog("db"); // no-op unless NODE_DEBUG=db
async function query(sql) {
const t = performance.now();
const rows = await pool.query(sql);
log("query took %dms (%d rows)", performance.now() - t, rows.length);
return rows;
}
// run: NODE_DEBUG=db node app.js → "DB 3245: query took 14ms (3 rows)"
// run: node app.js → silent, zero overheadThe skill is matching the tool to the moment. The table below is the senior’s decision grid.
| Approach | What it gives | Cost | When |
|---|---|---|---|
—inspect | Attach live; breakpoints, watch, REPL in scope | Manual attach; async — can miss fast failures | Long-running server, bug mid-flight |
—inspect-brk | Same, but pauses before line 1 | Blocks until a debugger attaches | Startup / module-top / one-shot bugs |
NODE_DEBUG + util.debuglog | Section-gated stderr trace, no halt | No-op when off; near-zero cost | Conditional tracing, safe to ship |
console.log | Always-on print, no setup | Always formats + writes; pollutes logs | Throwaway local probe only |
The failure mode: the inspector port is a remote shell
Here is the rule that turns a debugging tool into an incident: the inspector port is a remote-code-execution surface. The Chrome DevTools Protocol includes Runtime.evaluate, which runs arbitrary expressions inside your process. The Node docs are blunt: if the debugger is bound to a public IP or 0.0.0.0, any client that can reach that address can connect with no authentication and run arbitrary code on behalf of your process. There is no password, no token — reachability is authorization.
So two non-negotiables. Never bind the inspector to anything but localhost, and never leave --inspect on in production. The default 127.0.0.1 is safe precisely because only the local box can reach it; --inspect=0.0.0.0 throws that away. To debug a remote box, don’t widen the bind — tunnel localhost-to-localhost over SSH (ssh -L 9221:localhost:9229 user@host), so the port never touches the public network. A --inspect flag accidentally baked into a production container image, or a 0.0.0.0 bind on a host with a public interface, is a full RCE backdoor that no firewall rule inside the app can close.
Your service throws and exits during startup, before the first request. Why does plain `node --inspect` fail to catch it, and what catches it?
By default, what does `node --inspect app.js` bind to, and why does that default matter?
A function in a long-running production-like staging server intermittently returns wrong totals, but only for certain users. You want to inspect locals at the moment it goes wrong, without halting every request. Which approach fits best?
Order the steps to attach DevTools to a running script and pause it on a startup bug, safely:
- 1 Start the process with the inspector agent breaking before line 1: node --inspect-brk app.js
- 2 Node opens the V8 Inspector WebSocket on 127.0.0.1:9229 and prints the ws:// URL, parked before user code
- 3 Open chrome://inspect (or VS Code / DevTools) and attach to the listed 127.0.0.1:9229 target
- 4 Set breakpoints (line / conditional / logpoint) in the source; with --enable-source-maps they land in your .ts
- 5 Resume execution; the process runs to your breakpoint, where you inspect locals in the paused frame
- 01Why is leaving `--inspect` enabled in production (or binding it to 0.0.0.0) a remote-code-execution risk, and how do you debug a remote box safely instead?
- 02You're debugging a TypeScript service and your breakpoints land in dist/app.js, not your src .ts, and stack traces point at compiled line numbers. What's happening and what's the fix?
node --inspect does not run a debugger — it starts the V8 Inspector agent, a WebSocket server bound by default to 127.0.0.1:9229, and DevTools, VS Code, or chrome://inspect attach to it over the Chrome DevTools Protocol. Because attaching takes wall-clock time, plain --inspect can miss a fast-failing startup bug; --inspect-brk breaks before the first line of user code and waits for the debugger, so boot-time and one-shot bugs can’t outrun the attach. Once paused you get line, conditional, and logpoint breakpoints, stepping, a watch list, and a Console evaluated in the paused frame’s scope; debugger; is the same breakpoint in source. For compiled or TypeScript code, run with --enable-source-maps so stacks and breakpoints map back to the original .ts instead of dist. When you don’t want to halt, util.debuglog(section) gives a near-zero-cost trace that is a no-op unless NODE_DEBUG names the section — the same mechanism core uses for NODE_DEBUG=http,net. And the one rule that turns a tool into an incident: the inspector port runs Runtime.evaluate, i.e. arbitrary code, with no authentication, so keep it on localhost, tunnel over SSH for remote work, and never bind to 0.0.0.0 or ship --inspect to production. Now when you hit a startup crash that --inspect never caught, or spot 0.0.0.0:9229 in a container image, you know exactly what to reach for — and what to remove.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.