Most Node.js debugging happens with console.log. That works until it doesn't: async stacks that point to the wrong place, a variable that changes between the log and the crash, or a server that only misbehaves on one request out of a thousand. Here's the toolkit I reach for instead.
Start with --inspect, not print statements
The built-in inspector gives you breakpoints, step-through, and a real call stack. Start your process with:
node --inspect app.js
Then open chrome://inspect in Chrome and click "inspect". For a process you can't restart easily (a worker, a Docker container), use --inspect-brk to pause on the first line, or send SIGUSR1 to a running process to enable the inspector on the fly.
kill -USR1 <pid>
If the port needs to be reachable from outside a container, bind it explicitly: node --inspect=0.0.0.0:9229 app.js. Don't do this on a public host.
Break on the condition, not the line
A plain breakpoint inside a hot loop stops on every iteration. Right-click the line in DevTools and add a conditional breakpoint. Instead of:
for (const order of orders) {
processOrder(order); // stops 10,000 times
}
Set the condition to order.id === 'abc123' and it stops exactly once. This turns a flaky bug into a reproducible one.
Use debugger statements deliberately
A debugger; line behaves like a breakpoint when the inspector is attached, and is a no-op otherwise. That makes it safe to commit temporarily:
function applyDiscount(cart) {
debugger; // only pauses when --inspect is active
return cart.total * 0.9;
}
I'll leave these in during a debugging session and strip them before the PR.
Log objects, not strings
console.log('user:', user) prints [object Object] in some terminals and truncates deeply nested data. Two fixes:
console.log(JSON.stringify(user, null, 2));
console.dir(user, { depth: null });
console.dir with depth: null is the one I use most. It respects circular references, which JSON.stringify throws on.
Trace async properly with --async-stack-traces
Async stack traces are on by default in modern Node, but if you're on an older release or have them disabled, enable them:
node --async-stack-traces app.js
Without this, an error thrown inside a setTimeout or a promise chain shows a stack that starts at the callback, not at the code that scheduled it. With it, you see the full causal chain.
Attach a profiler when it's slow, not broken
If the bug is "it's slow," breakpoints won't help. Use the built-in profiler:
node --cpu-prof --cpu-prof-dir=./profiles app.js
This writes a .cpuprofile file you can load in Chrome DevTools under the Performance tab. Look for wide bars: those are the functions eating your time. For memory, --heap-prof does the same for allocations, and process.memoryUsage() gives you a cheap snapshot:
setInterval(() => {
const { heapUsed } = process.memoryUsage();
console.log(`heap: ${(heapUsed / 1024 / 1024).toFixed(1)} MB`);
}, 5000);
A heap that climbs steadily and never drops is a leak. A heap that sawtooths is just GC doing its job.
Read the error, including code
Node errors carry structured fields that err.message hides:
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${err.port} is taken`);
}
console.error(err);
});
EADDRINUSE, ECONNREFUSED, and ENOENT tell you the category of failure instantly. Log the whole error object, not err.message.
A quick checklist
- Reproduce it reliably first. A conditional breakpoint beats guessing.
-
--inspect-brkfor startup crashes,SIGUSR1for running processes. -
console.dir(obj, { depth: null })over string concatenation. -
--cpu-profand--heap-proffor performance, not breakpoints. - Check
err.codebefore you checkerr.message.
None of this is exotic. It's all built into Node. The trick is remembering it exists before you add the twentieth console.log.
Top comments (0)