Performance Profiling

Difficulty: Advanced

Question

How do you identify and fix performance bottlenecks in a Node.js application? Describe your profiling workflow.

Answer

Performance profiling in Node.js involves identifying CPU bottlenecks, memory leaks, slow database queries, and event loop blocking. The process starts with monitoring production metrics, reproducing the issue, profiling to find the root cause, and verifying the fix.

Node.js provides built-in tools: --inspect flag for Chrome DevTools debugging, --prof for V8 profiler output, and process.memoryUsage() for memory tracking. Third-party tools like clinic.js provide flame graphs, event loop analysis, and heap profiling.

Memory leaks are the most common production issue. They occur when objects accumulate in memory and are never garbage collected. Common causes: growing arrays/maps, unclosed event listeners, closures holding references, and global caches without eviction. Heap snapshots taken at intervals can reveal growing object counts.

For CPU profiling, flame graphs show which functions consume the most time. Look for wide bars (slow functions) and tall stacks (deep call chains). Event loop delay monitoring reveals if synchronous work is blocking the loop. A delay above 100ms typically indicates a problem.

Code examples

Memory Leak Detection

// Monitor memory usage
function logMemory() {
  const usage = process.memoryUsage();
  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`,
  });
}
setInterval(logMemory, 10000);

// Common memory leak: growing array
const cache = []; // LEAK: grows forever!
app.get('/api/data', (req, res) => {
  const data = fetchData();
  cache.push(data); // Never cleaned up
  res.json(data);
});

// Fix: use LRU cache with max size
const LRU = require('lru-cache');
const cache = new LRU({ max: 1000, ttl: 1000 * 60 * 5 }); // 1000 items, 5min TTL

// Common leak: event listeners
const EventEmitter = require('events');
const emitter = new EventEmitter();

app.get('/api/stream', (req, res) => {
  // LEAK: new listener added every request, never removed
  emitter.on('data', (d) => res.write(d));
  // FIX: remove listener when request ends
  const handler = (d) => res.write(d);
  emitter.on('data', handler);
  req.on('close', () => emitter.removeListener('data', handler));
});

Growing heapUsed over time indicates a memory leak. LRU caches auto-evict old entries. Always remove event listeners when they are no longer needed.

CPU Profiling and Event Loop Monitoring

// Event loop delay monitoring
const { monitorEventLoopDelay } = require('perf_hooks');

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

setInterval(() => {
  console.log({
    min: `${(histogram.min / 1e6).toFixed(1)}ms`,
    max: `${(histogram.max / 1e6).toFixed(1)}ms`,
    mean: `${(histogram.mean / 1e6).toFixed(1)}ms`,
    p99: `${(histogram.percentile(99) / 1e6).toFixed(1)}ms`,
  });
  histogram.reset();
}, 5000);

// CPU profiling with --inspect
// node --inspect src/index.js
// Open chrome://inspect in Chrome
// -> Performance tab -> Record -> Run workload -> Stop

// Programmatic CPU profiling
const v8Profiler = require('v8-profiler-next');

app.get('/debug/profile', (req, res) => {
  const title = `profile-${Date.now()}`;
  v8Profiler.startProfiling(title, true);

  setTimeout(() => {
    const profile = v8Profiler.stopProfiling(title);
    profile.export((error, result) => {
      res.setHeader('Content-Type', 'application/json');
      res.setHeader('Content-Disposition', `attachment; filename=${title}.cpuprofile`);
      res.send(result);
      profile.delete();
    });
  }, 10000); // Profile for 10 seconds
});

Event loop delay shows if synchronous work is blocking. Values above 100ms indicate problems. CPU profiles can be loaded into Chrome DevTools for analysis.

Benchmarking and Load Testing

// Simple benchmarking with console.time
console.time('db-query');
const users = await prisma.user.findMany({ take: 1000 });
console.timeEnd('db-query'); // db-query: 45.2ms

// Performance hooks for detailed measurement
const { performance, PerformanceObserver } = require('perf_hooks');

// Measure function execution time
performance.mark('start');
await heavyOperation();
performance.mark('end');
performance.measure('heavyOperation', 'start', 'end');

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.duration.toFixed(1)}ms`);
  }
});
observer.observe({ entryTypes: ['measure'] });

// Express middleware to track response times
app.use((req, res, next) => {
  const start = performance.now();
  res.on('finish', () => {
    const duration = performance.now() - start;
    if (duration > 1000) {
      console.warn(`SLOW: ${req.method} ${req.path} took ${duration.toFixed(0)}ms`);
    }
  });
  next();
});

// Load testing with autocannon (CLI)
// npx autocannon -c 100 -d 30 http://localhost:3000/api/users
// -c 100: 100 concurrent connections
// -d 30: run for 30 seconds
// Output: requests/sec, latency percentiles, throughput

Use console.time for quick checks, perf_hooks for detailed measurement, and autocannon for load testing. Track slow routes in production.

Key points

Concepts covered

Profiling, Memory Leaks, CPU Profiling, Heap Snapshot, Benchmarking