deep-dive2025-03-15·7·152/348

Node.js EventEmitter once 리스너 활용

Using EventEmitter once() listeners for one-time event handling, cleanup patterns, and avoiding memory leaks in Node.js.

Node.js EventEmitter once 리스너 활용

Introduction

EventEmitter is the backbone of Node.js's event-driven architecture. While .on() registers persistent listeners, .once() registers listeners that automatically remove themselves after firing once. This simple distinction has important implications for memory management and code correctness.

Environment

node --version
# v20.11.0

Problem

Using .on() for one-time events causes listener accumulation:

const EventEmitter = require('events');

class TaskRunner extends EventEmitter {
  constructor() {
    super();
    this.tasks = [];
  }
  
  addTask(task) {
    this.tasks.push(task);
    this.emit('taskAdded', task);
  }
  
  run() {
    // BAD: Using .on() for one-time completion
    this.on('complete', (result) => {
      console.log('Task completed:', result);
    });
  }
}

const runner = new TaskRunner();
runner.run(); // Adds listener

// Each call adds another listener!
runner.run();
runner.run();
runner.run();

console.log(runner.listenerCount('complete')); // 4 listeners!

Analysis

The difference between .on() and .once():

.on('event', handler)    → Listener stays active, fires every time
.once('event', handler)  → Listener fires once, then auto-removes

Memory implications:

.on()     → Listeners accumulate → Memory leak risk
.once()   → Listeners auto-cleanup → Safe for one-time events

The .once() implementation:

// Simplified internal implementation of once()
EventEmitter.prototype.once = function(event, listener) {
  const wrapper = (...args) => {
    this.removeListener(event, wrapper);
    listener.apply(this, args);
  };
  this.on(event, wrapper);
  return this;
};

Solution

Solution 1: Use once() for initialization

const EventEmitter = require('events');

class Database extends EventEmitter {
  constructor() {
    super();
    this.connected = false;
  }
  
  async connect() {
    try {
      await this._establishConnection();
      this.connected = true;
      this.emit('connected');
    } catch (err) {
      this.emit('error', err);
    }
  }
}

const db = new Database();

// GOOD: Use once() for initialization events
db.once('connected', () => {
  console.log('Database connected - this runs only once');
  startServer();
});

db.connect();

Solution 2: Promise wrapper with once()

function waitForEvent(emitter, event, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(`Timeout waiting for ${event}`));
    }, timeout);
    
    emitter.once(event, (data) => {
      clearTimeout(timer);
      resolve(data);
    });
  });
}

// Usage
async function initialize() {
  const emitter = new EventEmitter();
  
  // Wait for specific events
  const [dbResult, cacheResult] = await Promise.all([
    waitForEvent(emitter, 'dbReady'),
    waitForEvent(emitter, 'cacheReady')
  ]);
  
  console.log('All systems ready');
}

Solution 3: Cleanup pattern with once()

class Connection extends EventEmitter {
  constructor() {
    super();
    this.cleanupHandlers = [];
  }
  
  onConnect(callback) {
    this.once('connect', callback);
  }
  
  onClose(callback) {
    this.once('close', callback);
  }
  
  // Auto-cleanup on error
  onError(callback) {
    const wrapper = (err) => {
      this.removeListener('error', wrapper);
      callback(err);
    };
    this.on('error', wrapper);
  }
  
  disconnect() {
    this.emit('close');
    this.removeAllListeners();
  }
}

const conn = new Connection();

// GOOD: once() for connection lifecycle
conn.once('connect', () => {
  console.log('Connected');
});

conn.once('close', () => {
  console.log('Disconnected - cleanup can happen here');
});

conn.onConnect(() => {
  console.log('Connection established');
});

Solution 4: Event-driven state machine

class StateMachine extends EventEmitter {
  constructor(initialState) {
    super();
    this.state = initialState;
    this.transitions = new Map();
  }
  
  addTransition(from, to, event) {
    if (!this.transitions.has(from)) {
      this.transitions.set(from, new Map());
    }
    this.transitions.get(from).set(event, to);
  }
  
  trigger(event) {
    const possibleStates = this.transitions.get(this.state);
    if (!possibleStates || !possibleStates.has(event)) {
      return false;
    }
    
    const newState = possibleStates.get(event);
    const oldState = this.state;
    this.state = newState;
    
    // Emit transition event (use once() for one-time handlers)
    this.emit('transition', { from: oldState, to: newState, event });
    
    return true;
  }
  
  onTransition(callback) {
    // Use once() if you only care about specific transitions
    this.once('transition', callback);
  }
}

// Usage
const fsm = new StateMachine('idle');
fsm.addTransition('idle', 'loading', 'start');
fsm.addTransition('loading', 'done', 'finish');

fsm.onTransition(({ from, to }) => {
  console.log(`Transitioned from ${from} to ${to}`);
});

fsm.trigger('start');  // Logs: Transitioned from idle to loading
fsm.trigger('finish'); // Listener already removed, no log

Lessons Learned

  1. Use once() for one-time events - Initialization, completion, cleanup
  2. Use on() for ongoing events - Data streams, progress updates
  3. Monitor listener counts - Use emitter.listenerCount() to detect leaks
  4. Always clean up listeners - Use removeListener() or removeAllListeners()
  5. Consider WeakRef for long-lived listeners - Prevent memory leaks in caching

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.