Debugging Node.js: the complete practical guide
By Flavio Copes
Debug Node.js with Chrome DevTools, VS Code, breakpoints, source maps, CPU and heap profiles, inspector ports, child processes, and safe remote access.
console.log() is useful, but sometimes you need to stop the program and look around.
Node includes the V8 Inspector. It lets Chrome DevTools, VS Code, and other debugging clients attach to a running Node process.
You can pause code, inspect variables, step through functions, follow async calls, and record CPU or memory profiles.
Start the Node inspector
Run your program with --inspect:
node --inspect server.js
Node starts the application and opens the inspector on port 9229.
The terminal prints a WebSocket URL similar to this:
Debugger listening on ws://127.0.0.1:9229/...
The long identifier changes every time. Debugging tools discover it automatically.
Attach Chrome DevTools
Open this page in Chrome:
chrome://inspect
Older examples sometimes use about://inspect. Chrome redirects it to the same page.
Under Remote Target, find your Node process and click inspect.
Chrome opens a dedicated DevTools window. The Sources panel contains your Node files instead of a web page’s scripts.
Pause before the program starts
--inspect starts the code immediately. A startup bug might happen before you attach.
Use --inspect-brk to pause on the first line:
node --inspect-brk server.js
Attach the debugger, add any breakpoints you need, then resume execution.
Current Node releases also support --inspect-wait:
node --inspect-wait server.js
This waits for a debugger before execution but does not force a first-line breakpoint.
Use:
--inspectwhen the app can start before you attach--inspect-brkwhen you want to step from the first line--inspect-waitwhen execution must wait for the debugger
Add a breakpoint in DevTools
Open a file in the Sources panel and click its line number.
Reload or repeat the action that reaches that line. Node pauses before executing it.
While paused, DevTools shows:
- local and closure variables
- the current
thisvalue - the call stack
- active breakpoints
- expressions you added to Watch
Hover over a variable to see its current value.
Use the debugger statement
You can place a breakpoint directly in the code:
function calculateTotal(items) {
debugger
return items.reduce((total, item) => total + item.price, 0)
}
Node only pauses there when a debugger is attached.
Remove debugger statements when the investigation is over. A connected debugger can still stop on one in production code.
Step through code
The debugger offers several ways to continue:
- Resume runs until the next breakpoint
- Step over executes the current line without entering called functions
- Step into enters the function called on the current line
- Step out finishes the current function and returns to its caller
Start with Step over. Step into only when the bug appears to live inside the called function.
This keeps you out of framework and dependency internals unless you need them.
Use conditional breakpoints
A loop might run thousands of times before the wrong value appears.
Right-click a line number and add a conditional breakpoint:
customer.id === 'cus_42'
DevTools pauses only when the expression is true.
You can also add a logpoint. It writes a message without changing the source file or stopping execution.
Inspect asynchronous code
Promises, timers, and event handlers move work across the event loop. That can make the immediate call stack look incomplete.
Chrome DevTools can show async stack traces. They connect the current callback to the code that scheduled it.
Consider this example:
async function loadCustomer(id) {
const response = await fetch(`https://api.test/customers/${id}`)
return response.json()
}
Put a breakpoint after await. The async stack helps you find the caller that started loadCustomer().
Enable Pause on caught exceptions only when needed. Many libraries catch expected errors internally, so enabling it can create a lot of noise.
Pause on exceptions
The Sources panel includes a pause-on-exceptions control.
The useful modes are:
- do not pause on exceptions
- pause on uncaught exceptions
- pause on caught and uncaught exceptions
Start with uncaught exceptions. This stops near the original failure instead of much later when a rejected promise or error reaches a generic handler.
Debug an npm script
Pass Node flags through the NODE_OPTIONS environment variable:
NODE_OPTIONS='--inspect-brk' npm run dev
This works when the npm script launches Node directly.
Another option is to change the script temporarily:
{
"scripts": {
"debug": "node --inspect-brk server.js"
}
}
Then run:
npm run debug
Use a different inspector port
Each inspected process needs its own port.
node --inspect=9230 worker.js
Use port 0 to let Node choose an available port:
node --inspect=0 worker.js
The chosen address appears in the terminal output.
This is handy for test runners and child processes that may run in parallel.
Debug child processes
A child Node process does not automatically share its parent’s inspector session.
Pass an inspector flag through execArgv when using fork():
import { fork } from 'node:child_process'
const child = fork('./worker.js', [], {
execArgv: ['--inspect=0'],
})
Each child gets its own inspector endpoint.
When a framework starts workers for you, check its debugging options. Starting every worker on port 9229 creates a collision.
Debug worker threads
Chrome DevTools can display worker threads associated with the inspected process.
In the Sources panel, use the thread selector to switch execution contexts. Breakpoints in a worker must be set in that worker’s loaded script.
Workers can stop independently, so always check which thread is currently paused.
Debug TypeScript with source maps
The debugger runs the JavaScript emitted by TypeScript.
Source maps connect that JavaScript back to the original .ts files.
Enable them in tsconfig.json:
{
"compilerOptions": {
"sourceMap": true
}
}
Then start the compiled entry file with source maps enabled:
node --enable-source-maps --inspect-brk dist/server.js
You should now see the TypeScript source and get TypeScript locations in stack traces.
Bundlers and runtime TypeScript tools have their own source-map settings. Check that they emit accurate maps before blaming the debugger.
Attach VS Code
VS Code speaks the same inspector protocol.
The quickest workflow is to open Run and Debug, choose Node.js, and launch the current file.
For a repeatable configuration, create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug server",
"program": "${workspaceFolder}/server.js",
"skipFiles": ["<node_internals>/**"]
}
]
}
Use request: "attach" when Node is already running with --inspect.
Inspect values without stopping
Sometimes you only need a clearer object representation.
Node’s console.dir() lets you control depth and colors:
console.dir(customer, {
depth: null,
colors: true,
})
Read how to inspect JavaScript objects for more options.
Use breakpoints when the important question is how a value changed, not only what it contains now.
Record a CPU profile
When a process uses too much CPU, open the Performance panel in DevTools.
Start recording, reproduce the slow operation, then stop.
The profile shows which functions consumed execution time. Focus on wide or repeated frames in the flame chart.
Keep the recording short. A focused reproduction is easier to understand than several minutes of unrelated activity.
Node can also write a CPU profile without attaching DevTools:
node --cpu-prof server.js
Open the generated profile in Chrome DevTools later.
Investigate memory usage
Use the Memory panel to take heap snapshots.
A practical leak investigation looks like this:
- Start the application in a stable state.
- Take a baseline snapshot.
- Repeat the suspected leaking action several times.
- Force garbage collection when your debugging setup allows it.
- Take another snapshot and compare retained objects.
Look at retaining paths. They tell you why an object is still reachable.
Do not assume every increase is a leak. Caches, connection pools, and lazy initialization can grow once and then stabilize.
Debug a process that is already running
On POSIX systems, Node can start the inspector after receiving SIGUSR1:
kill -USR1 12345
Replace 12345 with the Node process ID.
This is useful when you did not start the process with --inspect. Be careful: opening a debugger on a live service changes its security and operational risk.
Never expose the inspector publicly
The inspector provides powerful access to the running process. A connected client can execute code and read application data.
Keep it bound to the loopback interface:
node --inspect=127.0.0.1:9229 server.js
Do not bind it to 0.0.0.0 on an internet-accessible machine.
For remote debugging, use an authenticated tunnel such as SSH and keep the inspector itself local.
Common debugging problems
The process does not appear in Chrome
Check that Node printed a debugger URL. Confirm the port and add it under Configure on chrome://inspect if discovery does not find it.
The breakpoint is hollow or ignored
The loaded code may not match the file you opened. Check source maps, build output, and whether another process is serving an older file.
The app runs before I can attach
Use --inspect-brk or --inspect-wait.
The inspector port is already in use
Choose another port or use --inspect=0.
Stepping enters Node internals
Enable blackboxing in DevTools or use skipFiles in VS Code.
My debugging workflow
I start with the smallest reproducible action.
Then I add one breakpoint before the value becomes wrong and one after it. I inspect the arguments, step over each operation, and move the first breakpoint closer once I know which branch causes the problem.
For startup failures I use --inspect-brk. For slow code I record a short CPU profile. For suspected leaks I compare heap snapshots after repeating one action.
The official Node debugger documentation lists the current inspector flags and command-line debugger.
Related posts about node: