Last updated: 2026-09-14

Debugging Node.js

Debugging is the process of finding out why code is not doing what it should. Every developer does this constantly, whether deliberately or not — and the tools available for debugging Node.js range from the simplest possible approach, a console.log(), to a full interactive debugger capable of pausing execution mid-function and inspecting every variable in scope.

Knowing the right tool for a given situation, rather than defaulting to the same approach every time, is what separates fast debugging from slow, frustrating debugging.


console.log() — and Why It Is Not Enough on Its Own

The simplest debugging technique is printing values to see what is actually happening:

function calculateTotal(items) {
  console.log('items:', items);
  const total = items.reduce((sum, item) => sum + item.price, 0);
  console.log('total:', total);
  return total;
}

This works, and it is genuinely useful for quick checks. But it has real limitations. It requires editing the code to add logging, then editing it again to remove the logging afterward. It cannot pause execution to let you explore the state of the program interactively. And in an asynchronous codebase, scattered console.log() calls across multiple callbacks can produce output that is difficult to correlate back to a single request or operation, especially under concurrent load.

Beyond Basic console.log()

The console object has more capability than just .log(), and knowing the rest of it reduces how often a plain console.log() is actually the best tool:

console.error('This is an error');   // writes to stderr, not stdout
console.warn('This is a warning');   // also writes to stderr
console.table(users);                // renders an array of objects as a table
console.time('operation');
// ... code to measure ...
console.timeEnd('operation');        // operation: 42.891ms

console.trace('Trace point reached'); // prints a full stack trace at this point

console.table() is particularly useful when inspecting an array of objects — it is far more readable than the default nested output of console.log() for that shape of data. console.time() / console.timeEnd() provide a quick way to measure how long a section of code takes without installing a profiling tool.


The Node.js Built-in Debugger

Node.js ships with an interactive command-line debugger, accessible via the inspect flag:

node inspect server.js

This starts the script in debug mode and drops into an interactive prompt. From there, commands like next (step to the next line), cont (continue execution until the next breakpoint), and repl (drop into a live REPL to inspect variables) allow stepping through code line by line.

This built-in CLI debugger is functional but limited in practice compared to the alternative covered next — most developers today use a visual debugger rather than this command-line interface, but it is useful to know it exists, particularly in environments where a graphical tool is not available, such as debugging directly on a remote server over SSH.


Debugging with Chrome DevTools

Node.js exposes a debugging protocol that Chrome's DevTools can connect to directly, providing a full graphical debugging experience — breakpoints, variable inspection, a call stack viewer, and a console — for server-side code.

node --inspect server.js

This starts the application and opens a debugging port. Navigating to chrome://inspect in Chrome, then clicking "Open dedicated DevTools for Node," connects DevTools to the running process. From there, the experience is identical to debugging frontend JavaScript in the browser — the same interface, applied to server-side code.

Breaking on the First Line

node --inspect-brk server.js
  • -inspect-brk pauses execution on the very first line of the script, before anything runs, giving time to open DevTools and set breakpoints before the application has already started processing requests. This is particularly useful when debugging issues that occur during application startup.

Debugging in VS Code

VS Code has Node.js debugging built directly into the editor, without needing a separate browser tab. This is, in practice, the debugging workflow most Node.js developers use day to day.

Quick Start — No Configuration

Opening a JavaScript file and pressing F5 (or using the "Run and Debug" panel) starts the file under VS Code's built-in debugger immediately, with no setup required for simple cases.

launch.json — Configured Debugging

For more control — specifying environment variables, the exact entry file, or arguments — a .vscode/launch.json file defines the debug configuration explicitly:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Server",
      "program": "${workspaceFolder}/server.js",
      "envFile": "${workspaceFolder}/.env",
      "console": "integratedTerminal"
    }
  ]
}

This configuration launches server.js, loads environment variables from .env, and runs output in VS Code's integrated terminal — set up once, and reused every time debugging starts.

Setting Breakpoints

Clicking in the gutter to the left of a line number sets a breakpoint — execution pauses at that exact line whenever it is reached, and the entire call stack, every variable currently in scope, and the ability to step forward line by line are all available directly in the editor.

function calculateTotal(items) {
  let total = 0;
  for (const item of items) {
    total += item.price; // set a breakpoint on this line
  }
  return total;
}

With a breakpoint here, execution pauses on each iteration of the loop, and item, total, and every other variable in scope can be inspected directly in VS Code's debug panel — no console.log() statements needed anywhere in the code.

Conditional Breakpoints

