Debugging Node.js

Debugging is the process of finding out why your Node.js program isn’t doing what you expect, and Node gives you far more than console.log for that job. Because Node runs on V8, the same JavaScript engine that powers Chrome, it ships with a built-in debugger that speaks the Chrome DevTools Protocol — so you can set real breakpoints, step through code, and inspect scope and the call stack from a browser or your editor, not just print statements. This lesson covers the whole toolbox: the debugger statement, the --inspect and --inspect-brk flags, Chrome DevTools and VS Code, and the mistakes that make Node bugs harder to find than they need to be.

Overview: How Debugging Works in Node.js

Every debugging technique in Node ultimately answers one question: what is the call stack and variable state at the moment something goes wrong? There are three broad ways to get that answer, and understanding all three lets you pick the fastest one for the bug in front of you.

The first is print debugging: console.log(), console.error(), console.table(), and console.trace() write directly to stdout/stderr. This costs nothing to set up and is often enough for a quick check, but it only shows you what you thought to print in advance, and it doesn’t let you pause execution or interactively poke at nested objects.

The second, and the one this lesson focuses on, is the built-in inspector. Node embeds the same V8 Inspector Protocol that Chrome DevTools use. When you start Node with --inspect or --inspect-brk, the process opens a WebSocket server (by default on 127.0.0.1:9229) and prints a ws:// URL to the terminal. Any client that speaks the protocol — Chrome at chrome://inspect, or an editor like VS Code — can attach to that URL, set breakpoints, step line by line, watch expressions, and inspect the full scope chain, all without adding a single console.log to your source.

The third is the debugger statement. Anywhere you write debugger; in your code, execution pauses at that exact line if — and only if — an inspector client is currently attached. If nothing is attached, it’s a complete no-op and costs nothing at runtime, which is why it’s technically safe to commit. Still, treat it like a temporary tool: leaving it in reviewed, shipped code confuses future readers who wonder why execution might stop there.

A key distinction between the two inspector flags: --inspect starts your process and runs it immediately while the inspector attaches in the background whenever a client shows up, so you’ll miss anything that happens before you connect. --inspect-brk does the same but pauses on the very first line of your program and waits for a debugger client to connect before anything else executes. Use --inspect-brk when the bug happens during module load or early startup; use plain --inspect when the bug happens later and you just want to attach whenever it’s convenient.

Because Node’s I/O is asynchronous, a debugger also has to deal with async stack traces — the call stack you see when an await resolves is not the same physical stack that made the original call, since the event loop ran other work in between. Modern V8 reconstructs a readable “async” stack trace in most cases, so stepping through await calls in the inspector still shows a sensible chain of frames rather than an opaque, truncated callback trace.

Syntax

The core building blocks of Node debugging are a couple of CLI flags and one statement:

Flag / Statement What it does
node --inspect file.js Starts the process normally and opens an inspector WebSocket (default 127.0.0.1:9229) that a client can attach to at any time.
node --inspect-brk file.js Same as --inspect, but pauses execution on the first line of the program until a debugger client attaches.
node --inspect=host:port file.js Chooses a specific interface and port for the inspector instead of the default.
debugger; A statement placed anywhere in your code. Pauses execution there if an inspector is attached; otherwise it does nothing.
chrome://inspect Chrome’s built-in page for discovering and attaching to any local Node process started with --inspect or --inspect-brk.
node --trace-warnings file.js Prints a full stack trace for process warnings (like deprecation notices) instead of a one-line summary.
--stack-trace-limit=N A V8 flag controlling how many stack frames Error.stack captures (default 10).

Examples

Example 1: Finding a Bug with console.log and Fixing It

Here’s a small program that computes an average but gives a nonsensical answer:

function average(numbers) {
  const total = numbers.reduce((sum, n) => sum + n, 0);
  return total / numbers.length;
}

const scores = [10, 20, 30, undefined, 40];
console.log('Average:', average(scores));

Output:

Average: NaN

Adding a console.log(numbers) inside average quickly reveals the array contains an undefined entry, which poisons the sum the moment reduce adds it. The fix is to filter out non-numeric values (and to fail loudly if nothing usable is left, instead of silently returning NaN):

