Debugging & Monitoring Β· Astro Tech Blog

Debugging with the Built-In Inspector

Node.js ships with the Chrome DevTools protocol built in. No extra tools, no plugins β€” just Node.js and a browser.

How the Inspector Works

When you start Node.js with --inspect, it opens a WebSocket connection that speaks the Chrome DevTools Protocol (CDP). Chrome DevTools connects to this WebSocket and gives you full debugging capabilities.

Node.js Process              Chrome DevTools
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ V8 Engine      │◄─WS────►│ Sources Tab    β”‚
β”‚ Inspector      β”‚  CDP    β”‚ Console Tab    β”‚
β”‚ --inspect      β”‚          β”‚ Profiler Tab   β”‚
β”‚ --inspect-brk  β”‚          β”‚ Memory Tab     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

1. Basic Inspector

# Start with inspector enabled
node --inspect app.js

# Output:
# Debugger listening on ws://127.0.0.1:9229/...
# Chrome DevTools URL: chrome-devtools://...

Open chrome://inspect in Chrome β†’ click β€œOpen dedicated DevTools for Node”.

2. Break on Start

Normally, the inspector connects after your code starts running. If you need to debug from the very first line:

# Pause immediately on first line
node --inspect-brk app.js

This pauses execution before any JavaScript runs. You can then set breakpoints in DevTools and resume.

3. The debugger Statement

You can insert breakpoints directly in your code:

// debug-demo.js
function calculateTotal(items) {
  debugger; // Execution pauses here when inspector is attached
  return items.reduce((sum, item) => sum + item.price, 0);
}

const cart = [
  { name: 'Laptop', price: 999 },
  { name: 'Mouse', price: 25 },
];

console.log(calculateTotal(cart));

Then:

node --inspect-brk debug-demo.js

When execution hits debugger, DevTools will pause, letting you inspect all variables, step through, and even edit values live.

What You Can Do in Chrome DevTools

FeatureHowUse Case
BreakpointsClick line numbers in Sources tabPause at specific code
Watch expressionsRight-click β†’ Add to watchTrack variable values
Call stackRight panel in SourcesSee how you got here
Scope variablesRight panel β†’ ScopeInspect local/closure/global
ConsoleBottom panelRun arbitrary JS in paused context
Edit live codeDouble-click in SourcesFix bug without restart
Conditional breakpointsRight-click line numberBreak only when condition is true
LogpointsRight-click β†’ Add logpointLog without changing code

4. CLI Debugger (node inspect)

If you don’t have Chrome (e.g., on a remote server via SSH):

node inspect app.js
# Debugger listening on ws://127.0.0.1:9229/...
# ok
debug> n           # next line
debug> s           # step into a function
debug> o           # step out of current function
debug> c           # continue to next breakpoint
debug> pause       # pause execution immediately
debug> repl        # open REPL (evaluate expressions)
debug> watch('myVar')  # watch a variable
debug> watchers    # list watched variables
debug> help        # show all commands
debug> quit        # exit debugger

CPU Profiling

Profiling identifies which functions consume the most CPU time. Without a profile, you’re guessing.

Using --cpu-prof

Node.js can generate a CPU profile file directly β€” no Chrome needed:

// cpu-heavy.js
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}

function main() {
  console.log('Computing fib(40)...');
  const result = fib(40);
  console.log('Result:', result);
}

main();
# Generate CPU profile
node --cpu-prof --cpu-prof-dir=./profiles cpu-heavy.js

# After execution, you'll find a file like:
# ./profiles/isolate-0x123456789-cpu-profile.cpuprofile

# Upload this file to chrome://inspect β†’ CPU Profiler β†’ Load

Understanding the Output

The .cpuprofile file is JSON. When you load it in Chrome DevTools:

  • Flame Chart β€” shows call stack over time. Wider bars = more CPU time
  • Heavy (Bottom Up) β€” sorted by total time, from most expensive to least
  • Tree (Top Down) β€” shows call hierarchy starting from the root

Look for functions with high self time β€” time spent inside the function excluding its children. These are your true bottlenecks.

Using Clinic.js

Clinic.js provides beautiful flamegraphs with zero setup:

npm install -g clinic

# CPU flamegraph
clinic doctor -- node app.js
# Opens a flamegraph in the browser
# Red/orange areas indicate hot functions

# More detailed view
clinic flame -- node app.js
# Interactive flamegraph β€” click any function to zoom

Memory Profiling

Memory leaks are insidious β€” they degrade performance slowly until the process runs out of memory.

Detecting a Memory Leak

// memory-leak.js
const leaks = [];

function createLeak() {
  // Each call allocates an array and keeps a reference
  const largeArray = new Array(10000).fill('memory leak data');
  leaks.push(largeArray); // Never released β€” growth forever
}

