Skip to content

Debugging Memory Issues in Production Systems

Techniques for diagnosing memory leaks, excessive allocation, and OOM kills in production — covering heap snapshots, profiling tools, and common leak patterns.

4 min read
Memory usage graph showing a gradual leak pattern over time with sawtooth garbage collection

The server's memory usage climbs steadily over hours. At some point, the container hits its memory limit and Kubernetes kills it with OOMKilled. The pod restarts, memory starts low, and the climb begins again. This sawtooth pattern is the signature of a memory leak.

Fixing memory issues in production requires different techniques than development debugging. You cannot attach a debugger to a production process casually. You need low-overhead tools that capture enough information to identify the leak without impacting user traffic.

Recognizing the Patterns

Memory problems manifest in three distinct patterns, each with different causes:

Gradual leak (sawtooth):
Memory ▲
       │    /|    /|    /|
       │   / |   / |   / |  ← OOMKill + restart
       │  /  |  /  |  /  |
       │ /   | /   | /   |
       └─────┴─────┴─────── Time

Sudden spike:
Memory ▲
       │         ┌─┐
       │         │ │
       │─────────┘ └─────── ← One request/event causes massive allocation
       └─────────────────── Time

Slow growth (no release):
Memory ▲
       │              ┌────
       │         ┌────┘
       │    ┌────┘
       │────┘                ← Caches, pools, or closures growing without bound
       └─────────────────── Time
shbash
# Monitor memory patterns in Kubernetes
kubectl top pods -w --sort-by=memory
 
# Check for OOMKill events
kubectl get events --field-selector reason=OOMKilling
 
# Get memory details for a specific pod
kubectl describe pod api-server-7d5f8b6c4-abc12 | grep -A5 "Last State"

Heap Snapshots in Node.js

Heap snapshots capture the entire memory state. Comparing two snapshots taken minutes apart reveals objects that grew — the likely leak candidates.

tstypescript
// Expose a debug endpoint for heap snapshots (behind auth)
import v8 from 'v8';
import fs from 'fs';
import path from 'path';
 
// Only expose in controlled environments
function registerDebugEndpoints(app: Express) {
  app.post('/debug/heap-snapshot', authMiddleware, async (req, res) => {
    const snapshotPath = path.join(
      '/tmp',
      `heap-${Date.now()}.heapsnapshot`
    );
 
    const snapshotStream = v8.writeHeapSnapshot(snapshotPath);
    res.json({
      path: snapshotStream,
      timestamp: new Date().toISOString(),
      heapUsed: process.memoryUsage().heapUsed,
    });
  });
 
  app.get('/debug/memory', authMiddleware, (req, res) => {
    const mem = process.memoryUsage();
    res.json({
      rss: `${(mem.rss / 1024 / 1024).toFixed(1)}MB`,
      heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB`,
      heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`,
      external: `${(mem.external / 1024 / 1024).toFixed(1)}MB`,
      arrayBuffers: `${(mem.arrayBuffers / 1024 / 1024).toFixed(1)}MB`,
    });
  });
}
shbash
# Workflow: take two snapshots 5 minutes apart
curl -X POST https://internal-api/debug/heap-snapshot  # snapshot 1
sleep 300
curl -X POST https://internal-api/debug/heap-snapshot  # snapshot 2
 
# Download snapshots and open in Chrome DevTools:
# 1. Open chrome://inspect → Memory tab
# 2. Load both snapshots
# 3. Select "Comparison" view between snapshot 1 and 2
# 4. Sort by "Size Delta" — largest growth = likely leak

Common Leak Patterns

Most memory leaks in Node.js fall into a few recurring categories.

tstypescript
// ❌ Leak Pattern 1: Event listeners accumulating
class DataProcessor {
  constructor(private emitter: EventEmitter) {}
 
  process(data: unknown) {
    // Each call adds a NEW listener — they never get removed
    this.emitter.on('update', () => {
      this.handleUpdate(data);  // Closure captures 'data'
    });
  }
}
 
// ✅ Fixed: remove listeners when done
class DataProcessor {
  private handler: (() => void) | null = null;
 
  process(data: unknown) {
    // Remove previous listener before adding new one
    if (this.handler) {
      this.emitter.off('update', this.handler);
    }
    this.handler = () => this.handleUpdate(data);
    this.emitter.on('update', this.handler);
  }
 
  destroy() {
    if (this.handler) {
      this.emitter.off('update', this.handler);
      this.handler = null;
    }
  }
}
tstypescript
// ❌ Leak Pattern 2: Unbounded cache
const cache = new Map<string, UserData>();
 
async function getUser(id: string): Promise<UserData> {
  if (cache.has(id)) return cache.get(id)!;
  const user = await db.users.findById(id);
  cache.set(id, user);  // Map grows forever
  return user;
}
 
// ✅ Fixed: bounded cache with LRU eviction
import { LRUCache } from 'lru-cache';
 
const cache = new LRUCache<string, UserData>({
  max: 1000,           // Maximum entries
  ttl: 1000 * 60 * 5,  // 5-minute TTL
});
 
