Memory leak Node.js akibat listener EventEmitter menumpuk terjadi ketika aplikasi terus mendaftarkan listener pada emitter berumur panjang, tetapi tidak melepaskannya setelah request, timeout, pembatalan, atau reconnect selesai. Listener adalah referensi kuat; selama masih terdaftar, closure beserta objek request, payload, buffer, atau konteks lain yang ditangkapnya tidak dapat dibersihkan oleh garbage collector.

Gejala umumnya berupa kenaikan heap bertahap, MaxListenersExceededWarning, jeda garbage collection yang makin sering, latensi memburuk, lalu proses dihentikan karena kehabisan memori. Solusinya bukan menaikkan batas listener, melainkan memastikan setiap registrasi memiliki lifecycle dan jalur cleanup yang jelas.

Studi kasus: heap naik sampai proses restart

Sebuah backend menerima request untuk memulai pekerjaan asinkron. Hasil pekerjaan diterbitkan melalui emitter global bernama jobBus. Setiap request menunggu event result yang memiliki ID sesuai request.

Di lingkungan produksi, tim mengamati pola berikut:

  • heapUsed naik perlahan setelah setiap gelombang trafik dan tidak kembali mendekati baseline.
  • Log menampilkan MaxListenersExceededWarning untuk event result.
  • Waktu respons memburuk seiring bertambahnya listener dan tekanan garbage collection.
  • RSS terus meningkat sampai container atau proses restart akibat OOM.
  • Restart memulihkan memori untuk sementara, tetapi pola yang sama kembali muncul.

Implementasi yang bermasalah terlihat seperti berikut:

import { EventEmitter } from 'node:events';

const jobBus = new EventEmitter();

function waitForResult(requestId, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const onResult = (message) => {
      if (message.requestId === requestId) {
        resolve(message);
      }
    };

    jobBus.on('result', onResult);

    setTimeout(() => {
      reject(new Error(`Timeout menunggu ${requestId}`));
    }, timeoutMs);
  });
}

Listener tidak dilepas ketika hasil diterima maupun ketika timeout terjadi. Karena jobBus hidup selama proses berjalan, array listener pada event result terus bertambah. Setiap listener juga menangkap requestId dan dapat menangkap objek yang jauh lebih besar pada kode produksi.

MaxListenersExceededWarning adalah sinyal diagnostik, bukan bukti mutlak kebocoran. Sebaliknya, tidak adanya warning juga tidak membuktikan aplikasi aman. Jika setiap request menggunakan nama event unik, jumlah listener per event mungkin tetap kecil walaupun jumlah total listener terus naik.

Alur triase memory leak Node.js akibat EventEmitter

1. Bedakan heap, RSS, dan memori eksternal

Mulailah dengan tren memori, bukan satu sampel. Metrik dari process.memoryUsage() membantu membedakan beberapa kategori:

setInterval(() => {
  const memory = process.memoryUsage();

  console.log({
    rss: memory.rss,
    heapUsed: memory.heapUsed,
    heapTotal: memory.heapTotal,
    external: memory.external,
    arrayBuffers: memory.arrayBuffers
  });
}, 10_000).unref();
  • heapUsed: objek JavaScript yang saat ini menggunakan heap V8.
  • heapTotal: kapasitas heap yang dialokasikan V8.
  • RSS: total halaman memori proses yang berada di RAM, termasuk heap, kode, stack, dan alokasi native.
  • external dan arrayBuffers: memori di luar heap V8 yang terkait dengan objek JavaScript, misalnya buffer.

Pada kebocoran listener yang menahan closure JavaScript, heapUsed biasanya membentuk tren naik setelah siklus garbage collection. RSS tidak harus turun segera setelah objek dibebaskan karena allocator dapat mempertahankan halaman memori untuk digunakan kembali.

2. Hubungkan warning dengan stack registrasi

Jalankan aplikasi dengan pelacakan warning di lingkungan reproduksi:

node --trace-warnings server.js

Stack trace warning membantu menemukan lokasi tempat listener ditambahkan. Warning juga dapat dimasukkan ke log terstruktur:

process.on('warning', (warning) => {
  if (warning.name === 'MaxListenersExceededWarning') {
    logger.warn({
      warning: warning.name,
      eventType: warning.type,
      listenerCount: warning.count,
      stack: warning.stack
    }, 'Jumlah listener EventEmitter melewati ambang peringatan');
  }
});

Jangan mencatat seluruh objek emitter tanpa seleksi karena objek tersebut dapat besar atau memiliki struktur siklik.

3. Ukur jumlah listener pada emitter yang dicurigai

Jika emitter dapat diakses, catat jumlah listener sebelum dan sesudah request atau reconnect:

console.log({
  event: 'result',
  listeners: jobBus.listenerCount('result'),
  eventNames: jobBus.eventNames().map(String)
});

Uji invariant sederhana: setelah operasi selesai, jumlah listener seharusnya kembali ke baseline. Peningkatan monoton setelah request timeout adalah indikasi kuat bahwa jalur cleanup tidak dijalankan.

4. Bandingkan heap snapshot

Ambil snapshot saat baseline, jalankan beban berulang, lalu ambil snapshot kedua. Gunakan inspector Node.js dan alat analisis heap yang kompatibel, kemudian cari objek yang jumlah atau retained size-nya meningkat.

Pada kasus ini, retaining path biasanya menyerupai:

GC root
└─ jobBus
   └─ _events
      └─ result
         └─ Array
            └─ onResult closure
               ├─ requestId
               ├─ request context
               └─ payload atau buffer

Fokus pada retained size dan retaining path, bukan hanya shallow size fungsi listener. Fungsi kecil dapat mempertahankan graph objek yang besar melalui closure.

Mengambil heap snapshot dapat menghentikan event loop sementara dan membutuhkan memori tambahan. Lakukan di staging atau replika produksi jika memungkinkan. Jika harus dilakukan di produksi, perhitungkan kapasitas dan dampak operasionalnya.

5. Reproduksi dengan beban yang mengaktifkan jalur gagal

Reproduksi harus mencakup timeout, pembatalan client, error downstream, dan reconnect—bukan hanya request sukses. Jalankan batch request yang sengaja tidak menerima event hasil, lalu ukur listener dan memori setelah setiap batch.

  1. Catat baseline heapUsed dan listenerCount('result').
  2. Kirim request berulang ke endpoint yang akhirnya timeout.
  3. Tunggu semua timeout selesai.
  4. Catat kembali jumlah listener.
  5. Ulangi beberapa batch dan bandingkan heap snapshot.

Jika setiap timeout meninggalkan satu listener, pertumbuhan listener akan mengikuti jumlah request gagal. Reproduksi deterministik ini biasanya lebih berguna daripada sekadar menunggu OOM.

Memperbaiki lifecycle listener

Gunakan cleanup eksplisit untuk event bersama

Jika banyak request mendengarkan event yang sama dan melakukan filter berdasarkan ID, listener harus dilepas pada semua jalur penyelesaian: sukses, timeout, abort, dan error.

function createAbortError() {
  const error = new Error('Operasi dibatalkan');
  error.name = 'AbortError';
  return error;
}

function waitForResult(emitter, requestId, timeoutMs = 5000, signal) {
  return new Promise((resolve, reject) => {
    let timer;

    const cleanup = () => {
      emitter.off('result', onResult);
      signal?.removeEventListener('abort', onAbort);
      clearTimeout(timer);
    };

    const onResult = (message) => {
      if (message.requestId !== requestId) return;

      cleanup();
      resolve(message);
    };

    const onAbort = () => {
      cleanup();
      reject(createAbortError());
    };

    emitter.on('result', onResult);

    if (signal?.aborted) {
      onAbort();
      return;
    }

    signal?.addEventListener('abort', onAbort, { once: true });

    timer = setTimeout(() => {
      cleanup();
      reject(new Error(`Timeout menunggu ${requestId}`));
    }, timeoutMs);
  });
}

Referensi fungsi onResult harus disimpan karena off() atau removeListener() memerlukan fungsi yang sama dengan yang didaftarkan. Pola berikut tidak bekerja karena kedua arrow function merupakan objek berbeda:

emitter.on('result', (value) => handle(value));
emitter.off('result', (value) => handle(value)); // Tidak menghapus listener sebelumnya

Kapan memakai once()

once() tepat ketika listener hanya membutuhkan event berikutnya dan event tersebut pasti menandai akhir lifecycle. Untuk korelasi request, gunakan event khusus agar event milik request lain tidak menghapus listener terlalu dini:

function waitForDedicatedResult(emitter, requestId, timeoutMs = 5000) {
  const eventName = `result:${requestId}`;

  return new Promise((resolve, reject) => {
    const onResult = (message) => {
      clearTimeout(timer);
      resolve(message);
    };

    const timer = setTimeout(() => {
      emitter.off(eventName, onResult);
      reject(new Error(`Timeout menunggu ${requestId}`));
    }, timeoutMs);

    emitter.once(eventName, onResult);
  });
}

once() hanya melepaskan listener setelah event terpanggil. Jika event tidak pernah datang, listener tetap terdaftar. Karena itu, jalur timeout atau pembatalan masih harus menghapusnya.

Pada runtime yang mendukung opsi AbortSignal untuk utilitas events.once(), pembatalan dapat dibuat lebih deklaratif:

import { once } from 'node:events';

async function waitForDedicatedResult(emitter, requestId, signal) {
  const [message] = await once(emitter, `result:${requestId}`, { signal });
  return message;
}

Pemanggil bertanggung jawab membatalkan signal ketika request terputus atau deadline terlampaui. Pendekatan ini mengurangi cleanup manual, tetapi kode tetap harus menangani penolakan promise akibat abort.

Gunakan finally untuk registrasi sementara

Untuk listener yang aktif hanya selama sebuah operasi, pasangkan registrasi dan pelepasan dalam try/finally:

