LEARN · DEBUGGING GUIDE

Node.js Inspector Debugger: Diagnosing Protocol Disconnections and Async Breakpoints

The Node.js inspector is powerful but fragile. This guide covers real-world failures: protocol disconnections, missing async stacks, and breakpoints that never hit.

IntermediateNode.js7 min read

What this usually means

The Node.js debugger uses the Chrome DevTools Protocol (CDP) over WebSocket. Disconnections often stem from the WebSocket handshake failing due to port conflicts, firewall rules, or the inspector timing out before the client connects. Missing async breakpoints usually mean the V8 inspector didn't await the async pause—Node.js 10+ uses AsyncHooks to track async contexts, but breakpoints must be set after the async function is compiled. Source map issues occur when the source map URL is relative and the inspector cannot resolve it, or when the map is missing the 'sourceRoot' field. These are not bugs in Node.js but configuration gaps that this guide addresses.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `node --inspect-brk app.js` and immediately open `chrome://inspect` in Chrome—confirm the device appears under 'Remote Target'
  • 2If disconnection occurs, check WebSocket health: `curl -o /dev/null -s -w '%{http_code}\n' http://localhost:9229/json/version`—should return 200
  • 3In the inspector console, type `process._debugEnd()` then `process._debugProcess(process.pid)` to restart the debugger without restarting the app
  • 4For missing breakpoints, add `debugger;` statement in the code—if it triggers, the issue is breakpoint timing, not protocol
  • 5Check for multiple inspector instances: `ss -tlnp | grep 9229`—only one process should listen
  • 6Verify source map presence: `ls dist/*.map` and confirm the map references correct sources via `head -c 1000 dist/app.js.map | python -m json.tool 2>/dev/null`
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchChrome DevTools > DevTools Settings > Experiments > 'Enable Node.js debugging' (if missing) or 'Network' tab to see WebSocket frames
  • searchNode.js process output: `NODE_DEBUG=inspector* node --inspect app.js` emits protocol-level logs
  • searchSystem logs for killed processes: `journalctl -u myapp.service | grep -i debug`
  • searchSource map file: `cat dist/app.js.map | jq '.sources, .sourceRoot'`
  • searchFirewall/iptables rules: `sudo iptables -L -n | grep 9229`
  • searchEnvironment variable check: `env | grep -E '^NODE_OPTIONS'`—conflicting --inspect in NODE_OPTIONS can cause port binding failures
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningWebSocket idle timeout: Chrome DevTools disconnects after 30 seconds of no activity—Node.js inspector does not send heartbeats by default
  • warningBreakpoints set too early: breakpoints in async functions fail if the function hasn't been compiled yet (e.g., lazy-loaded modules)
  • warningSource map missing 'sourceRoot' or incorrect mappings: V8 inspector fails to resolve sources to original files
  • warningFirewall or proxy blocking WebSocket upgrade: HTTP 101 response never reaches client
  • warningMultiple debugger clients: two DevTools windows both trying to control the same debug session causes protocol conflicts
  • warningNode version mismatch: old Node.js (pre-10) lacks full async stack support; Node 8 uses legacy debugger (--debug), not --inspect
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildEnable WebSocket keepalive: use `require('inspector').console` or send periodic `Runtime.evaluate` calls via the client
  • buildSet breakpoints after async function definition: add a small delay (e.g., `setTimeout(() => { debugger; }, 100)`) or set breakpoints in code after the function is called
  • buildFix source maps: set `sourceRoot` to an absolute URL or '.'; use webpack's `devtool: 'source-map'` instead of 'eval'
  • buildPrevent port conflicts: use `--inspect=0` to assign a random port, then read the port from process output or `process.debugPort`
  • buildUse `--inspect-publish-uid=http` to make the inspector endpoint available without WebSocket (useful for custom clients)
  • buildFor Docker environments, explicitly expose port 9229 and add `--expose-internals` if needed
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedSet a breakpoint in an async function via DevTools—hit Refresh; it should pause. If not, use `debugger;` as control
  • verifiedRun `curl http://localhost:9229/json/list`—should return a JSON array with one target, and the devtoolsFrontendUrl should be reachable
  • verifiedAfter fix, trigger a disconnection scenario (idle 1 minute) and confirm DevTools remains connected—check WebSocket frame count in DevTools Network tab
  • verifiedSource map verification: set breakpoint in original TypeScript file; DevTools should show the original code, not compiled JS
  • verifiedRun `node --inspect -e "console.log(process.debugPort)"`—should output 9229 (or random port if using 0)
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningDon't rely on `--inspect-brk` alone for production debugging—it pauses on first line and can cause startup timeouts in containers
  • warningDon't set breakpoints before the module is required—breakpoints in top-level code of a module are only valid after the module is loaded
  • warningDon't use `node --debug` on Node.js 8+—it's the legacy debugger and conflicts with `--inspect`
  • warningDon't ignore `EADDRINUSE` errors—port 9229 may already be in use; use random port instead
  • warningDon't assume source maps work with `eval` webpack devtool—they don't produce separate map files
( 07 )War story

The Phantom Disconnect: Async Breakpoints That Never Fired

Senior Backend EngineerNode.js 14, Express 4, TypeScript, Webpack 5, Docker, AWS ECS

Timeline

  1. 09:15Deploy new microservice to staging; logs show 'Debugger attached' on startup
  2. 09:20Open Chrome DevTools at chrome://inspect; target appears but sources tab empty
  3. 09:25Wait 30 seconds; DevTools shows 'Disconnected from debug target'
  4. 09:30Restart with --inspect-brk; breakpoint at first line works, but async breakpoints don't
  5. 09:40Check WebSocket: curl http://localhost:9229/json/list returns empty array
  6. 09:45Realize Docker container exposes port 9229 but host firewall blocks the WebSocket upgrade
  7. 09:50Add firewall rule and restart; DevTools stays connected, but async breakpoints still fail
  8. 10:00Insert debugger; in an async function; it hits, so breakpoint timing is the issue
  9. 10:05Set breakpoint after the async function is invoked; it works
  10. 10:10Fix source maps by setting devtool: 'source-map' and adding sourceRoot: '.'