async function getUser(id: string): Promise<UserData> {
  const cached = cache.get(id);
  if (cached) return cached;
  const user = await db.users.findById(id);
  cache.set(id, user);
  return user;
}
tstypescript
// ❌ Leak Pattern 3: Closures holding references
function createHandler(largeDataset: Buffer) {
  // The returned function captures the entire largeDataset
  // Even if the handler only needs a small piece
  return (req: Request) => {
    const id = req.params.id;
    return largeDataset.slice(0, 100);
  };
  // largeDataset (possibly MB) stays in memory as long as
  // the handler exists
}
 
// ✅ Fixed: extract only what you need before the closure
function createHandler(largeDataset: Buffer) {
  const header = Buffer.from(largeDataset.slice(0, 100));
  // largeDataset can now be garbage collected
  return (req: Request) => {
    return header;
  };
}

Continuous Memory Monitoring

Do not wait for OOMKills to discover leaks. Monitor memory metrics continuously and alert on abnormal growth rates.

tstypescript
// Expose Prometheus-compatible memory metrics
import { collectDefaultMetrics, register, Gauge } from 'prom-client';
 
collectDefaultMetrics({ prefix: 'app_' });
 
const heapGauge = new Gauge({
  name: 'app_heap_used_bytes',
  help: 'Process heap used in bytes',
});
 
const rssGauge = new Gauge({
  name: 'app_rss_bytes',
  help: 'Process resident set size in bytes',
});
 
// Update metrics every 15 seconds
setInterval(() => {
  const mem = process.memoryUsage();
  heapGauge.set(mem.heapUsed);
  rssGauge.set(mem.rss);
}, 15000);
 
// Metrics endpoint
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});
ymlyaml
# Prometheus alert rule: detect memory leak pattern
groups:
  - name: memory
    rules:
      - alert: MemoryLeakDetected
        expr: |
          deriv(app_heap_used_bytes[1h]) > 1048576
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Possible memory leak in {{ $labels.pod }}"
          description: "Heap memory growing at >1MB/hour for 30 minutes"

The deriv() function in PromQL calculates the rate of change. If heap memory consistently grows at more than 1MB per hour for 30 minutes, something is accumulating.

Profiling Without Production Impact

Heap snapshots are expensive — they pause the process for seconds. For production profiling, use allocation sampling, which has minimal overhead.

tstypescript
// Allocation sampling — low overhead, production-safe
import { Session } from 'inspector';
 
async function captureAllocationProfile(durationMs: number): Promise<Buffer> {
  const session = new Session();
  session.connect();
 
  return new Promise((resolve) => {
    session.post('HeapProfiler.startSampling', {
      samplingInterval: 32768,  // Sample every 32KB allocated
    });
 
    setTimeout(() => {
      session.post('HeapProfiler.stopSampling', (err, result) => {
        session.disconnect();
        const profile = JSON.stringify(result?.profile);
        resolve(Buffer.from(profile));
      });
    }, durationMs);
  });
}
 
// Capture a 30-second allocation profile
app.post('/debug/allocation-profile', authMiddleware, async (req, res) => {
  const profile = await captureAllocationProfile(30000);
  res.setHeader('Content-Disposition',
    `attachment; filename="alloc-${Date.now()}.heapprofile"`);
  res.send(profile);
});
shbash
# For non-Node.js systems: use system-level tools
# Linux memory profiling
valgrind --tool=massif ./your-binary
 
# JVM heap dump
jmap -dump:format=b,file=heap.bin <pid>
 
# Go pprof
go tool pprof http://localhost:6060/debug/pprof/heap

Container Memory Configuration

Misconfigured container limits cause OOMKills even without leaks. The process thinks it has more memory than the container allows.

ymlyaml
# ❌ Node.js default heap limit (1.7GB) exceeds container limit (512MB)
spec:
  containers:
    - name: api
      image: myregistry/api:latest
      resources:
        limits:
          memory: "512Mi"
      # Node.js will try to use 1.7GB → OOMKilled
 
# ✅ Match Node.js heap limit to container limit
spec:
  containers:
    - name: api
      image: myregistry/api:latest
      resources:
        requests:
          memory: "256Mi"
        limits:
          memory: "512Mi"
      env:
        - name: NODE_OPTIONS
          value: "--max-old-space-size=384"
          # 384MB heap + ~128MB overhead = within 512MB container limit
shbash
# Rule of thumb for Node.js:
# --max-old-space-size = container_limit * 0.75
# The remaining 25% covers:
# - V8 overhead (new space, code space)
# - Native memory (buffers, C++ objects)
# - OS page cache

Key Takeaways

  1. Recognize the pattern first — gradual leak, sudden spike, and slow growth have different root causes and different fixes
  2. Compare heap snapshots — two snapshots minutes apart reveal what is growing
  3. Bound every cache — use LRU with a max size, never an unbounded Map
  4. Remove event listeners explicitly — accumulated listeners are the most common Node.js leak
  5. Monitor memory growth rate, not absolute value — deriv() in Prometheus catches leaks before they cause OOMKills
  6. Match Node.js heap limit to container limit — set --max-old-space-size to 75% of the container memory limit
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX