Node.js process của mình từ 180MB RSS lên 1.4GB sau 3 ngày mà không có lý do rõ ràng. Restart thì hết. Hôm sau lại tăng. Tuần tiếp theo thì OOM kill lúc 3h sáng, production down 7 phút trước khi PM phát hiện.

Pattern này đặc trưng đến mức mình nhận ra ngay: memory leak kiểu “slow bleed” — không crash ngay, không có exception, chỉ tăng dần cho đến khi hệ thống tự xử lý theo cách không ai muốn.

Bài này là những gì mình đã làm để tìm ra nguyên nhân, cố định nó, và thêm vài guardrail để lần sau không lặp lại cái vòng lặp đó.


RSS vs Heap: leak nằm ở đâu?

Trước khi bắt đầu hunt, cần hiểu đang tìm gì.

RSS (Resident Set Size) là tổng memory process đang dùng — bao gồm V8 heap, native buffers, shared libraries, và các thứ khác OS allocate cho process. Đây là con số bạn thấy trong htop hay docker stats.

Heap là phần V8 quản lý — nơi JavaScript objects sống. V8 có GC để thu dọn objects không còn reference. Nếu heap tăng mãi dù GC chạy, là có vật nào đó “giữ” reference không buông.

Hiểu nôm na: RSS là tổng diện tích căn nhà, heap là phòng khách nơi mình để đồ. Có thể phòng khách sạch nhưng toàn bộ nhà vẫn đầy — buffer, native addon, memory-mapped file đều là “phòng khác” nằm ngoài heap.

// Monitor memory định kỳ — đặt trong background interval
function logMemoryUsage(): void {
  const mem = process.memoryUsage();
  console.log({
    rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
    heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
    heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
    external: `${Math.round(mem.external / 1024 / 1024)}MB`, // native buffers
  });
}

setInterval(logMemoryUsage, 30_000); // mỗi 30 giây

Nếu heapUsed tăng cùng nhịp với rss, leak nằm trong JavaScript code. Nếu rss tăng nhưng heapUsed ổn định, look at external — có thể là Buffer, native addon, hoặc child_process spawn quá nhiều.

Với case của mình: heapUsed tăng từ 120MB lên 1.1GB trong 3 ngày. Leak nằm trong V8 heap — JavaScript code thuần.


4 nguyên nhân phổ biến nhất

1. EventEmitter không removeListener

Đây là “classic” — xuất hiện trong mọi tutorial về memory leak Node.js, và vẫn gặp đều trong production.

// ❌ Leak: mỗi request tạo listener mới, không bao giờ remove
class RequestHandler {
  constructor(private emitter: EventEmitter) {}

  handle(req: Request): void {
    // Listener được add vào emitter mỗi lần handle() được gọi
    this.emitter.on("data", (data) => {
      processData(data, req.userId);
    });
  }
}

Sau 500k request, emitter có 500k listeners. Mỗi listener giữ reference đến req.userId và closure scope xung quanh. Node.js thậm chí cảnh báo khi vượt quá 10 listeners với MaxListenersExceededWarning — nhưng default thì chỉ warn, không throw.

// ✅ Fix: cleanup sau khi dùng xong
class RequestHandler {
  constructor(private emitter: EventEmitter) {}

  handle(req: Request): void {
    const listener = (data: unknown) => {
      processData(data, req.userId);
    };

    this.emitter.on("data", listener);

    // Cleanup khi request done — hoặc dùng once() nếu chỉ cần nghe 1 lần
    req.on("close", () => {
      this.emitter.removeListener("data", listener);
    });
  }
}

Hoặc dùng emitter.once() nếu chỉ cần nghe một lần. once() tự remove listener sau khi event fire lần đầu.

2. Closure giữ reference đến object lớn

JavaScript closure capture toàn bộ scope xung quanh nó — kể cả những thứ không cần dùng.

// ❌ Leak: closure giữ toàn bộ `config` object (có thể rất lớn)
function createProcessor(config: HugeConfigObject) {
  const apiKey = config.apiKey; // chỉ cần apiKey

  // Nhưng closure này capture cả `config` vì nó nằm trong same scope
  return function processItem(item: Item) {
    return fetch(apiKey + "/process", { body: JSON.stringify(item) });
  };
}
// ✅ Fix: chỉ lấy thứ cần thiết ra khỏi scope
function createProcessor(config: HugeConfigObject) {
  const { apiKey } = config; // destructure ra biến riêng

  return function processItem(item: Item) {
    return fetch(apiKey + "/process", { body: JSON.stringify(item) });
  };
  // config có thể được GC sau khi createProcessor return
}

