OOM không phải lúc nào cũng là “leak classic” (quên remove listener). Hay gặp hơn trên server Node / worker: số công việc đang bay tăng không trần — mỗi cái giữ closure, buffer, socket, response object — cho đến khi process chết hoặc event loop chỉ còn thrash GC.

Gọi là unbounded in-flight: không ai đếm “đang có bao nhiêu Promise/job sống”, cũng không ai từ chối thêm.

Demo: Unbounded vs pool — xem in-flight và “memory proxy” tăng.

Anti-pattern nhận mặt 3 giây

// 1) bắn hết
for (const item of items) {
  void process(item); // không await, không limit
}

// 2) Promise.all trên list khổng lồ
await Promise.all(items.map(process)); // 100k fetch cùng lúc

// 3) queue push không max trong khi consumer chậm
while (true) {
  queue.push(await readEvent()); // producer không bao giờ chậm lại
}

(1) và (2) là hai mặt: fire-and-forget vs “await tất cả nhưng start tất cả ngay”. Cả hai đều có thể tạo N in-flight ≈ N items.

Pool đã giới hạn — nhưng nguồn vẫn phình

mapPool(limit) giữ tối đa N đang chạy. Vẫn có thể phình nếu:

  • Bạn map trước thành mảng 1M closure/job object — heap đã to trước khi pool chạy.
  • Kết quả results[] giữ nguyên payload lớn của mọi item — xong job mà không stream/ghi đĩa.
  • Error path không settle — Promise treo → slot “chết” hoặc (fire-and-forget) zombie forever.

Backpressure thật cần bound cả hàng đợi chờ, không chỉ concurrency đang chạy.

Map / cache giữ Promise

const cache = new Map();

function get(key) {
  if (!cache.has(key)) {
    cache.set(key, load(key)); // Promise + rồi cả result
  }
  return cache.get(key);
}

Bẫy:

  • Không TTL / max entries → Map lớn dần theo key unique (user id, URL…).
  • Không xóa khi reject → key “độc” cache lỗi mãi, hoặc ngược lại giữ Promise reject + stack.
  • Single-flight quên finally delete — key kẹt; hoặc ngược lại giữ result như cache mà tưởng chỉ inflight (xem single-flight).
// inflight đúng nghĩa: xóa khi settle
const inflight = new Map();
function doOnce(key, work) {
  if (inflight.has(key)) return inflight.get(key);
  const p = Promise.resolve()
    .then(work)
    .finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}

Listener & “Promise không ai await”

  • EventEmitter.on mỗi request thêm listener, không off → list listener + closure giữ req/res.
  • setInterval trong hot path không clear.
  • Stream data không pause khi xử lý chậm — buffer nội bộ phình (lại backpressure).

Sketch: giới hạn in-flight có chủ đích

function createLimiter(max) {
  let active = 0;
  const wait = [];
  const acquire = () =>
    new Promise((resolve) => {
      if (active < max) {
        active++;
        resolve();
      } else wait.push(resolve);
    });
  const release = () => {
    active--;
    if (wait.length) {
      active++;
      wait.shift()();
    }
  };
  return async function run(fn) {
    await acquire();
    try {
      return await fn();
    } finally {
      release();
    }
  };
}

const limit = createLimiter(20);
// mỗi request:
await limit(() => handle(job));

Khi full: caller chờ (backpressure) thay vì tạo thêm work. Ở HTTP edge có thể chuyển thành 503 ngay (tryAcquire) thay vì chờ vô hạn — load shed.

Quan sát gì trên production

  • Gauge inflight_requests / active_jobs — có ceiling không?
  • Heap used vs traffic — tăng bậc thang theo deploy/feature?
  • Event loop lag / GC pause khi spike traffic.
  • Độ dài queue nội bộ (Bull waiting, custom array).

Alert đơn giản: inflight > max_expected * 1.5 trong N phút — thường bắt được “consumer chết mà gateway vẫn nhận” sớm hơn OOM killer.

Một câu để nhớ

Mọi thứ async cần budget: bao nhiêu được bay cùng lúc, bao nhiêu được xếp hàng, bao nhiêu bị từ chối. Không budget = heap là hàng đợi.

Pool, limiter, bounded queue, TTL cache, finally dọn map — cùng một kỷ luật: sống có trần.