setInterval(createLeak, 100);

Taking Heap Snapshots

node --inspect memory-leak.js

In Chrome DevTools:

  1. Go to Memory tab
  2. Select β€œHeap snapshot” β†’ Take snapshot
  3. Wait a few seconds β†’ Take another snapshot
  4. Select the second snapshot β†’ Comparison view
  5. Filter by β€œDelta” (positive = growing objects)

What to look for:

  • Detached DOM nodes (not relevant for Node.js)
  • Closures holding large arrays
  • Event listeners that were never removed
  • Caches without eviction β€” grow unbounded

Programmatic Memory Monitoring

const v8 = require('v8');

function logMemory() {
  const usage = process.memoryUsage();
  const heap = v8.getHeapStatistics();

  console.log({
    rss: `${(usage.rss / 1024 / 1024).toFixed(1)} MB`,
    heapUsed: `${(usage.heapUsed / 1024 / 1024).toFixed(1)} MB`,
    heapTotal: `${(usage.heapTotal / 1024 / 1024).toFixed(1)} MB`,
    external: `${(usage.external / 1024 / 1024).toFixed(1)} MB`,
    heapLimit: `${(heap.heap_size_limit / 1024 / 1024).toFixed(1)} MB`,
    heapAvailable: `${(heap.total_available_size / 1024 / 1024).toFixed(1)} MB`,
  });
}

// Log every 5 seconds in development
if (process.env.NODE_ENV === 'development') {
  setInterval(logMemory, 5000);
}

Warning signs:

  • RSS growing continuously without plateaus
  • heapUsed approaching heapLimit
  • GC pauses becoming longer (measured via event loop lag)

Logging Strategies

Why Structured Logging?

Raw console.log is fine for development but useless in production. When you have 20 servers each producing 1000 lines per second, you need logs that machines can parse.

1. Structured JSON Logging

// structured-logger.js
const levels = { error: 0, warn: 1, info: 2, debug: 3 };

class Logger {
  constructor(name, level = 'info') {
    this.name = name;
    this.level = levels[level] ?? levels.info;
  }

  _log(level, message, meta = {}) {
    if (levels[level] > this.level) return;

    const entry = {
      timestamp: new Date().toISOString(),
      level,
      logger: this.name,
      message,
      ...meta,
    };
    const output = JSON.stringify(entry) + '\n';

    // Errors go to stderr, everything else to stdout
    if (level === 'error') {
      process.stderr.write(output);
    } else {
      process.stdout.write(output);
    }
  }

  error(msg, meta) { this._log('error', msg, meta); }
  warn(msg, meta) { this._log('warn', msg, meta); }
  info(msg, meta) { this._log('info', msg, meta); }
  debug(msg, meta) { this._log('debug', msg, meta); }
}

// Usage
const logger = new Logger('app', 'debug');
logger.info('Server started', { port: 3000, env: process.env.NODE_ENV });
logger.error('DB connection failed', { database: 'users', error: 'ECONNREFUSED' });

Output:

{"timestamp":"2026-06-13T10:30:00.000Z","level":"info","logger":"app","message":"Server started","port":3000,"env":"development"}
{"timestamp":"2026-06-13T10:30:00.001Z","level":"error","logger":"app","message":"DB connection failed","database":"users","error":"ECONNREFUSED"}

Why JSON: Structured logs are parseable by Logstash, Datadog, Grafana Loki, AWS CloudWatch, and every other log aggregator. Raw text logs require fragile regex parsing.

2. Using pino (Production-Ready)

Pino is the fastest Node.js logger β€” it produces JSON with minimal overhead:

npm install pino
npm install -D pino-pretty  # Human-readable output in dev
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Pretty-print in development
  transport: process.env.NODE_ENV === 'development'
    ? { target: 'pino-pretty', options: { colorize: true, translateTime: true } }
    : undefined,
});

// Usage β€” first argument is structured metadata, second is message
logger.info({ port: 3000 }, 'Server listening');
logger.error({ err: new Error('Connection timeout'), url: '/api/data' }, 'Request failed');
logger.warn({ diskUsage: '85%' }, 'Disk space running low');

// Pino handles Error objects correctly β€” includes stack trace
logger.error({ err: new Error('DB crash'), db: 'primary' }, 'Database failure');

3. Request Logging Middleware

Automatically log every HTTP request with timing:

const express = require('express');
const pino = require('pino');
const pinoHttp = require('pino-http');

const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
const app = express();

// Log all HTTP requests automatically
app.use(pinoHttp({
  logger,
  autoLogging: {
    ignore: (req) => req.url === '/health', // Don't log health checks
  },
}));

app.get('/api/users', async (req, res) => {
  req.log.info({ userId: req.query.id }, 'Fetching user');
  const users = await db.findUsers();
  res.json(users);
});