3. Module-level cache không có size limit

// ❌ Leak: cache tăng không giới hạn theo số unique userId
const userCache = new Map<string, UserProfile>();

async function getUser(userId: string): Promise<UserProfile> {
  if (userCache.has(userId)) {
    return userCache.get(userId)!;
  }

  const user = await db.users.findById(userId);
  userCache.set(userId, user); // Không bao giờ xóa
  return user;
}

Với 1M unique user, cache này sẽ giữ 1M UserProfile objects. Nếu mỗi object ~2KB, đó là 2GB leak thẳng vào heap. (Mình đã gặp đúng pattern này — userCache của team cũ không ai nghĩ đến việc evict.)

// ✅ Fix option 1: LRU cache với size limit
import LRU from "lru-cache";

const userCache = new LRU<string, UserProfile>({
  max: 10_000, // Giữ tối đa 10k entries
  ttl: 5 * 60 * 1000, // TTL 5 phút
});
// ✅ Fix option 2: WeakMap nếu key là object (không phải primitive)
const requestCache = new WeakMap<Request, ProcessedData>();

// WeakMap không giữ strong reference đến key
// Khi `request` object bị GC, entry trong WeakMap tự biến mất

4. setInterval không clearInterval

// ❌ Leak: interval tạo ra khi request đến, không bao giờ clear
app.post("/start-monitoring", (req, res) => {
  const { sessionId } = req.body;

  // Interval này tồn tại cho đến khi process restart
  setInterval(() => {
    checkSessionStatus(sessionId);
  }, 5000);

  res.json({ started: true });
});

Sau 1000 request vào endpoint này: 1000 intervals chạy song song, mỗi cái giữ closure với sessionId. Heap tăng dần vì mỗi interval là một object tồn tại vĩnh viễn.

// ✅ Fix: lưu ref và clear khi không cần
const activeIntervals = new Map<string, NodeJS.Timeout>();

app.post("/start-monitoring", (req, res) => {
  const { sessionId } = req.body;

  // Clear interval cũ nếu đang chạy
  if (activeIntervals.has(sessionId)) {
    clearInterval(activeIntervals.get(sessionId)!);
  }

  const interval = setInterval(() => {
    checkSessionStatus(sessionId);
  }, 5000);

  activeIntervals.set(sessionId, interval);
  res.json({ started: true });
});

app.post("/stop-monitoring", (req, res) => {
  const { sessionId } = req.body;
  const interval = activeIntervals.get(sessionId);
  if (interval) {
    clearInterval(interval);
    activeIntervals.delete(sessionId);
  }
  res.json({ stopped: true });
});

Heap snapshot: công cụ không cần cài thêm gì

Khi đã biết “leak ở đâu đó” nhưng chưa biết chính xác là gì, heap snapshot là công cụ mạnh nhất. Không cần install gì ngoài Node.js và Chrome (hoặc Chromium-based browser).

// Thêm endpoint để trigger heap snapshot on-demand
import v8 from "v8";
import path from "path";

app.get("/debug/heap-snapshot", (req, res) => {
  // Chỉ cho phép trong development/staging — không bao giờ expose production
  if (process.env.NODE_ENV === "production") {
    res.status(403).send("Forbidden");
    return;
  }

  const filename = path.join("/tmp", `heap-${Date.now()}.heapsnapshot`);
  v8.writeHeapSnapshot(filename);
  res.json({ file: filename });
});

Sau đó:

  1. Restart process (baseline sạch)
  2. Gọi /debug/heap-snapshot → lưu file baseline.heapsnapshot
  3. Để process chạy 1-2 giờ / simulate load
  4. Gọi lại /debug/heap-snapshot → lưu file after.heapsnapshot
  5. Mở Chrome DevTools → Memory tab → Load cả hai file → chọn Comparison

Trong view Comparison, cột #Delta cho biết số objects loại nào tăng nhiều nhất. Tìm những type có #Delta dương lớn và Size Delta cao — đó là “suspects”.

Filter theo “Detached” để tìm DOM nodes hoặc objects không còn thuộc cây nào nhưng vẫn bị giữ reference. Trong Node.js context, “detached” thường là closures hoặc objects được giữ trong cache/array mà không ai dùng đến nữa.

Với case của mình: Comparison view cho thấy một type custom SessionContext#Delta tăng đúng bằng số request trong 2 tiếng. Trace ngược lên thì thấy một Map ở module scope đang accumulate mọi SessionContext mà không evict.


WeakMap vs Map: khi nào dùng cái nào

Câu hỏi này xuất hiện thường xuyên khi refactor cache — vậy khi nào thật sự nên dùng WeakMap?

Map giữ strong reference đến cả key và value. GC sẽ không thu dọn key hay value chừng nào Map còn tồn tại.

WeakMap giữ weak reference đến key (key phải là object). Nếu không còn reference nào khác đến key đó, GC có thể thu dọn cả key lẫn entry tương ứng trong WeakMap.

// Ví dụ rõ nhất: cache metadata gắn với một object có lifecycle riêng

const requestMetadata = new WeakMap<
  Request,
  { startTime: number; traceId: string }
>();

app.use((req, res, next) => {
  // Gán metadata cho request object
  requestMetadata.set(req, {
    startTime: Date.now(),
    traceId: generateTraceId(),
  });
  next();
});

app.get("/api/data", (req, res) => {
  const meta = requestMetadata.get(req);
  // ... dùng meta.traceId
  res.json({ data: "ok" });
  // Khi req object bị GC sau response, entry trong WeakMap tự biến mất
});

Giới hạn của WeakMap: không iterable, không có .size, không dùng được primitive làm key. Nên WeakMap chỉ phù hợp khi key là object có lifecycle tự nhiên (Request, Socket, Node), không phù hợp cho cache user profile với userId (string) làm key.


Flag --max-old-space-size: brutal nhưng thực tế

Trong production, khi chưa tìm ra leak, có một cách “defensive” mà mình hay dùng tạm thời: giới hạn heap size để process OOM-killed và tự restart thay vì leak vô hạn.

node --max-old-space-size=512 server.js

Flag này giới hạn V8 old-generation heap ở 512MB. Khi vượt ngưỡng, process sẽ throw FATAL ERROR: Reached heap limit và crash. Với PM2 hay Kubernetes restart policy, process tự restart trong vài giây.

(Cách này nghe brutal, nhưng mình đã dùng nó để ngủ ngon qua mấy đêm trong khi team investigate root cause. 7 phút downtime lúc 3h sáng không bằng process cứ leak đến 14GB và cả cluster OOM.)

Pair với --max-old-space-size là metric process.memoryUsage().heapUsed — alert khi heapUsed vượt 80% limit để restart có kiểm soát trước khi OOM:

// Health check endpoint với memory warning
app.get("/health", (req, res) => {
  const mem = process.memoryUsage();
  const heapUsedMB = mem.heapUsed / 1024 / 1024;
  const maxHeapMB = 512; // phải match --max-old-space-size

  const status = heapUsedMB > maxHeapMB * 0.85 ? "degraded" : "ok";

  res.status(status === "ok" ? 200 : 503).json({
    status,
    heap: `${Math.round(heapUsedMB)}MB / ${maxHeapMB}MB`,
  });
});

Kubernetes liveness probe check endpoint này — nếu 503, k8s restart pod trước khi OOM tự xảy ra.


Số liệu để hình dung scale của leak

Mỗi request leak 2KB (ví dụ: một SessionContext object nhỏ không được release): sau 500k request, đó là 1GB heap bị giữ lại. Ở traffic 50 req/s, 500k request mất khoảng 2 giờ 46 phút — tức là process của mình sẽ OOM trong vòng nửa ngày, không phải 3 ngày.

Suy ra: leak của mình nhỏ hơn 2KB/request, hoặc traffic thấp hơn. Sau khi đo thực tế: SessionContext của mình ~800 bytes, traffic trung bình ~20 req/s → 800B × 20 × 86400s = ~1.38GB/ngày. Khớp đúng với 1.4GB sau 3 ngày.

Số liệu này cũng giúp prioritize: leak nhỏ ở endpoint ít traffic thì chấp nhận được vài tuần trong khi fix. Leak ở endpoint hot path thì phải fix trong ngày — không có lý thuyết, chỉ có toán đơn giản.

Memory leak không bao giờ “xuất hiện” từ không khí. Nó luôn có dạng: “thứ gì đó đang được thêm vào một collection mà không bao giờ được remove.” Heap snapshot sẽ chỉ cho bạn thấy collection đó là gì. Còn việc tìm ra code nào đang add vào đó mà không cleanup — đó là phần cần đọc code.