Right-clicking a breakpoint allows attaching a condition — execution only pauses when the condition evaluates to true. This is useful when a bug only manifests on a specific iteration or under a specific condition, out of many that would otherwise trigger the same breakpoint repeatedly:

// Breakpoint condition: item.price > 1000
for (const item of items) {
  total += item.price;
}

This pauses execution only when an item with a price over 1000 is encountered, skipping silently over every other iteration.


Debugging Asynchronous Code

Stepping through asynchronous code with a debugger requires a small adjustment in expectation. Setting a breakpoint inside a .then() callback or inside an async function works exactly as it would for synchronous code — execution pauses at that point, regardless of how much time passed waiting for the Promise to settle.

async function getUser(id) {
  const user = await db.findUser(id); // execution resumes here once the await settles
  console.log(user); // set a breakpoint here to inspect the resolved value
  return user;
}

The one detail worth understanding: while the debugger is paused at a breakpoint, the rest of the Node.js process is also paused — no other requests are being handled, no other timers are firing. This is different from a single slow synchronous operation blocking the event loop; it is an intentional, controlled pause for the purpose of inspection, and it ends the moment execution is resumed.


Inspecting Memory and Performance

Beyond stepping through logic, Node.js provides tools for diagnosing performance problems and memory issues.

The -prof Flag

node --prof server.js

This generates a V8 profiling log capturing where the application spends its CPU time. The raw log is processed afterward with:

node --prof-process isolate-0x*.log > processed.txt

The resulting report identifies which functions consumed the most CPU time — useful for diagnosing performance bottlenecks in CPU-bound code, distinct from I/O-bound slowness, which usually has different causes and different diagnostic approaches.

Memory Snapshots in Chrome DevTools

When connected via --inspect, Chrome DevTools' Memory tab can take a heap snapshot — a complete picture of every object currently allocated in memory. Taking snapshots at two different points in time and comparing them is the standard technique for diagnosing a memory leak: objects that should have been garbage collected but were not, because something somewhere is still holding a reference to them.

This level of debugging is genuinely advanced and not something most day-to-day Node.js work requires — it becomes relevant specifically when an application's memory usage grows steadily over time without levelling off, which is the classic symptom of a leak.


Debugging in Production

The tools covered so far assume direct access to a running process on your own machine. Debugging an issue that only occurs in production requires a different approach, since attaching a live interactive debugger to a production server is rarely practical or safe.

Structured Logging

As mentioned in the Error Handling topic, production debugging relies heavily on logs captured at the time something went wrong — structured, searchable logs that record enough context (timestamp, request details, error stack trace, relevant identifiers) to reconstruct what happened after the fact, without needing to reproduce the issue live.

Error Tracking Services

Tools like Sentry capture uncaught exceptions and unhandled rejections automatically in a deployed application, reporting them with full stack traces, the request context that triggered them, and aggregation across multiple occurrences of the same underlying issue — turning a production crash from a mystery into a reviewable, actionable report.

Remote Debugging

It is possible to attach a debugger to a remote, running Node.js process using --inspect with the port exposed and access secured appropriately, but this is generally reserved for staging environments or carefully controlled situations rather than live production traffic, given the risk of pausing a process that is actively serving real users.


A Practical Debugging Workflow

When something is not working as expected, a reasonable sequence to work through:

  1. Read the error message and stack trace carefully first. The exact line and the nature of the error (TypeError, ENOENT, etc.) often points directly at the problem without needing any further investigation.
  2. Reproduce the issue reliably. A bug that cannot be reliably reproduced is much harder to debug than one that fails the same way every time.
  3. Use a breakpoint, not console.log(), for anything beyond a single quick check. A debugger lets you inspect the full state at the point of failure, rather than guessing in advance which values are worth printing.
  4. Narrow the problem with conditional breakpoints or targeted logging when the issue only occurs under specific conditions, deep inside a loop or a frequently-called function.
  5. Check the assumption, not just the symptom. Frequently, the actual bug is an assumption made earlier in the code — a value expected to never be null, an array expected to always have at least one item — rather than the line where the error finally surfaces.

Summary

ToolBest For
console.log() and friendsQuick checks; console.table() for arrays of objects
node inspectCommand-line debugging, useful when no graphical tool is available
node --inspect + Chrome DevToolsFull graphical debugging without leaving the browser
node --inspect-brkDebugging issues during application startup
VS Code debuggerDay-to-day debugging workflow for most developers
Conditional breakpointsBugs that only occur under specific conditions
--profDiagnosing CPU performance bottlenecks
Heap snapshotsDiagnosing memory leaks
Structured loggingReconstructing what happened in production after the fact
Error tracking services (e.g. Sentry)Capturing and aggregating production errors automatically