function average(numbers) {
  const clean = numbers.filter((n) => typeof n === 'number' && !Number.isNaN(n));
  if (clean.length === 0) {
    throw new Error('average() requires at least one numeric value');
  }
  const total = clean.reduce((sum, n) => sum + n, 0);
  return total / clean.length;
}

const scores = [10, 20, 30, undefined, 40];
console.log('Average:', average(scores));

Output:

Average: 25

This is print debugging at its best: fast, no setup, good enough for a bug you can localize in one function. For anything bigger — nested objects, a bug that only shows up after ten iterations, or logic spread across several files — a real breakpoint is faster.

Example 2: Setting a Breakpoint with debugger and –inspect-brk

Save this as debug-me.js:

function findDuplicate(items) {
  const seen = new Set();
  for (const item of items) {
    debugger;
    if (seen.has(item)) {
      return item;
    }
    seen.add(item);
  }
  return null;
}

const result = findDuplicate([3, 5, 8, 5, 9]);
console.log('Duplicate:', result);

Run it with the inspector paused from the start:

node --inspect-brk debug-me.js

Node prints a message with a ws://127.0.0.1:9229/... URL and waits. Open Chrome and navigate to chrome://inspect, click “Configure” if needed to confirm localhost:9229 is watched, then click “inspect” under Remote Target. DevTools opens paused on the very first line of the file. Click Resume once to let the module load, and execution will stop again at every debugger; line inside the loop, letting you inspect seen and item in the Scope panel on each iteration and step forward with the Step Over button. VS Code offers the same experience natively: open the file, press F5 with a Node launch configuration, and it attaches an inspector session automatically without you touching the command line at all.

Example 3: Catching Errors You Didn’t Expect

Some of the hardest bugs to “see” are errors that never reach a catch block at all. This program guards against both an unhandled promise rejection and an uncaught synchronous exception:

const fs = require('node:fs/promises');

async function loadConfig(path) {
  const raw = await fs.readFile(path, 'utf8');
  return JSON.parse(raw);
}

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection:', reason);
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught exception:', err);
  process.exit(1);
});

loadConfig('./config.json')
  .then((config) => console.log('Config loaded:', config))
  .catch((err) => console.error('Failed to load config:', err.message));

Output (if config.json is missing):

Failed to load config: ENOENT: no such file or directory, open './config.json'

Here the .catch() already handles the expected failure (a missing file) cleanly. The process.on('unhandledRejection', ...) and process.on('uncaughtException', ...) listeners are a safety net for anything that isn’t handled anywhere else — without them, an uncaught exception crashes the process with a raw stack trace, and (since Node 15) an unhandled rejection does the same. Seeing your own log line instead of Node’s default crash dump is often the fastest clue about which promise was never given a .catch().

Under the Hood: Attaching a Debugger Step by Step

When you run node --inspect-brk app.js and then attach from Chrome or VS Code, this is the order of events:

  1. Node starts the V8 engine and, before executing any of your code, opens a WebSocket server implementing the Chrome DevTools Protocol, printing the debug URL to stderr.
  2. Because --inspect-brk was used, V8 pauses at the first statement of your entry file and waits.
  3. Your debugger client (Chrome DevTools or VS Code) opens a WebSocket connection to that URL and sends protocol commands like Debugger.enable and Runtime.enable.
  4. The client sends Debugger.setBreakpointByUrl for any breakpoints you’ve set (including ones implied by debugger; statements) and then Debugger.resume to let execution continue.
  5. Your synchronous top-level code runs. Any require calls execute immediately, in order.
  6. When V8 hits a paused location — a breakpoint or a debugger; statement — it sends a Debugger.paused event over the socket with the current call stack and scope, and the client renders it.
  7. You inspect variables, evaluate expressions in the console, and click Step Over/Into/Out, each of which sends another protocol command; execution advances exactly one step and pauses again.
  8. When you click Resume, the process continues normally — including running any pending timers, promise callbacks, and I/O callbacks queued on the event loop — until the next breakpoint or the program’s natural exit.

Common Mistakes

Mistake 1: Swallowing a rejected promise

It’s easy to write a .then() chain and forget the failure path entirely:

