Săn memory leak Node.js trên production — Từ hoảng loạn đến root cause

4 giờ sáng, PagerDuty kêu. API server restart lần thứ 3 trong ngày. Grafana cho thấy RSS memory tăng đều từ 200MB lên 1.5GB trong khoảng 18 tiếng, rồi OOM killer xử lý. PM2 restart, memory reset về 200MB, rồi lại leo dần. Pattern kinh điển của memory leak.

Bài này chia sẻ workflow tôi dùng để debug memory leak trên production Node.js — không phải toy example, mà là server đang serve 2000 req/s.

Bước 1: Xác nhận đúng là memory leak

Không phải mọi trường hợp memory tăng đều là leak. Node.js V8 garbage collector không chạy liên tục — nó chờ đến khi cần. Memory có thể tăng rồi giảm theo chu kỳ GC, đó là hành vi bình thường.

Dấu hiệu thật sự là leak:

  • Memory tăng đều (monotonic increase) qua nhiều GC cycles
  • Không bao giờ giảm về baseline dù traffic giảm
  • Cuối cùng crash với OOM

Confirm bằng cách expose GC metrics:

// Theo dõi heap used sau mỗi GC cycle
const v8 = require('v8');

setInterval(() => {
  const heap = v8.getHeapStatistics();
  console.log(JSON.stringify({
    type: 'heap_stats',
    heap_used_mb: Math.round(heap.used_heap_size / 1024 / 1024),
    heap_total_mb: Math.round(heap.total_heap_size / 1024 / 1024),
    external_mb: Math.round(heap.external_memory / 1024 / 1024),
    timestamp: Date.now(),
  }));
}, 60_000);

Nếu heap_used_mb tăng liên tục qua hàng giờ dù traffic ổn định — bạn có memory leak.

💡 Phân biệt heap growth vs RSS growth. Heap do V8 quản lý (JS objects). RSS bao gồm cả native memory (Buffers, C++ addons). Leak có thể ở cả hai.

Bước 2: Thu thập heap snapshot trên production

Đây là bước nhiều người ngại vì sợ ảnh hưởng performance. Thực tế: heap snapshot sẽ pause event loop — nhưng có cách giảm thiểu.

Cách an toàn: Signal-based snapshot

// Thêm vào entry point của server
process.on('SIGUSR2', () => {
  const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
  require('v8').writeHeapSnapshot(filename);
  console.log(`Heap snapshot written to ${filename}`);
});

Khi cần snapshot:

# Tìm PID
pgrep -f "node server.js"

# Gửi signal
kill -USR2 <pid>

Strategy: 2 snapshots cách nhau

Thu thập 2 snapshots cách nhau khoảng thời gian memory tăng đáng kể (ví dụ 2-4 tiếng):

# Snapshot 1: khi server mới start được 1 giờ (baseline)
kill -USR2 <pid>
# → /tmp/heap-1711900000000.heapsnapshot

# Đợi 3 tiếng...

# Snapshot 2: khi memory đã tăng đáng kể
kill -USR2 <pid>
# → /tmp/heap-1711910800000.heapsnapshot

⚠️ Heap snapshot size tỷ lệ với heap size. Heap 500MB → snapshot file khoảng 500MB-1GB. Đảm bảo /tmp có đủ dung lượng, và event loop pause có thể kéo dài 2-10 giây tuỳ heap size. Nếu server đang serve traffic, hãy drain 1 instance khỏi load balancer trước khi snapshot.

Bước 3: Phân tích heap snapshot

Chrome DevTools

Cách truyền thống và vẫn hiệu quả nhất:

  1. Mở Chrome → chrome://inspect → Open dedicated DevTools for Node.js
  2. Memory tab → Load snapshot file
  3. Chọn Comparison view, compare snapshot 2 với snapshot 1

Comparison view hiển thị:

  • # New: objects mới được allocate giữa 2 snapshots
  • # Deleted: objects đã được GC
  • # Delta: = New - Deleted. Đây là con số quan trọng nhất
  • Size Delta: memory tăng thêm

Sort theo Size Delta giảm dần. Top entries thường chỉ thẳng vào root cause.

Trường hợp của tôi: Closures giữ reference

Comparison view cho thấy:

  • (closure) delta: +45,000 objects, +12MB
  • (string) delta: +120,000 objects, +35MB
  • IncomingMessage delta: +2,000 objects, +8MB

Click vào (closure) → Retainers tab → thấy chuỗi reference:

(closure) → requestHandler → cache (Map) → [entries]

Một Map object đang giữ reference đến tất cả closures. Tìm trong code:

// middleware/cache.js — THỦ PHẠM
const responseCache = new Map();

function cacheMiddleware(req, res, next) {
  const key = req.url + JSON.stringify(req.query);

  if (responseCache.has(key)) {
    return res.json(responseCache.get(key));
  }

  // Override res.json để cache response
  const originalJson = res.json.bind(res);
  res.json = (data) => {
    responseCache.set(key, data);  // ← LEAK: Map chỉ set, không bao giờ delete
    originalJson(data);
  };

  next();
}

Map cache không có giới hạn size, không có TTL, không có eviction. Mỗi unique URL + query combination tạo một entry mới, và không bao giờ bị xoá. Với 2000 req/s và URL combinations đa dạng — map phình lên vô tận.

Bước 4: Fix và verify

Fix ngay: Bounded cache với TTL

// middleware/cache.js — SAU KHI FIX
const CACHE_MAX_SIZE = 1000;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 phút

const responseCache = new Map();

