Debugging Node.js
The Node.js Inspector
Section titled “The Node.js Inspector”Node ships a built-in inspector based on the Chrome DevTools Protocol. Start it with the --inspect flag and connect any CDP-compatible debugger — Chrome DevTools, VS Code, or WebStorm.
# Start with inspector (process continues running, connects on demand)node --inspect src/app.js
# Start AND pause on the very first line of your codenode --inspect-brk src/app.jsAfter launching, open chrome://inspect in Chrome, click “Open dedicated DevTools for Node”, and you get full breakpoints, call stacks, scope inspection, heap snapshots, and CPU profiling — exactly the same panel you use for browser debugging.
The inspector always listens on port 9229 by default (a different port only if you pass --inspect=<port>), and it speaks the same Chrome DevTools Protocol used by browser DevTools — WebSocket in, JSON commands and events, whichever client (Chrome, VS Code, WebStorm) just happens to be the one connected:
flowchart LR App["node --inspect app.js"] -->|"opens"| Port["ws://127.0.0.1:9229/uuid"] Port -->|"Chrome DevTools Protocol (JSON over WebSocket)"| Client["Any CDP client"] Client --> Chrome["chrome://inspect"] Client --> VSCode["VS Code debugger"] Client --> CLI["node inspect (built-in CLI client)"]
node inspect — the built-in command-line client
Section titled “node inspect — the built-in command-line client”You do not need a browser or an editor at all: node inspect <file> starts the target process and a debugger REPL in the same terminal, using the same protocol:
node inspect src/app.js< Debugger listening on ws://127.0.0.1:9229/<uuid>< For help, see: https://nodejs.org/en/docs/inspectorconnecting to 127.0.0.1:9229 ... okdebug> Break on start in src/app.js:1> 1 console.log('line1'); 2 debugger; 3 console.log('line2');debug> contType cont (or c) to run to the next breakpoint/debugger statement, next/n to step over, step/s to step into, out/o to step out, watch('expr') to add a watch expression, and repl to drop into a REPL scoped to the current stack frame. It is slower to navigate than a GUI, but it needs nothing beyond a terminal — useful over SSH or in a container with no forwarded port.
Connecting with VS Code
Section titled “Connecting with VS Code”VS Code detects Node inspector automatically. Add a .vscode/launch.json:
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Debug App", "program": "${workspaceFolder}/src/app.js", "runtimeArgs": ["--inspect-brk"] }, { "type": "node", "request": "attach", "name": "Attach to running node", "port": 9229 } ]}Press F5 to launch or Ctrl+Shift+P → “Attach to Node Process” to connect to an already-running --inspect server.
The debugger statement
Section titled “The debugger statement”Insert debugger; anywhere in your JavaScript. When the inspector is attached, execution pauses at that exact line — like setting a breakpoint in code:
function calculateTotal(items) { let total = 0; for (const item of items) { debugger; // execution pauses here when inspector is open total += item.price * item.qty; } return total;}When no inspector is connected, debugger is silently ignored. Remove or gate debugger statements before deploying to production.
console methods beyond console.log
Section titled “console methods beyond console.log”The console object has far more methods than most developers use:
// Tabular output — great for arrays of objectsconst users = [ { name: 'Alice', age: 30, role: 'admin' }, { name: 'Bob', age: 25, role: 'user' },];console.table(users);// Prints a formatted ASCII table with columns: name, age, role
// Deep inspection — prints non-enumerable properties and prototype chainconst map = new Map([['key', 'value']]);console.dir(map, { depth: 3, colors: true });
// Timing a code blockconsole.time('database-query');// ... run expensive operation ...console.timeEnd('database-query');// Prints: database-query: 42.183ms
// Count invocationsfunction hit() { console.count('hit');}hit(); // hit: 1hit(); // hit: 2hit(); // hit: 3
// Group related outputconsole.group('User details');console.log('name: Alice');console.log('role: admin');console.groupEnd();
// Conditional breakpoint-style assertionconsole.assert(1 === 2, 'Math is broken'); // prints assertion errorconsole.assert(1 === 1, 'This never prints');console.trace() prints the current call stack without stopping execution — useful for answering “who called this?” without attaching a debugger at all:
Needs the Node.js runtime — open in StackBlitz to run.
This one has no verified-output panel for the same reason as the earlier live demo: the stack trace includes file paths and line numbers that are only meaningful (and only identical) on the machine that ran it — not something to freeze into a single “verified” snapshot.
Inspecting deep objects and long stack traces
Section titled “Inspecting deep objects and long stack traces”console.log’s default formatter (util.inspect under the hood) stops descending into nested objects after depth 2 — deeper levels collapse to [Object]. Pass a custom depth when you need more:
Output (verified on Node 24)
{ a: { b: { c: [Object] } } }
{ a: { b: [Object] } }
{
a: { b: { c: { d: 1 } } }
}--stack-trace-limit controls how many frames Error.prototype.stack captures (the V8 default is 10). Lower it in a script that logs huge stacks from a deep recursive call, raise it when you need more context than 10 frames give you:
node --stack-trace-limit=2 -e "function a(){b()} function b(){c()} function c(){throw new Error('x')} try{a()}catch(e){console.log(e.stack.split('\n').length)}"3With the limit at 2, e.stack has 3 lines total: the Error: x header plus exactly 2 stack frames — c and b — a is cut off.
Tracing warnings and uncaught throws to their source
Section titled “Tracing warnings and uncaught throws to their source”Two flags turn a one-line warning or crash message into a full stack trace pointing at where it was actually created or thrown:
node --trace-warnings depwarn.mjslegacy(node:12345) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead. at showFlaggedDeprecation (node:buffer:195:11) at new Buffer (node:buffer:278:3) at file:///path/to/depwarn.mjs:1:11Without --trace-warnings, Node prints only the one-line warning and a hint to re-run with the flag — exactly the line you’d otherwise have to guess the cause of. --trace-uncaught does the same for exceptions specifically when the throw site differs from where the Error object was originally created (for example, an error built early and thrown later from a setTimeout callback):
node --trace-uncaught trace-uncaught-demo.mjsfile:///path/to/trace-uncaught-demo.mjs:6 throw err; ^
Error: created early, thrown later at file:///path/to/trace-uncaught-demo.mjs:2:13Thrown at: at file:///path/to/trace-uncaught-demo.mjs:2:13
Node.js v24.13.0The extra Thrown at: block only appears with the flag — normally you only get the stack from where the Error was constructed, which can be a different line entirely from where it was actually thrown.
NODE_DEBUG — built-in module tracing
Section titled “NODE_DEBUG — built-in module tracing”Several core modules (http, net, timers, module, cluster, tls) print internal diagnostic lines when you set NODE_DEBUG to their name:
NODE_DEBUG=http node server-and-client.mjsHTTP 12345: createConnection localhost:54321:HTTP 12345: sockets localhost:54321: 1 1HTTP 12345: outgoing message end.(node:12345) Warning: Setting the NODE_DEBUG environment variable to 'http' can expose sensitive data (such as passwords, tokens and authentication headers) in the resulting log.HTTP 12345: SERVER new http connectionNode itself warns about this: NODE_DEBUG=http logs request/response internals, which in a real application can include authentication headers and other sensitive data. Use it locally to understand connection reuse and keep-alive behavior; never enable it against traffic carrying real credentials.
uncaughtException and unhandledRejection — the real default behavior
Section titled “uncaughtException and unhandledRejection — the real default behavior”Both crash the process by default — Node considers an exception nobody caught, or a rejection nobody handled, to mean the program’s state can no longer be trusted:
// @expect-throw uncaught sync exception crashes by defaultthrow new Error('sync boom');// @expect-throw unhandled rejection crashes by default (has since Node 15)Promise.reject(new Error('boom'));You can listen for either — but listening does not mean it becomes safe to keep running. It means you decide what “safe” is:
Output (verified on Node 24)
before reject
after scheduling rejection
unhandled rejection: async boomSetting process.exitCode (rather than calling process.exit() from inside the handler) lets any pending I/O or cleanup finish, while still making sure the process ultimately exits non-zero so CI, a process manager, or an on-call engineer notices something went wrong.
Why “just add a handler and keep going” is dangerous
Section titled “Why “just add a handler and keep going” is dangerous”Output (verified on Node 24)
tick 1
swallowed: corrupt state at tick 2 -- process kept running, state may now be inconsistent
tick 3The interval callback that threw at counter === 2 never got to run its own logic past the throw — whatever it was supposed to do on that tick (update a counter, release a lock, finish writing a file) simply did not happen, yet the process carries on as if nothing happened. The Node.js documentation is explicit about this: after an uncaught exception, the “correct use” of the event is to perform synchronous cleanup and then let the process exit — not to resume normal operation. An uncaughtException handler is for logging and a graceful shutdown, not a global try/catch.
Practical debugging workflow
Section titled “Practical debugging workflow”# 1. Add --inspect-brk to pause before any code runsnode --inspect-brk src/server.js
# 2. Open chrome://inspect → click your target
# 3. Set breakpoints in the Sources panel, or hit the existing debugger statement
# 4. Step through: F10 (step over), F11 (step into), F8 (resume)
# 5. Inspect variables in the Scope panel or hover over them
# 6. Use the Console panel to run expressions in the current scopeDebugging with nodemon (watch + inspect)
Section titled “Debugging with nodemon (watch + inspect)”# Auto-restart AND keep inspector open on changesnpx nodemon --inspect src/app.js
# With ts-node for TypeScriptnpx nodemon --exec "node --inspect --loader ts-node/esm" src/app.tsMental Model
Section titled “Mental Model”Every debugging tool on this page answers the same question — “what is actually happening, right now, in this process?” — at a different level of intrusiveness: console.* methods are cheap, always-on, and change nothing about execution; --trace-warnings/--trace-uncaught/NODE_DEBUG add detail to output that would happen anyway; --inspect/--inspect-brk actually pause and let you step; and uncaughtException/unhandledRejection handlers are the last line of defense once something has already gone wrong. Reach for the cheapest tool that answers your question.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
console.log/console.trace | Zero setup, works everywhere including production logs | No breakpoints, no live variable inspection, easy to forget and leave in code |
node --inspect + DevTools/VS Code | Full breakpoints, step-through, live scope inspection, heap snapshots | Requires a GUI client and, for a remote/containerized process, port forwarding |
node inspect (CLI) | Works over SSH, no forwarded port beyond the shell itself | Slower to navigate, no visual variable tree |
Handling uncaughtException/unhandledRejection | A chance to log context and shut down cleanly instead of an abrupt crash | Handling it wrong (resuming normal operation) hides real bugs and risks corrupted state — the safe move is still to exit |
Common Misconceptions
Section titled “Common Misconceptions”- “An unhandled promise rejection just prints a warning, it doesn’t crash the process” — fact: since Node 15, an unhandled rejection terminates the process by default, exactly like an uncaught synchronous exception.
- “Adding a
process.on('uncaughtException', ...)handler makes it safe to keep running” — fact: the callback that threw stopped executing at thethrow— whatever it still had to do did not happen. The documented safe use of the handler is synchronous cleanup followed by exit, not resuming normal operation. - “console.log’s default depth shows the whole object” — fact:
util.inspect(whichconsole.loguses) stops at depth 2 by default; deeper levels print as[Object]unless you pass{ depth: null }or a higher number. - “—trace-warnings and —trace-uncaught do the same thing” — fact:
--trace-warningsadds a stack to process warnings (deprecations, etc.);--trace-uncaughtadds a second stack — where the exception was actually thrown — for uncaught exceptions whose throw site differs from where theErrorwas constructed.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a
process.on('uncaughtException', ...)orprocess.on('unhandledRejection', ...)handler just log and continue, with noprocess.exit()/process.exitCodeand no shutdown of open resources (servers, DB connections, timers)? That is exactly the “swallow and keep going” anti-pattern — the safe default is to clean up and exit. - Does debugging code leave
console.trace()or verboseNODE_DEBUG-style logging active on a path that also handles real user data? Stack traces and debug logs can leak internals or credentials into production logs. - Is a
debugger;statement present in code heading toward a commit or deploy? It’s inert without an attached inspector, but it is also a sign the code was never cleaned up after a debugging session. - Does error-handling code assume
err.stackalways has full context, without accounting for--stack-trace-limitor the possibility theErrorwas created far from where it was thrown (exactly what--trace-uncaughtexists to reveal)?
🐛 Spot the bug
import http from 'node:http';
const server = http.createServer((req, res) => {res.end('ok');});
process.on('uncaughtException', (err) => {console.error('caught an error, continuing:', err.message);});
server.listen(3000);Show the answer
This handler logs the error and returns — there is no process.exit(), no server.close(), nothing that stops the process. Whatever request handler was running when the exception was thrown never finished (the response may never be sent, and the connected client just hangs until its own timeout). Every future request is served by a process whose internal state is now unknown: was a database transaction left open? A lock never released? A counter left half-updated? The Node.js docs are explicit that the correct use of uncaughtException is synchronous cleanup followed by letting the process exit — not swallowing the error and serving more traffic. The fix: log with full context, close the server to stop accepting new connections, finish or abort in-flight work, and call process.exit(1) (or set process.exitCode = 1 and let pending work drain) so an orchestrator restarts the process into a known-good state.