readConfig()
  .then((config) => {
    start(config);
  });

If readConfig() rejects, nothing here handles it — the failure either triggers unhandledRejection and crashes the process, or (worse) is silently lost if you’ve configured Node to only warn. Always finish a promise chain with a .catch(), or wrap the awaited call in try/catch:

readConfig()
  .then((config) => start(config))
  .catch((err) => {
    console.error('Failed to start:', err);
    process.exit(1);
  });

Mistake 2: Using __dirname in an ES module

__dirname and __filename only exist in CommonJS. In an ES module they are simply undefined, which throws a ReferenceError at runtime the first time you use them — a syntax checker won’t catch this, because the code parses just fine:

import path from 'node:path';

const configPath = path.join(__dirname, 'config.json');
console.log('Config path:', configPath);

In an ES module, rebuild the equivalent values from import.meta.url instead (or use import.meta.dirname directly on Node 20.11+):

import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const configPath = path.join(__dirname, 'config.json');
console.log('Config path:', configPath);

Mistake 3: Treating console.log as your only tool

Sprinkling console.log everywhere works for tiny bugs but scales badly: it doesn’t show you the call stack, it doesn’t let you inspect an object’s full shape without a separate JSON.stringify or util.inspect call, and cleaning up dozens of stray log lines afterward is its own chore. For anything beyond a one-function bug, reach for debugger; plus --inspect-brk instead — you get the same information without editing the file at all.

Mistake 4: Exposing the inspector on a public interface

Running node --inspect=0.0.0.0:9229 app.js binds the debugger to every network interface. Anyone who can reach that port can attach a debugger session, read memory, and execute arbitrary code in your process — there is no authentication on the protocol. Only bind the inspector to 127.0.0.1 (the default), and never enable --inspect on a production server exposed to the internet.

Best Practices

  • Use node --watch app.js during development so the process restarts automatically on file changes, instead of manually stopping and restarting.
  • Prefer the inspector (debugger; + --inspect-brk) over scattered console.log calls once a bug involves more than one function or an object you need to explore interactively.
  • In VS Code, create a launch configuration so you can set breakpoints by clicking the gutter and press F5 to start debugging, without touching the terminal.
  • Always attach an unhandledRejection and uncaughtException handler in long-running services, even if only to log and exit cleanly — a silent crash with no log line is the hardest bug to diagnose.
  • Never bind the inspector to a non-localhost address, and never leave --inspect enabled on a production deployment.
  • Run with --enable-source-maps when debugging transpiled or bundled code so breakpoints and stack traces map back to your original source lines.
  • Use console.table() for arrays of objects and util.inspect(obj, { depth: null }) when a default console.log truncates a deeply nested object.

A minimal VS Code launch configuration (saved as .vscode/launch.json) looks like this:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug app.js",
      "program": "${workspaceFolder}/app.js",
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

Practice Exercises

  1. Take the average() bug from Example 1, but instead of fixing it with a filter, use node --inspect-brk and a debugger; statement placed inside the reduce callback to watch sum become NaN in real time. Note which iteration first turns it into NaN.
  2. Write a small script that reads a file with fs/promises and deliberately points it at a file that doesn’t exist. Add a process.on('unhandledRejection', ...) handler, then remove your .catch() and observe what Node prints with and without the handler in place.
  3. Create a two-function program (an outer function that calls an inner one several levels deep) where the inner function throws. Set a breakpoint on the throwing line with --inspect-brk and use the Call Stack panel in DevTools to identify every intermediate function that led there.

Summary

  • Node embeds the V8 Inspector Protocol, so any DevTools-Protocol client (Chrome, VS Code) can attach real breakpoints, not just print statements.
  • --inspect attaches in the background while your code runs immediately; --inspect-brk pauses on the first line until a client connects.
  • The debugger; statement pauses execution only when a client is attached, and is a safe no-op otherwise, though it should still be removed once you’re done.
  • Always terminate promise chains with .catch() and register unhandledRejection/uncaughtException handlers in long-running processes.
  • __dirname does not exist in ES modules — derive it from import.meta.url or use import.meta.dirname on Node 20.11+.
  • Never expose the inspector on a non-localhost interface; it has no built-in authentication.