function cacheMiddleware(req, res, next) {
  const key = req.url;
  const cached = responseCache.get(key);

  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
    return res.json(cached.data);
  }

  const originalJson = res.json.bind(res);
  res.json = (data) => {
    // Evict oldest entries nếu vượt giới hạn
    if (responseCache.size >= CACHE_MAX_SIZE) {
      const oldestKey = responseCache.keys().next().value;
      responseCache.delete(oldestKey);
    }

    responseCache.set(key, { data, timestamp: Date.now() });
    originalJson(data);
  };

  next();
}

Fix tốt hơn: Dùng LRU Cache

import { LRUCache } from 'lru-cache';

const responseCache = new LRUCache({
  max: 1000,            // max entries
  ttl: 5 * 60 * 1000,   // 5 phút TTL
  maxSize: 50_000_000,   // 50MB max total size
  sizeCalculation: (value) => JSON.stringify(value).length,
});

💡 Quy tắc vàng: mọi in-memory cache đều phải có upper bound. Không có ngoại lệ. MapSet không có giới hạn built-in — nếu bạn dùng chúng làm cache, bạn phải tự implement eviction.

Các pattern leak phổ biến khác

1. Event listener không được remove

// LEAK: listener tích lũy mỗi request
function handleRequest(req, res) {
  const onData = (chunk) => processChunk(chunk);
  externalStream.on('data', onData);
  // Quên remove listener khi request kết thúc
}

// FIX
function handleRequest(req, res) {
  const onData = (chunk) => processChunk(chunk);
  externalStream.on('data', onData);

  res.on('finish', () => {
    externalStream.removeListener('data', onData);
  });
}

Node.js cảnh báo khi EventEmitter có >10 listeners (mặc định). Nếu bạn thấy warning MaxListenersExceededWarningđừng tăng limit, hãy fix leak.

2. Global arrays/objects tích lũy

// LEAK: log buffer không bao giờ flush
const logBuffer = [];

function log(message) {
  logBuffer.push({
    message,
    timestamp: Date.now(),
    stack: new Error().stack,  // Stack trace nặng
  });

  // "Sẽ flush sau" — không bao giờ flush
}

3. Closures giữ reference không cần thiết

// LEAK: closure giữ reference đến toàn bộ `largeData`
function processRequest(req) {
  const largeData = loadLargeDataset(); // 10MB

  return {
    getSummary() {
      // Chỉ dùng largeData.length nhưng closure giữ cả object
      return { count: largeData.length };
    }
  };
}

// FIX: extract giá trị cần thiết trước khi tạo closure
function processRequest(req) {
  const largeData = loadLargeDataset();
  const count = largeData.length; // Extract ra

  return {
    getSummary() {
      return { count }; // Closure chỉ giữ số, không giữ cả dataset
    }
  };
}

4. Timer không được clear

// LEAK: setInterval trong connection handler
function onConnection(socket) {
  const heartbeat = setInterval(() => {
    socket.ping();
  }, 30_000);

  // Nếu socket disconnect mà không trigger 'close' event
  // → interval chạy mãi, giữ reference đến socket
  socket.on('close', () => {
    clearInterval(heartbeat); // ← BẮT BUỘC
  });
}

Tools hữu ích

clinicjs — profiling suite cho Node.js

# Detect memory issues
npx clinic doctor -- node server.js
# Chạy traffic vào server, rồi Ctrl+C
# → Tự động generate HTML report phân tích

# Heap profiling chi tiết
npx clinic heapprofiler -- node server.js

--inspect flag trên production

# Mở debugger port (chỉ listen localhost)
node --inspect=127.0.0.1:9229 server.js

Rồi SSH tunnel từ local:

ssh -L 9229:127.0.0.1:9229 user@production-server

Mở Chrome DevTools, connect đến localhost:9229. Bạn có thể take heap snapshot, record allocation timeline, và inspect objects — trực tiếp trên production process.

⚠️ Không bao giờ expose debug port ra public interface. --inspect=0.0.0.0:9229 trên production = remote code execution vulnerability.

process.memoryUsage() cho monitoring

// Expose qua health check endpoint
app.get('/health', (req, res) => {
  const mem = process.memoryUsage();
  res.json({
    rss_mb: Math.round(mem.rss / 1024 / 1024),
    heap_used_mb: Math.round(mem.heapUsed / 1024 / 1024),
    heap_total_mb: Math.round(mem.heapTotal / 1024 / 1024),
    external_mb: Math.round(mem.external / 1024 / 1024),
    array_buffers_mb: Math.round(mem.arrayBuffers / 1024 / 1024),
    uptime_hours: Math.round(process.uptime() / 3600 * 10) / 10,
  });
});

Scrape endpoint này bằng Prometheus → Grafana dashboard → alert khi heap_used tăng quá threshold.

Checklist phòng ngừa

  • Mọi Map/Set dùng làm cache → phải có max size + TTL
  • Mọi addEventListener/.on() → phải có corresponding removeListener
  • Mọi setInterval/setTimeout → phải clear khi không cần
  • Closures trong long-lived objects → kiểm tra reference đến large objects
  • Monitor process.memoryUsage() liên tục, alert sớm trước khi OOM
  • Heap snapshot script sẵn sàng trên mọi production server

Memory leak trên production không phải lúc nào cũng drama. Đa số trường hợp là một Map quên eviction, một listener quên remove, một closure giữ reference thừa. Workflow cố định: confirm leak → snapshot → compare → tìm retainer chain → fix → verify. Không có phép màu, chỉ có kỷ luật.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.