Skip to content

Node.js Application Debugging -- Memory Leaks, Event Loop Blocking & Crash Analysis

DodaTech Updated 2026-06-23 8 min read

In this tutorial, you'll learn about Node.js Application Debugging. We cover key concepts, practical examples, and best practices.

Node.js applications can suffer from memory leaks, event loop blocking, and unhandled rejections that degrade performance silently over time -- this guide shows you how to detect and fix these issues using the built-in inspector, heap snapshots, and profiling tools.

What You'll Learn

Why It Matters

A Node.js memory leak that grows by 1MB per hour takes down a server in 3 days. An event loop blocked for 50ms makes your API feel sluggish. Knowing how to profile and debug Node.js is essential for anyone running it in production.

Real-World Use

When your Express API latency climbs from 20ms to 2000ms over 48 hours, your WebSocket server stops accepting new connections, or a production crash "out of memory" occurs at the same time every day, these debugging techniques reveal the root cause.

Common Node.js Issues Table

Issue Symptom Cause Diagnostic Tool
Memory leak RSS grows over time, eventually OOM Closures retaining references, global cache Heap snapshots, --inspect
Event loop blocking High latency under load CPU-heavy sync operations blocked-at, clinic.js
Unhandled promise rejection Deprecation warning, process crash Missing .catch() on promises --unhandled-rejections=strict
Memory leak in closures Objects retained after use Accidental closure over large data heapdump, Chrome DevTools
High CPU usage Event loop lag, slow responses Inefficient algorithms, tight loops clinic flame, node --prof
Garbage collection pauses Periodic latency spikes Large heap, frequent GC cycles --trace-gc, perf_hooks

Step-by-Step Fixes

Fix 1: Detect Memory Leaks with Heap Snapshots

// app.js -- Add heap snapshot generation
const fs = require('fs');
const http = require('http');

const server = http.createServer((req, res) => {
  // Simulate a leak: data retained across requests
  if (!global.leak) {
    global.leak = [];
  }
  global.leak.push(Buffer.alloc(1024 * 1024)); // 1MB per request

  res.writeHead(200);
  res.end('OK');
});

// Generate heap snapshot on SIGUSR2
process.on('SIGUSR2', () => {
  const heapdump = require('heapdump');
  const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(filename, () => {
    console.log(`Heap snapshot written to ${filename}`);
  });
});

server.listen(3000);
# Compare two heap snapshots to find growing objects
# 1. Start the server with inspector
node --inspect app.js

# 2. Take snapshot A (after warmup)
kill -USR2 $(pgrep -f app.js)

# 3. Generate load
for i in $(seq 1 100); do curl http://localhost:3000 &; done

# 4. Take snapshot B
kill -USR2 $(pgrep -f app.js)

# 5. Open both in Chrome DevTools -> Memory -> Load
# Compare to find objects that grew in count

Expected output:

Heap snapshot written to /tmp/heap-1719123456789.heapsnapshot
Heap snapshot written to /tmp/heap-1719123456999.heapsnapshot

Fix 2: Find Event Loop Blocking

// Use perf_hooks to measure event loop lag
const { monitorEventLoopDelay } = require('perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  // Simulate a blocking operation
  const start = Date.now();
  while (Date.now() - start < 100) {
    Math.sqrt(Math.random() * 100000);
  }

  console.log(`Event loop max delay: ${(histogram.max / 1e6).toFixed(2)}ms`);
  console.log(`p99 delay: ${(histogram.percentile(99) / 1e6).toFixed(2)}ms`);
}, 1000);
# Use clinic.js to visualize event loop blocking
npx clinic doctor -- node app.js

# Run load test while clinic is profiling
npx autocannon -c 10 -d 30 http://localhost:3000

# Open the generated flamegraph
clinic flame -- node app.js

Expected output:

Event loop max delay: 105.23ms
p99 delay: 98.45ms

Fix 3: Debug Unhandled Promise Rejections

// bad.js -- Unhandled rejection
async function fetchData() {
  throw new Error('API request failed');
}

fetchData(); // Promise rejection is unhandled -- crashes in Node 15+

// fixed.js -- Always handle rejections
async function fetchData() {
  throw new Error('API request failed');
}

fetchData().catch((err) => {
  console.error('Handled rejection:', err.message);
  // Log to monitoring system
});

// Global handler for any unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  process.exit(1); // Exit cleanly, let orchestrator restart
});

Expected output:

Handled rejection: API request failed

Fix 4: CPU Profiling with Built-in Tools

# Run the application with the built-in profiler
node --prof app.js

# Generate load
npx autocannon -c 20 -d 60 http://localhost:3000

# Stop the app and process the profiler output
node --prof-process isolate-*.log > processed-profile.txt

# View the top hotspots
head -50 processed-profile.txt
// Use console.profile for targeted profiling
function heavyComputation() {
  console.profile('heavyComputation');
  let result = 0;
  for (let i = 0; i < 10000000; i++) {
    result += Math.sqrt(i);
  }
  console.profileEnd('heavyComputation');
  return result;
}

heavyComputation(); // Shows timing in Chrome DevTools Performance tab

Expected output:

 [top 10]:
   ticks  total  nonlib   name
   4521   45.2%  52.3%    JavaScript
   1234   12.3%  14.2%    C++
    789    7.9%   9.1%    GC

Fix 5: Optimize Garbage Collection

// Monitor GC pauses
const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((list) => {
  const entry = list.getEntries()[0];
  if (entry.duration > 50) {
    console.warn(`Long GC pause: ${entry.duration.toFixed(2)}ms`);
  }
});
obs.observe({ entryTypes: ['gc'] });

// Force GC at specific times instead of unpredictable pauses
// Start with --expose-gc flag: node --expose-gc app.js
if (global.gc) {
  setInterval(() => {
    global.gc();
    console.log('Manual GC triggered');
  }, 30000).unref();
}
# Run with GC tracing
node --trace-gc app.js 2>&1 | grep "Mark-sweep" | head -5

Expected output:

[12345:0x1234567]  12345 ms: Mark-sweep 145.2 -> 98.3 MB, 45.2 ms
[12345:0x1234567]  45678 ms: Mark-sweep 198.4 -> 102.1 MB, 62.8 ms  <-- long pause

Node.js Debugging Flowchart

flowchart TD
    A[Node.js Performance Issue] --> B{Symptom?}
    B -->|Memory grows over time| C[Take heap snapshots]
    C --> D[Compare snapshots in DevTools]
    D --> E[Find objects retained across requests]
    B -->|High latency spikes| F[Check event loop lag]
    F --> G[Use clinic.js or perf_hooks]
    G --> H[Identify blocking operations]
    B -->|Process crash| I{Check error type}
    I -->|OOM| J[Analyze heap with --max-old-space-size]
    I -->|Unhandled rejection| K[Add .catch to all promises]
    I -->|Segfault| L[Check native modules for compat]
    B -->|CPU 100%| M[Run node --prof]
    M --> N[Process with --prof-process]
    N --> O[Optimize hot functions]
    E --> P[Application Stable]
    H --> P
    K --> P
    O --> P

Prevention Tips

  • Set --max-old-space-size to limit heap and fail fast instead of swapping
  • Always handle promise rejections with .catch() or try-catch in async functions
  • Use the clinic.js tool suite in your CI pipeline to detect performance regressions
  • Monitor event loop delay with monitorEventLoopDelay() and alert on p99 > 50ms
  • Use --inspect in development and profile regularly with heap snapshots
  • Set NODE_OPTIONS="--unhandled-rejections=strict" in production to fail fast on unhandled rejections

Practice Questions

  1. How do you detect a memory leak in a running Node.js process without restarting it? Answer: Use process.on('SIGUSR2', ...) to trigger heap snapshot generation at runtime. Take two snapshots at different times, load them into Chrome DevTools Memory tab, and use the "Comparison" view to find objects that grew in count or retained size.

  2. What causes event loop blocking in Node.js and how do you find the culprit? Answer: Event loop blocking is caused by synchronous CPU-heavy operations (large JSON parsing, crypto operations, tight loops) running on the main thread. Use monitorEventLoopDelay() from perf_hooks or clinic.js to measure lag, then use flamegraphs to identify the blocking functions.

  3. What is the difference between --inspect and --prof when debugging Node.js? Answer: --inspect opens the Chrome DevTools protocol for real-time debugging, heap snapshots, and CPU profiling with a GUI. --prof generates V8 profiler output that can be processed with node --prof-process to produce text-based call statistics. Use --inspect for interactive debugging and --prof for production profiling.

  4. Challenge: Write an Express.js middleware that monitors response times and event loop lag, logging warnings when either exceeds a threshold. Answer:

    const { monitorEventLoopDelay } = require('perf_hooks');
    const histogram = monitorEventLoopDelay({ resolution: 20 });
    histogram.enable();
    
    app.use((req, res, next) => {
      const start = Date.now();
      res.on('finish', () => {
        const elapsed = Date.now() - start;
        const lag = histogram.percentile(99) / 1e6;
        if (elapsed > 1000 || lag > 50) {
          console.warn({ path: req.path, elapsed, lagMs: lag });
        }
      });
      next();
    });
    

Quick Reference

Issue Diagnostic Command Resolution
Memory leak Heap snapshots via Chrome DevTools Fix closure references, clear caches
Event loop lag monitorEventLoopDelay() Offload CPU work to worker threads
Unhandled rejection --unhandled-rejections=strict Add .catch() to every promise
High CPU node --prof app.js Profile and optimize hot functions
Long GC pauses node --trace-gc Reduce heap size, tune GC flags

FAQ

How do you debug a Node.js production server without exposing the inspector port to the internet?

SSH tunnel the inspector port to your local machine: ssh -L 9229:localhost:9229 production-server then run node --inspect-brk=0.0.0.0:9229 app.js inside the server (listening on localhost only). Connect Chrome DevTools to chrome://inspect on your local machine -- the connection is encrypted through SSH.

What is the best way to handle promises that you intentionally do not await?

Attach a noop .catch() to prevent unhandled rejections: someAsyncOp().catch(() => {}) or use a dedicated error logger: someAsyncOp().catch(err => logger.error('Background job failed', err)). Never leave promises floating silently -- even fire-and-forget operations need error handling to avoid silent failures.

Why does --max-old-space-size vary between Node.js versions and how do you choose the right value?

The default V8 heap size depends on available RAM and Node.js version. On systems with less than 2GB RAM, the default is 512MB. On systems with more, it varies up to ~1.4GB. Set --max-old-space-size to 75% of available RAM for the application, leaving headroom for the OS and other processes. Monitor with process.memoryUsage().heapUsed to validate.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro