Debugging Node.js Like a Pro: My Debugging Workflow (2026)
Stop using console.log everywhere. Here's my systematic approach to finding bugs fast.
The Debugging Mindset
Before touching code:
1. What exactly is the symptom? (Not your guess — the actual behavior)
2. When does it happen? (Every time? Intermittent? Under load?)
3. What changed recently? (Code, deps, environment, data)
The bug is never where you think it is.
The log message that will reveal it is never the one you want to write.
Level 1: Console (But Smarter)
// ❌ SCATTERSHOT debugging
console.log('here');
console.log('data:', data);
console.log('after function');
// ✅ STRUCTURED logging
const debug = require('debug')('myapp:user');
debug('Creating user with email=%s', email);
// Set DEBUG=myapp:* to see these, empty string to hide
// ✅ NAMED console groups
console.group('🔍 Auth Flow');
console.log('Token received:', token.substring(0, 10) + '...');
console.log('Expires at:', new Date(expires * 1000));
console.groupDecoded('Payload');
console.log(decoded);
console.groupEnd();
console.groupEnd();
// ✅ TABLE for objects/arrays
console.table(users.map(u => ({ id: u.id, email: u.email, role: u.role })));
// ✅ COUNTER for loops
let i = 0;
items.forEach(item => {
console.count('processed'); // auto-increments each call
process(item);
});
console.countReset('processed'); // reset when done
// ✅ TIME for performance
console.time('db-query');
const users = await db.query('SELECT * FROM users');
console.timeEnd('db-query'); // → db-query: 142ms
// ✅ TRACE for call stack
function deepFunction() {
console.trace('How did I get here?'); // Shows full stack trace
}
// ✅ ASSERT for invariants
const assert = require('node:assert/strict');
assert(user.email.includes('@'), 'Email should contain @', { email: user.email });
assert(response.status < 400, `Unexpected status: ${response.status}`);
Level 2: Node.js Built-in Debugger
# Start debugger (breaks on first line)
node inspect server.js
# Or break on first statement of a script
node --inspect-brk script.js
# Attach to running process
node --inspect=9229 server.js
# Then connect from Chrome: chrome://inspect
Debugger Commands
c(ontinue) # Resume execution
n(ext) # Step over (don't enter functions)
s(tep) # Step into (enter functions)
o(ut) # Step out of current function
pa(use) # Pause code execution
re(sum) # Resume execution
Programmatic Breakpoints
// Conditional breakpoint — only stops when condition is true
debugger; // Always stops
if (userId === 'problematic-user-123') {
debugger; // Only stops for this user
}
if (data.length > 10000) {
debugger; // Stops when data looks suspicious
}
Level 3: VS Code Debugger (Game Changer)
launch.json Configuration
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Server",
"program": "${workspaceFolder}/server.js",
"cwd": "${workspaceFolder}",
"env": { "NODE_ENV": "development" },
"runtimeArgs": ["--inspect"],
"console": "integratedTerminal",
"restart": true,
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "attach",
"name": "Attach to Running",
"port": 9229,
"restart": true,
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "launch",
"name": "Run Tests",
"program": "${workspaceFolder}/node_modules/.bin/node",
"args": ["--test", "--test-reporter", "spec", "${workspaceFolder}/tests"],
"console": "integratedTerminal"
}
]
}
Must-Know Features
Watch Expressions: Monitor variables that update in real-time as you step.
Call Stack Panel: See exactly how you got here. Click any frame to see local variables at that point.
Breakpoint Conditions: Right-click breakpoint → "Edit Breakpoint" → Add condition:
users.length > 50 // Only breaks when array has > 50 items
email.includes('test') // Only breaks for test emails
i === 42 // Only breaks on specific iteration
Logpoints: Non-breaking breakpoints that log messages:
Right-click line number → "Add Logpoint"
Enter: "User ID: {userId}, Role: {role}"
→ Shows in debug console without pausing execution!
Inline Values: Enable in VS Code settings:
{
"debug.inlineValues": "on" // Shows variable values inline in editor
}
Level 4: Async Debugging
This is where most developers get stuck:
// ❌ Can't see inside this easily
async function processData() {
const data = await fetch(url); // What's in data?
const parsed = await data.json(); // And here?
const result = await transform(parsed); // ???
return result;
}
// ✅ Break INSIDE async functions
async function processData() {
const data = await fetch(url);
debugger; // ← Inspect data here
const parsed = await data.json();
debugger; // ← Inspect parsed here
const result = await transform(parsed);
return result;
}
// ✅ Or use .then() chain for stepping
fetch(url)
.then(r => r.json())
.then(data => { debugger; return data; }) // Breaks here
.then(transform);
Promise rejection handling:
// Catch ALL unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Don't crash in dev, but log everything
if (process.env.NODE_ENV === 'production') {
process.exit(1);
}
});
// In tests or critical paths:
await expectAsync(() => somePromise()).resolves.toBeDefined();
Level 5: Memory Leaks
// Detect growing memory
setInterval(() => {
const mem = process.memoryUsage();
console.log(`Heap: ${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB | RSS: ${(mem.rss / 1024 / 1024).toFixed(0)}MB`);
}, 10_000);
// Take heap snapshots in Chrome DevTools
// Compare snapshot A vs B → see what grew
Common leak patterns:
// ❌ Global variable accumulation
global.cache = global.cache || [];
global.cache.push(heavyObject); // Never cleaned up!
// ❌ Event listener not removed
emitter.on('data', handler); // Never off()
// Fix: emitter.once() when possible, or track and remove
// ❌ Closure holding references
function createHandler() {
const hugeData = new Array(1000000).fill('x');
return () => { /* closure keeps hugeData alive */ };
}
// ✅ WeakMap for cache (GC-friendly)
const cache = new WeakMap();
cache.set(obj, heavyData); // Auto-cleaned when obj is GC'd
Production Debugging
// src/middleware/errorLogger.js
module.exports = (err, req, res, _next) => {
const errorId = Date.now().toString(36) + Math.random().toString(36).slice(2);
// Log structured error
const errorLog = {
id: errorId,
timestamp: new Date().toISOString(),
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
userId: req.user?.id,
body: req.body,
headers: {
'user-agent': req.headers['user-agent'],
'content-type': req.headers['content-type']
}
};
console.error(JSON.stringify(errorLog)); // Structured JSON for log aggregation
// In production: don't expose internals
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production'
? `Internal error (ref: ${errorId})`
: err.message,
ref: errorId
}
});
};
My Debugging Checklist
When a bug report comes in:
□ Reproduce locally (same inputs, same env)
□ Check recent changes (git log --since="1 week ago")
□ Read the ACTUAL error message (not what you think it says)
□ Check logs around the timestamp
□ Add ONE strategic debugger/console.log
□ Verify assumptions (is the var really what you think?)
□ Simplify (remove layers until it works, then add back)
□ Check edge cases (empty, null, huge, unicode, concurrent)
□ Ask: "What if the data is different than expected?"
□ After fix: Write a test that would have caught this
What's your go-to debugging trick? Console.log ninja or IDE power user?
Follow @armorbreak for more practical Node.js guides.
Top comments (0)