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.0Problem
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-removesMemory implications:
.on() → Listeners accumulate → Memory leak risk
.once() → Listeners auto-cleanup → Safe for one-time eventsThe .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 logLessons Learned
- Use
once()for one-time events - Initialization, completion, cleanup - Use
on()for ongoing events - Data streams, progress updates - Monitor listener counts - Use
emitter.listenerCount()to detect leaks - Always clean up listeners - Use
removeListener()orremoveAllListeners() - Consider WeakRef for long-lived listeners - Prevent memory leaks in caching
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.