I deployed a new Node.js microservice to staging and immediately tried to debug an issue with async request handlers. The first sign of trouble was that Chrome DevTools connected briefly but then displayed an empty sources tab. After 30 seconds of idling, it disconnected entirely. I checked the standard diagnostics: the inspector port was open, and a simple curl to /json/version returned 200. But /json/list returned an empty array, meaning the inspector had no debug targets—it had already detached.

I suspected a firewall issue. The Docker container was running with --inspect-brk on port 9229, and I had mapped the port in the docker-compose file. However, the host's iptables were blocking the WebSocket upgrade handshake. After adding a rule to allow incoming connections on port 9229, the DevTools stayed connected. But async breakpoints still never fired. I inserted a `debugger;` statement inside the async function, and it paused there. That told me the inspector protocol was working, but breakpoints set via DevTools were being registered before the async function was compiled.

The root cause had two parts: first, the WebSocket handshake was being dropped by the firewall, causing the disconnection. Second, the async function was lazily compiled (it was inside a dynamically imported module), so breakpoints set before the module loaded were lost. I fixed the firewall and changed my breakpoint strategy: I set breakpoints after the async function was called, or I used `debugger;` inside the function. I also fixed the source maps because the transpiled code made it hard to set breakpoints on original lines. The lesson: never assume breakpoints survive module boundaries, and always verify the WebSocket connection end-to-end.

Root cause

Firewall blocked WebSocket upgrade, causing disconnection; and breakpoints set before async function compilation were ignored

The fix

Added iptables rule to allow port 9229; changed breakpoint timing to after async function invocation; fixed webpack sourceMap devtool to 'source-map'

The lesson

Always test WebSocket connectivity with /json/list, and treat async breakpoints as ephemeral until the function is compiled.

( 08 )Inspector Protocol Handshake: Why WebSocket Disconnects

When you run `node --inspect`, Node.js starts a WebSocket server on port 9229 (or the specified port). The inspector protocol (CDP) uses this WebSocket to send commands and events. The handshake is a standard HTTP upgrade request. If any intermediary (firewall, proxy, load balancer) does not forward the `Upgrade: websocket` header, the connection falls back to HTTP and eventually times out.

Common failure: Chrome DevTools shows 'Disconnected' after 30 seconds. This is the default WebSocket idle timeout in Chrome. Node.js does not send ping frames by default. To keep the connection alive, either send periodic commands (e.g., `Runtime.evaluate({expression: '1+1'})`) or use a custom client that implements heartbeats.

( 09 )Async Breakpoints: The Compilation Timing Problem

V8's inspector sets breakpoints by inserting a debug break instruction into the compiled bytecode. If the function has not yet been compiled (because it's lazily parsed or in a not-yet-loaded module), the breakpoint is stored as a pending breakpoint. When the function is compiled, the pending breakpoint is applied. However, if the breakpoint is set before the module is loaded, and the module is loaded asynchronously (e.g., dynamic `import()`), the pending breakpoint may not be applied correctly if the inspector session is reset.

To work around this, you can use the `debugger;` statement directly in the async function, or set a breakpoint on a line that executes after the async function is defined. Another approach is to use `--inspect-brk` and then execute code that triggers the module load before setting breakpoints.

( 10 )Source Map Resolution: Why the Inspector Shows Transpiled Code

When you set a breakpoint in a TypeScript file, the inspector uses the source map to map the breakpoint to the correct location in the compiled JavaScript. If the source map is missing a `sourceRoot` field, or if the `sources` array has relative paths that are not resolvable from the inspector's working directory, the breakpoint will be set in the compiled code instead.

The fix is to ensure your build tool outputs source maps with absolute or correct relative paths. For webpack, use `devtool: 'source-map'` (not 'eval-source-map') and set `output.sourceMapFilename` appropriately. Also, verify that the source map file is accessible via the inspector's URL (if using `--inspect-publish-uid=http`).

Frequently asked questions

How do I restart the inspector without restarting the Node.js process?

You can use `process._debugEnd()` to stop the inspector and `process._debugProcess(process.pid)` to start it again. Alternatively, send SIGUSR1 to the process: `kill -USR1 <pid>`.

Why does `--inspect-brk` sometimes fail to pause on the first line?

If there is a conflicting `--inspect` flag in `NODE_OPTIONS`, the debugger may start without break on entry. Also, if the process runs under a container that does not forward the signal properly, the pause may be missed. Use `--inspect-brk` alone and ensure no other debugger instances are running.

Can I use the inspector with a remote server behind a firewall?

Yes, but you need to expose the WebSocket port (9229) and ensure the `Upgrade` header passes through. You can also use SSH tunneling: `ssh -L 9229:localhost:9229 user@remote` and then connect Chrome DevTools to localhost:9229.

How do I debug Node.js child processes with the inspector?

Child processes inherit the inspector from the parent if you use `--inspect` on the parent. If you need separate debuggers, use `--inspect-port=0` in the child to get a random port, then listen to the child's `debugPort` event.

What does 'Cannot connect to target: connect ECONNREFUSED' mean?

It means the WebSocket server is not running on the specified port. Check if the process is still alive, if the port is correct, and if a firewall is blocking the connection. Also, ensure you are connecting to the correct IP (localhost vs 127.0.0.1 vs external IP).