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:
- 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.
- Because
--inspect-brkwas used, V8 pauses at the first statement of your entry file and waits. - Your debugger client (Chrome DevTools or VS Code) opens a WebSocket connection to that URL and sends protocol commands like
Debugger.enableandRuntime.enable. - The client sends
Debugger.setBreakpointByUrlfor any breakpoints you’ve set (including ones implied bydebugger;statements) and thenDebugger.resumeto let execution continue. - Your synchronous top-level code runs. Any
requirecalls execute immediately, in order. - When V8 hits a paused location — a breakpoint or a
debugger;statement — it sends aDebugger.pausedevent over the socket with the current call stack and scope, and the client renders it. - 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.
- 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.jsduring development so the process restarts automatically on file changes, instead of manually stopping and restarting. - Prefer the inspector (
debugger;+--inspect-brk) over scatteredconsole.logcalls 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
unhandledRejectionanduncaughtExceptionhandler 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
--inspectenabled on a production deployment. - Run with
--enable-source-mapswhen debugging transpiled or bundled code so breakpoints and stack traces map back to your original source lines. - Use
console.table()for arrays of objects andutil.inspect(obj, { depth: null })when a defaultconsole.logtruncates 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
- Take the
average()bug from Example 1, but instead of fixing it with a filter, usenode --inspect-brkand adebugger;statement placed inside thereducecallback to watchsumbecomeNaNin real time. Note which iteration first turns it intoNaN. - Write a small script that reads a file with
fs/promisesand deliberately points it at a file that doesn’t exist. Add aprocess.on('unhandledRejection', ...)handler, then remove your.catch()and observe what Node prints with and without the handler in place. - 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-brkand 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.
--inspectattaches in the background while your code runs immediately;--inspect-brkpauses 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 registerunhandledRejection/uncaughtExceptionhandlers in long-running processes. __dirnamedoes not exist in ES modules — derive it fromimport.meta.urlor useimport.meta.dirnameon Node 20.11+.- Never expose the inspector on a non-localhost interface; it has no built-in authentication.
