Debugging Node.js - When console.log Is Not Enough¶
Table of Contents¶
- Built-in Debugger : node inspect
- Chrome DevTools Debugging : --inspect , --inspect-brk
- VS Code Debugging Configurations
- Debugging Memory Leaks : heapdump , Chrome Memory Tab
- Debugging CPU Issues : --prof
- Debugging Infinite Loops and Event Loop Blocks
- debug Module for Logging , Not consolelog
Built-in Debugger : node inspect¶
Node ships with a command-line debugger. It's ugly but always available and works when nothing else does
node inspect app.js
Once in the debugger:
$ node inspect server.js
< Debugger listening on ws://127.0.0.1:9229/...
< ok
Break on start in server.js:1
> 1 const express = require('express')
2 const app = express()
3 const port = 3000
debug> n # next line
debug> s # step into function
debug> o # step out of function
debug> c # continue to next breakpoint
debug> repl # enter REPL mode to inspect variables
debug> watch('user') # watch expression
debug> watchers # show watched expressions
debug> setBreakpoint('utils.js:15')
debug> sb('handler', 42) # set breakpoint at function + line
debug> list(10) # show 10 lines of source
Set breakpoints in your code with the debugger statement:
function calculateTotal(items) {
let total = 0
for (const item of items) {
debugger // execution pauses here
total += item.price * item.quantity
}
return total
}
The built-in debugger is your last resort when Chrome DevTools won't connect or your container has no browser. Learn it , you'll need it on a headless server at 3AM someday
Chrome DevTools Debugging : --inspect , --inspect-brk¶
This is the debugger you'll actually use. Chrome DevTools has a full Node debugger with breakpoints , call stacks , scope inspection , and live editing
# start with inspector
node --inspect app.js
# pause on first line (before any code runs)
node --inspect-brk app.js
Open chrome://inspect in Chrome. You'll see your Node process listed under "Remote Target". Click "inspect" and you get the full DevTools debugger
node --inspect-brk server.js
Debugger listening on ws://127.0.0.1:9229/...
What you can do in DevTools:
- Set breakpoints by clicking line numbers in the Sources tab
- Add conditional breakpoints (right-click => "Add conditional breakpoint")
- Watch expressions that re-evaluate on every pause
- Inspect the call stack across async boundaries
- Edit variables live in the Console while paused
- Step through async code with proper async stack traces
--inspect-publish-uid for Docker/remote debugging:
# allow remote connections to the debugger
node --inspect=0.0.0.0:9229 --inspect-publish-uid=http app.js
Security warning: Never expose the inspector port (9229 by default) to the public internet. Anyone who connects can execute arbitrary code in your process. Bind to localhost or 127.0.0.1 unless you've secured the network
VS Code Debugging Configurations¶
VS Code has the best Node debugger experience. Create a .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/src/index.js",
"skipFiles": ["<node_internals>/**"],
"env": { "NODE_ENV": "development" }
},
{
"type": "node",
"request": "launch",
"name": "Debug Current File",
"program": "${file}",
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "attach",
"name": "Attach to Process",
"port": 9229,
"address": "localhost",
"skipFiles": ["<node_internals>/**"]
},
{
"type": "node",
"request": "launch",
"name": "Run Tests (Jest)",
"runtimeExecutable": "npx",
"runtimeArgs": ["jest", "--runInBand", "--no-cache"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
}
]
}
Press F5 to start debugging. Set breakpoints in the editor gutter. The debug sidebar shows variables , watches , call stack , and loaded scripts
Debugging Memory Leaks : heapdump , Chrome Memory Tab¶
Memory leaks in Node don't crash immediately. They degrade performance slowly over hours or days until the OOM killer terminates the process
heapdump captures the heap at any point for analysis:
npm install heapdump
const heapdump = require('heapdump')
const fs = require('fs')
const path = require('path')
// dump on demand via signal
process.on('SIGUSR2', () => {
const filename = path.join('/tmp', `heap-${process.pid}-${Date.now()}.heapsnapshot`)
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error('heapdump failed:', err)
else console.log('Heap dump written to', filename)
})
})
# trigger dump
kill -USR2 <pid>
Chrome DevTools analysis:
- Open
chrome://inspect - Click "Memory" tab
- Click "Load" and select the
.heapsnapshotfile - Sort by "Retained Size" - the biggest items are your leaks
- Check for detached DOM nodes (rare in Node) , large closures , unbounded caches
Common leak patterns:
- Closures that capture large objects
- Event listeners that never get removed
setIntervalwithoutclearInterval- Unbounded caches (Map , Set , object properties)
- Streams not properly consumed or destroyed
globalpollution accumulating state
// LEAKY - event listener accumulates
class LeakyService {
constructor() {
this.cache = new Map()
this.eventEmitter.on('data', (data) => {
// 'this' is captured - the whole service stays in memory
this.cache.set(data.id, data)
})
}
}
// FIX - remove listener or use weak refs
class FixedService {
constructor() {
this.cache = new Map()
this.handler = this.handleData.bind(this)
this.eventEmitter.on('data', this.handler)
}
handleData(data) {
this.cache.set(data.id, data)
}
destroy() {
this.eventEmitter.off('data', this.handler)
this.cache.clear()
}
}
Debugging CPU Issues : --prof¶
When Node is eating 100% CPU and nobody knows why , use the built-in profiler
# profile for 30 seconds
node --prof app.js
# run your app, generate load, then kill it
# V8 writes a file: isolate-<hash>.log
# process the log into readable format
node --prof-process isolate-*.log > profile.txt
The output shows which JavaScript functions consumed the most CPU time:
[JavaScript]:
ticks total nonlib name
52 10.4% 15.2% Function: calculateHash crypto.js:123
31 6.2% 9.0% Function: parseRequest parser.js:45
18 3.6% 5.2% Function: validateInput validator.js:78
Flamegraphs give you a visual timeline (use 0x):
npm install -g 0x
0x app.js
# open the generated flamegraph.html in a browser
The x-axis is stack frequency (wider = more CPU time). The y-axis is call depth. Look for fat boxes at the top - that's your hot path
Debugging Infinite Loops and Event Loop Blocks¶
An infinite loop in Node freezes everything - no requests , no timers , no nothing. The event loop is single-threaded and your infinite loop never yields control
Signs of a blocked event loop:
- Server stops responding to requests
- Logging stops mid-stream
setTimeoutcallbacks never fireprocess.hrtime()shows large gaps between executions
Diagnose with --trace-event-categories:
node --trace-event-categories node.perf app.js
Programmatic monitoring:
let lastLoop = Date.now()
function checkEventLoop() {
const now = Date.now()
const delay = now - lastLoop - 1000
if (delay > 100) {
console.warn(`Event loop blocked for ${delay}ms`)
}
lastLoop = now
}
setInterval(checkEventLoop, 1000)
Common blocking patterns:
// BAD - synchronous loop blocks the event loop
function processLargeArray(items) {
items.forEach(item => {
// heavy computation on 100k items
const result = expensiveSyncOperation(item)
results.push(result)
})
}
// FIX - chunk the work
async function processLargeArray(items) {
const results = []
for (let i = 0; i < items.length; i += 100) {
const chunk = items.slice(i, i + 100)
const chunkResults = chunk.map(item => expensiveSyncOperation(item))
results.push(...chunkResults)
// yield to event loop every 100 items
await new Promise(resolve => setImmediate(resolve))
}
return results
}
debug Module for Logging , Not consolelog¶
Stop using console.log for debugging. It's synchronous , unstructured , and you always forget to remove them
npm install debug
const debug = require('debug')
const log = debug('app:server')
const logAuth = debug('app:auth')
const logDb = debug('app:database')
// usage throughout the app
log('Server starting on port %d', 3000)
logAuth('User %s authenticated', userId)
logDb('Query executed in %dms', duration)
# enable all app debug logs
DEBUG=app:* node app.js
# enable specific namespace
DEBUG=app:auth,app:server node app.js
# disable all (default)
node app.js
The debug module is async-safe in production , color-coded per namespace by default , and the output never reaches production unless you explicitly set DEBUG. No more manually deleting console.log before commits
prerequisites¶
test_04_integration.md - integration testing , supertest , testcontainers
next -> perf_01_profiling.md