app.listen(3000);

Each request logs:

{"level":30,"time":"2026-06-13T10:30:00.000Z","req":{"method":"GET","url":"/api/users"},"res":{"statusCode":200},"responseTime":12}

Application Monitoring

Health Check Endpoint

Every production app should expose a health endpoint. Load balancers and orchestrators (Kubernetes, Docker Swarm) use this to decide if your app is alive:

// health.js
const express = require('express');
const app = express();

// Track health state
const health = {
  status: 'ok',
  uptime: process.uptime(),
  timestamp: Date.now(),
  checks: {
    database: false,
    memory: true,
  },
};

app.get('/health', async (req, res) => {
  try {
    await db.ping({ timeout: 2000 });
    health.checks.database = true;
    health.status = 'ok';
  } catch (err) {
    health.checks.database = false;
    health.status = 'degraded';
    // Don't crash β€” load balancer can route elsewhere
  }

  // Fail if memory exceeds 500MB
  health.checks.memory = process.memoryUsage().heapUsed < 500 * 1024 * 1024;
  if (!health.checks.memory) health.status = 'degraded';

  health.timestamp = Date.now();
  health.uptime = process.uptime();

  // Return 503 if degraded β€” orchestrator will restart
  const httpStatus = health.status === 'ok' ? 200 : 503;
  res.status(httpStatus).json(health);
});

app.listen(3000);

Kubernetes probe example:

livenessProbe:
  httpGet:
    path: /health
    port: 3000
  initialDelaySeconds: 10
  periodSeconds: 15

Graceful Shutdown

When your app receives SIGTERM (from Kubernetes, PM2, or a process manager), it should:

  1. Stop accepting new connections
  2. Finish in-flight requests
  3. Close database connections
  4. Exit cleanly
// shutdown.js
const server = app.listen(3000);

function shutdown(signal) {
  console.log(`Received ${signal}, shutting down gracefully...`);

  // Stop accepting new connections immediately
  server.close(() => {
    console.log('HTTP server closed β€” no more connections accepted');

    // Close database connection
    db.close(() => {
      console.log('Database connection closed');
      process.exit(0);
    });
  });

  // Force exit after 10 seconds β€” don't block forever
  setTimeout(() => {
    console.error('Forced shutdown after 10s timeout');
    process.exit(1); // Non-zero exit β€” orchestrator will handle
  }, 10000);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));  // Ctrl+C

Post-Mortem Debugging

When a process crashes in production (and you can’t attach a debugger), use these:

1. Core Dumps

# Enable core dumps
ulimit -c unlimited
node --abort-on-uncaught-exception app.js

# When it crashes, a core file is produced
# Analyze with:
gdb node core
# or
llnode core    # (npm install -g llnode)

2. Heap Dumps on Out-of-Memory

# Take a heap dump when an OOM happens
node --max-old-space-size=256 --heapsnapshot-signal=SIGUSR2 app.js
kill -USR2 <pid>  # Triggers heap snapshot

3. Process Managers (PM2)

npm install -g pm2
pm2 start app.js --name my-app
pm2 logs my-app           # View logs
pm2 monit                 # Real-time CPU/memory
pm2 dump                  # Save process list for resurrection

Production Checklist

Tool/PracticePurpose
node --inspectReal-time debugging with Chrome DevTools
node --cpu-profCPU profiling to find hot functions
clinic.jsFlamegraph visualisation
Heap snapshotsMemory leak detection via Chrome DevTools
Structured JSON loggingParseable logs for ELK/Datadog/Grafana
pinoFastest production logger
pino-httpAutomatic request/response logging
Health endpointContainer orchestration checks
Graceful shutdownSIGTERM β†’ clean exit (zero-dropped requests)
--heapsnapshot-signalTrigger heap dump on demand
SIGTERM handlerRequired for Kubernetes/Docker
Error-to-stderrLog errors to stderr, info to stdout

Key Takeaways

  • Use node --inspect or node --inspect-brk for Chrome DevTools debugging
  • The debugger statement inserts breakpoints directly in your code
  • Use node --cpu-prof or Clinic.js to find CPU bottlenecks without Chrome
  • Take heap snapshots in Chrome DevTools and compare them to detect memory leaks
  • Structured JSON logging (Pino) is essential for production β€” raw text logs don’t parse
  • Expose a /health endpoint with database and memory checks for orchestration
  • Graceful shutdown (SIGTERM β†’ close server β†’ close DB β†’ exit) prevents dropped connections
  • Always log to stdout/stderr β€” let Docker/systemd handle log routing
  • Use pino-http to automatically log every request with timing
  • Set up CPU profiling and heap snapshots before you need them β€” you can’t attach a debugger after a crash