async function runWithDisconnectGuard(emitter, operation) {
  const onDisconnect = () => {
    logger.warn('Koneksi terputus selama operasi');
  };

  emitter.on('disconnect', onDisconnect);

  try {
    return await operation();
  } finally {
    emitter.off('disconnect', onDisconnect);
  }
}

finally tetap dijalankan saat operasi sukses maupun melempar error. Pola yang sama berlaku pada reconnect: jangan menambahkan handler baru ke emitter global setiap kali koneksi tersambung kembali tanpa melepas handler milik siklus sebelumnya.

Mengapa setMaxListeners() bukan solusi

setMaxListeners() mengubah ambang warning, bukan membatasi jumlah listener dan bukan mengelola memori. Kode berikut hanya menunda atau menonaktifkan sinyal diagnostik:

jobBus.setMaxListeners(100);
// atau menonaktifkan warning batas listener
jobBus.setMaxListeners(0);

Jika satu listener bocor pada setiap request, jumlah listener tetap tumbuh meskipun warning baru muncul lebih lambat atau tidak muncul sama sekali. Akibatnya heap, waktu iterasi listener, dan tekanan garbage collection tetap meningkat.

Menaikkan batas dapat diterima jika banyak listener memang bagian dari desain, jumlahnya terikat, dan lifecycle-nya sudah diverifikasi. Contohnya adalah emitter yang secara sengaja melayani sejumlah worker tetap. Dokumentasikan alasan dan tetap pantau jumlah listener aktual.

Regression test untuk mencegah kebocoran kembali

Test sebaiknya memeriksa kondisi setelah operasi selesai, bukan hanya memastikan promise resolve atau reject. Gunakan emitter baru pada setiap test agar baseline terisolasi.

import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';

test('listener dibersihkan setelah timeout berulang', async () => {
  const emitter = new EventEmitter();
  const baseline = emitter.listenerCount('result');

  for (let index = 0; index < 50; index += 1) {
    await assert.rejects(
      waitForResult(emitter, `request-${index}`, 1),
      /Timeout/
    );
  }

  assert.equal(emitter.listenerCount('result'), baseline);
});

test('listener dibersihkan setelah hasil diterima', async () => {
  const emitter = new EventEmitter();
  const baseline = emitter.listenerCount('result');

  const pending = waitForResult(emitter, 'request-1', 1000);

  assert.equal(emitter.listenerCount('result'), baseline + 1);

  emitter.emit('result', {
    requestId: 'request-1',
    value: 'selesai'
  });

  await pending;
  assert.equal(emitter.listenerCount('result'), baseline);
});

Tambahkan kasus abort, error downstream, dan reconnect jika jalur tersebut ada di aplikasi. Test berbasis listener count sangat cepat dan deterministik, tetapi tidak menggantikan uji beban serta analisis heap karena closure lain dapat menahan objek di luar emitter yang diuji.

Verifikasi perbaikan dan pencegahan operasional

Verifikasi setelah deployment

Ulangi skenario beban yang sama sebelum dan sesudah perbaikan. Perbaikan dianggap efektif jika:

  • Jumlah listener kembali ke baseline setelah request selesai atau timeout.
  • Tidak ada pertumbuhan retained listener pada perbandingan heap snapshot.
  • heapUsed stabil di sekitar rentang kerja setelah beberapa siklus garbage collection, bukan naik monoton.
  • Durasi atau frekuensi jeda garbage collection tidak terus memburuk selama beban konstan.
  • Latensi kembali stabil dan proses tidak mengalami restart OOM.

Jangan menuntut heap kembali ke angka yang persis sama setelah setiap batch. Runtime dapat mempertahankan kapasitas heap, cache aplikasi mungkin memang tumbuh sampai batas tertentu, dan waktu garbage collection tidak selalu dapat diprediksi. Yang dicari adalah hilangnya tren pertumbuhan tanpa batas.

Metrik dan alert yang perlu ditambahkan

  • Memori proses: RSS, heap used, heap total, external, dan array buffer.
  • Listener: jumlah listener per emitter dan event kritis, terutama setelah request atau reconnect.
  • Runtime: event loop delay, durasi garbage collection, dan event loop utilization jika tersedia dalam sistem observabilitas.
  • Aplikasi: request aktif, operasi menunggu event, timeout, abort, error downstream, dan reconnect.
  • Platform: restart container, terminasi OOM, serta penggunaan memori terhadap limit.

Alert sebaiknya menggabungkan tren dan konteks. Contohnya, beri peringatan ketika jumlah listener event kritis naik selama beberapa interval sementara jumlah operasi aktif sudah turun, atau ketika heap setelah periode beban rendah terus membentuk baseline baru. Alert statis pada satu sampel mudah menghasilkan false positive.

Prinsip desain yang paling aman adalah memperlakukan listener sebagai resource: setiap on() harus memiliki pemilik, batas waktu, dan jalur pelepasan yang dapat ditunjukkan. Dengan invariant tersebut, warning, heap snapshot, dan regression test menjadi lapisan verifikasi—bukan satu-satunya pertahanan terhadap OOM.