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
maptrướ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.onmỗi request thêm listener, khôngoff→ list listener + closure giữ req/res. -
setIntervaltrong hot path không clear. -
Stream
datakhôngpausekhi 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.