Node.js NotesRohit’s interview study guide
Chapter 06

EventEmitter

Node's built-in observer — how emit really runs, errors, listener lifecycles, and when to reach for a real message broker instead.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Why event emitters exist

An event emitter is an object that keeps a list of functions per event name and calls them when that event is announced. One side emits ("an order was created, here it is"); any number of other pieces of code listen ("when an order is created, do this"). Neither side calls the other directly.

The problem it solves is coupling. Picture createOrder() in a growing app. First it saves the order. Then someone needs a metric, so it calls recordMetric(). Then the cache needs invalidating, then an audit log, then a webhook. Each new requirement edits createOrder, and the function ends up importing half the codebase. With an emitter, createOrder announces one fact — orderCreated — and every interested module subscribes on its own. Adding a new reaction no longer touches the producer.

The mental model is a radio station: it broadcasts on a named frequency and doesn't know or care who's tuned in — zero listeners, one, or twenty. Listeners tune in and out whenever they like. Node uses this design everywhere: an HTTP server emits request, a stream emits data and end, a child process emits exit. They all inherit from EventEmitter in node:events.

order.cjs
const { EventEmitter } = require('node:events');

const orderEvents = new EventEmitter();

async function saveOrder(order) {
  return { ...order, id: 101 };
}

async function createOrder(order) {
  const saved = await saveOrder(order);
  orderEvents.emit('orderCreated', saved); // announce it; don't care who listens
  return saved;
}

// Elsewhere — each module subscribes on its own:
orderEvents.on('orderCreated', (o) => console.log('metrics: order', o.id));
orderEvents.on('orderCreated', (o) => console.log('cache: invalidate orders for', o.user));

createOrder({ user: 'rohit', total: 40 }).then((o) => console.log('returned', o));
AdvancedOutput: subscribers react to orderCreated
Output
metrics: order 101
cache: invalidate orders for rohit
returned { user: 'rohit', total: 40, id: 101 }
What’s happening
  1. orderEvents is one shared emitter. In a real app it would live in its own module, so every file that requires it gets the same instance (see Shared state and the singleton pattern).
  2. Two listeners are registered for orderCreated. createOrder knows nothing about either; the metrics and cache modules registered themselves.
  3. createOrder awaits saveOrder, which resolves with the order plus id: 101. Then emit('orderCreated', saved) calls each listener in registration order, passing saved as the argument: the metrics line, then the cache line.
  4. Only after both listeners have returned does createOrder return, so returned … prints last. That ordering is the subject of emit runs listeners synchronously — the most important fact in this chapter.
  5. The producer still has a contract to keep: the event name, when it fires (after the save, not before) and the payload shape are now a public API that listeners depend on.
AdvancedWhere emitters fit well

Where emitters fit well: repeated notifications (a stream's chunks, a socket's messages), lifecycle signals (ready, close, error), and in-process decoupling of side effects that aren't part of the caller's result. Where they fit badly: anything the caller needs an answer from (use a function call or a promise), and anything that must survive a crash or reach another process (see Local events vs distributed messaging).

AdvancedCorrecting my notes: emitters aren't asynchronous

#Emitting and listening

The core API is small: on(name, listener) to subscribe, emit(name, ...args) to publish. Whatever arguments you pass after the name in emit are passed straight to every listener.

greet.cjs
const EventEmitter = require('node:events');

const myEmitter = new EventEmitter();

myEmitter.on('greet', (name) => {
  console.log(`Hello, ${name}!`);
});

myEmitter.emit('greet', 'John'); // Hello, John!
What’s happening
  1. require('node:events') returns the EventEmitter class itself (it's also available as require('node:events').EventEmitter — the two are the same function).
  2. on('greet', fn) appends fn to the emitter's internal listener array for 'greet'. Nothing runs yet.
  3. emit('greet', 'John') looks up the 'greet' array and calls each function with 'John' as its argument, so the listener prints Hello, John!.
  4. Event names are just keys — any string or symbol. There's no registry of allowed names, so a typo (emit('greeet')) is silently ignored: emit returns false and nothing happens.
AdvancedExtending EventEmitter in a class

The usual way to use it is to extend EventEmitter, so your class gets on/emit as its own methods. Every built-in emitter (streams, http.Server, ChildProcess) is built this way.

logger.cjs
const { EventEmitter } = require('node:events');

class Logger extends EventEmitter {
  log(message) {
    this.emit('log', { message });
  }
}

const logger = new Logger();

logger.on('log', ({ message }) => console.log(`Log to console: ${message}`));
logger.on('log', function ({ message }) {
  console.log(`Log to file: ${message}`, '| this is logger?', this === logger);
});

logger.log('An error occurred');
console.log(logger.listenerCount('log'), logger.eventNames());
AdvancedOutput: two listeners and this
Output
Log to console: An error occurred
Log to file: An error occurred | this is logger? true
2 [ 'log' ]
What’s happening
  1. class Logger extends EventEmitter gives every Logger its own listener storage (set up by the inherited constructor) and all the emitter methods.
  2. logger.log(...) is the class's public method; internally it calls this.emit('log', { message }). Callers of log don't need to know events are involved — the class decides what to announce.
  3. Two listeners are registered for 'log'. emit calls them in registration order: the console line, then the "file" line (a real one would write to a file or a transport).
  4. The second listener is a regular function, and Node calls listeners with this set to the emitter, so this === logger is true. An arrow function ignores that and keeps the this of where it was written — see this in callbacks, timers and listeners.
  5. listenerCount('log') is 2, and eventNames() lists every event that currently has at least one listener.
AdvancedReference: the methods you'll use

The methods you'll actually use:

MethodWhat it does
on(name, fn) / addListenerAppend a listener. Same function twice = called twice. Returns the emitter (chainable).
once(name, fn)Listener that removes itself before its first call.
prependListener(name, fn)Add to the front of the list instead of the end.
off(name, fn) / removeListenerRemove one registration of that exact function.
removeAllListeners([name])Remove all listeners for one event (or every event).
emit(name, ...args)Call the listeners synchronously; returns true if there were any.
listenerCount(name), listeners(name), rawListeners(name)Inspect what's registered.
setMaxListeners(n)Change the leak-warning threshold (default 10).
events.once(emitter, name)Promise for the next occurrence.
events.on(emitter, name)Async iterator over occurrences.

Lifecycle details — once, removing, and the helpers — are in on, once, off and removeListener.

#emit runs listeners synchronously

This is the trap my notes flagged as the biggest: emit() is a plain synchronous loop over the listeners. It doesn't schedule anything, doesn't wait for the event loop, and doesn't run listeners "in the background". When emit returns, every listener has already run to completion (or to its first await — see Async listeners).

Mental model: emit('x', data) is shorthand for for (const fn of listeners['x']) fn.call(emitter, data). If you'd be uneasy writing that loop inline at the call site, you should be equally uneasy about the emit.

emitter.cjs
const { EventEmitter } = require('node:events');

const emitter = new EventEmitter();

emitter.on('test', () => console.log('listener 1'));
emitter.on('test', () => {
  console.log('listener 2');
  return 42; // ignored
});

console.log('A');
const hadListeners = emitter.emit('test');
console.log('B', hadListeners);
console.log('C', emitter.emit('nobody-listens'));
AdvancedOutput: synchronous listener order
Output
A
listener 1
listener 2
B true
C false
What’s happening
  1. Two listeners are registered for 'test'; registering doesn't run anything.
  2. A prints. Then emit('test') copies the listener array and calls each function in order: listener 1, then listener 2. Both print before emit returns — there's no gap where other code could run.
  3. emit returns true, meaning "this event had at least one listener". It does not return listener results: listener 2's return 42 is thrown away.
  4. B true prints after both listeners. Then emit('nobody-listens') finds no array and returns false immediately — that boolean is the only feedback a producer gets.
  5. If you need values back from the "listeners", you don't want an event; you want to call functions and collect their results (await Promise.all(handlers.map((h) => h(data)))).
AdvancedConsequence 1: a slow listener blocks

Two consequences follow. First, a slow listener blocks everything — the code after emit, the other listeners, and the whole event loop:

slow.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

emitter.on('upload', () => {
  const start = Date.now();
  while (Date.now() - start < 300) {} // e.g. hashing a file synchronously
  console.log('slow listener done');
});
emitter.on('upload', () => console.log('fast listener'));

const t0 = Date.now();
setTimeout(() => console.log('timer (due at 10 ms) fired at', Date.now() - t0, 'ms'), 10);

emitter.emit('upload');
console.log('emit returned after', Date.now() - t0, 'ms');
AdvancedOutput: emit waits for the slow listener
Output
slow listener done
fast listener
emit returned after 301 ms
timer (due at 10 ms) fired at 301 ms
What’s happening
  1. A 10 ms timer is scheduled, then emit('upload') starts calling listeners.
  2. The first listener busy-waits for 300 ms. The second listener can't start until it returns, so fast listener waits too.
  3. emit returns after about 301 ms, and only then does the rest of the script finish.
  4. The timer was due at 10 ms but fires at about 301 ms. The event loop can't run a timer while the call stack is busy, and an emitter listener is the call stack. In a server, every other request waits the same way (see Blocking the event loop).
AdvancedConsequence 2: a throwing listener

Second, a throwing listener throws out of emit, and the listeners after it don't run:

throws.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

emitter.on('data', () => console.log('listener 1'));
emitter.on('data', () => { throw new Error('listener 2 broke'); });
emitter.on('data', () => console.log('listener 3'));

try {
  emitter.emit('data');
} catch (err) {
  console.log('caught at the emit call:', err.message);
}
// listener 1
// caught at the emit call: listener 2 broke
What’s happening
  1. emit('data') calls listener 1, which logs.
  2. Listener 2 throws. emit has no try/catch inside its loop, so the error propagates up through emit to the caller — exactly like an exception from a nested function call.
  3. Listener 3 never runs. One subscriber's bug just broke an unrelated subscriber, and the producer, which is now holding an exception from code it doesn't own.
  4. That's the hidden coupling of emitters: they decouple who listens, but not what happens when a listener misbehaves. Listeners should catch their own errors; producers that can't trust their listeners sometimes wrap the call to emit in try/catch.
AdvancedPitfall: events lost before .on()
Nested emits and once
quiz1.cjs
const { EventEmitter } = require('node:events');
const e = new EventEmitter();

e.on('a', () => {
  console.log('a1 start');
  e.emit('b');
  console.log('a1 end');
});
e.on('a', () => console.log('a2'));
e.on('b', () => console.log('b1'));

e.once('c', () => {
  console.log('c once');
  console.log('inner emit returned', e.emit('c'));
});

console.log(e.emit('a'));
console.log(e.emit('c'), e.emit('c'));
Show answer
Output
a1 start
b1
a1 end
a2
true
c once
inner emit returned false
true false
What’s happening
  1. emit('a') calls the first 'a' listener, which prints a1 start and then calls emit('b'). That's just a nested function call: b1 prints immediately, and only then does control come back to print a1 end. Nested emits run depth-first.
  2. The second 'a' listener runs next (a2), and the outer emit returns true.
  3. In the last line, the arguments are evaluated left to right. The first emit('c') calls the once wrapper, which removes itself before calling the listener. So when the listener emits 'c' again from inside, there are no listeners left: inner emit returned false, and no infinite recursion.
  4. The first emit('c') returns true (it had a listener when it started); the second returns false. Then console.log prints true false.

#Async listeners

Making a listener async doesn't make emit wait for it. emit calls the function, the function runs synchronously until its first await, then returns a promise — and emit throws that promise away and moves on. The rest of the listener runs later, as a microtask.

async-listener.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

emitter.on('save', async () => {
  console.log('listener started');
  await Promise.resolve();
  console.log('listener continued');
});

console.log('before');
const result = emitter.emit('save');
console.log('after, emit returned', result);
AdvancedOutput: an async listener returns early
Output
before
listener started
after, emit returned true
listener continued
What’s happening
  1. before prints, then emit('save') calls the async listener.
  2. An async function body starts running synchronously, so listener started prints inside the emit call.
  3. await Promise.resolve() suspends the listener and queues its continuation as a microtask. The listener returns a pending promise to emit, which ignores it.
  4. emit returns true (a boolean, not a promise — so await emitter.emit(...) waits for nothing useful), and after, emit returned true prints.
  5. The script's synchronous code finishes, the microtask queue drains, and listener continued prints. If the caller needed the save to be finished, it was wrong to rely on the event.
AdvancedAsync listener rejections

The dangerous part is rejections. An async listener that throws doesn't throw out of emit — it returns a rejected promise that nobody handles:

async-reject.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

emitter.on('job', async () => {
  throw new Error('Job failed');
});

try {
  emitter.emit('job');
  console.log('emit returned normally');
} catch {
  console.log('never printed: the rejection is not a throw');
}
AdvancedOutput: unhandled rejection from a listener
Output
emit returned normally
/…/rv-ee/async-reject.cjs:5
  throw new Error('Job failed');
        ^

Error: Job failed
    at EventEmitter.<anonymous> (/…/rv-ee/async-reject.cjs:5:9)
    at EventEmitter.emit (node:events:514:20)
    at Object.<anonymous> (/…/rv-ee/async-reject.cjs:9:11)
    …

Node.js v26.7.0
What’s happening
  1. emit('job') calls the async listener. Its throw happens inside an async function, so instead of propagating it becomes a rejected promise returned to emit.
  2. emit ignores return values, so the try/catch around it sees nothing: emit returned normally prints.
  3. At the end of the tick, Node notices a rejected promise with no handler. Since Node 15 the default --unhandled-rejections mode is throw, so the process crashes with exit code 1 (see Floating promises and unhandled rejections).
  4. The stack trace still points at the listener and at the emit call — useful for debugging, but by then the process is already going down.
AdvancedFix 1: catching inside the listener

You have two ways to handle it. The simple one is to catch inside the listener — each listener owns its own failures:

JavaScript
// Illustrative: sendEmail and reportEmailFailure are app services.
orderEvents.on('orderCreated', async (order) => {
  try {
    await sendEmail(order);
  } catch (err) {
    reportEmailFailure(order.id, err); // log, metric, or enqueue a retry
  }
});
AdvancedFix 2: captureRejections

The other is captureRejections, which tells the emitter to attach a rejection handler to every promise a listener returns and route the error to the emitter's 'error' event:

capture.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter({ captureRejections: true });

emitter.on('error', (err) => console.log('Handled:', err.message));
emitter.on('job', async () => {
  throw new Error('Job failed');
});

emitter.emit('job');
console.log('emit returned');
AdvancedOutput: rejection routed to 'error'
Output
emit returned
Handled: Job failed
What’s happening
  1. new EventEmitter({ captureRejections: true }) turns the option on for this emitter. (events.captureRejections = true turns it on for every new emitter.)
  2. emit('job') calls the async listener, gets back a rejected promise, and — because of the option — attaches a .then(undefined, handler) to it. emit still returns synchronously: emit returned prints first.
  3. When the rejection is processed (a microtask later), the handler emits 'error' with the rejection reason, so Handled: Job failed prints and the process does not crash.
  4. Keep the 'error' listener itself synchronous and non-throwing. If it were async and rejected, there'd be nothing left to catch it.
AdvancedCustom handler via captureRejectionSymbol

A class can also define its own handler under events.captureRejectionSymbol (which is Symbol.for('nodejs.rejection')). Node calls it instead of emitting 'error', with the error, the event name and the original arguments:

rejection-symbol.cjs
const { EventEmitter, captureRejectionSymbol } = require('node:events');

class Jobs extends EventEmitter {
  constructor() {
    super({ captureRejections: true });
  }
  [captureRejectionSymbol](err, eventName, ...args) {
    console.log(`rejection from '${eventName}' listener:`, err.message, args);
  }
}

const jobs = new Jobs();
jobs.on('run', async (id) => { throw new Error(`job ${id} failed`); });
jobs.emit('run', 7);
// rejection from 'run' listener: job 7 failed [ 7 ]
What’s happening
  1. The constructor enables captureRejections, so every listener's returned promise gets a rejection handler.
  2. The listener for 'run' rejects with job 7 failed.
  3. Because the class defines a method keyed by captureRejectionSymbol, Node calls that method instead of emitting 'error', passing the error, 'run', and the emit arguments ([ 7 ]).
  4. This is handy when the right response depends on which event failed — retrying job 7, for example — rather than funnelling everything into one generic error event.
AdvancedLimit: caught isn't the same as reliable
Async listener vs microtasks and timers
quiz2.cjs
const { EventEmitter } = require('node:events');
const e = new EventEmitter();

e.on('data', async (n) => {
  console.log('L1 got', n);
  await null;
  console.log('L1 after await', n);
});
e.on('data', (n) => console.log('L2 got', n));

setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
e.emit('data', 1);
console.log('sync end');
Show answer
Output
L1 got 1
L2 got 1
sync end
promise
L1 after await 1
timeout
What’s happening
  1. The timer goes into the timers phase and the .then callback goes into the microtask queue — that's microtask #1.
  2. emit('data', 1) calls L1, which prints L1 got 1 and hits await null. Its continuation is queued as microtask #2, after the .then that's already waiting.
  3. emit moves straight on to L2 — it didn't wait for L1 — so L2 got 1 prints, then sync end.
  4. The call stack is empty, so the microtask queue drains in FIFO order: promise (#1), then L1 after await 1 (#2).
  5. Only then does the event loop reach the timers phase: timeout. Same order in CommonJS and ESM, since nothing here uses process.nextTick.

#The error event

'error' is the one event name EventEmitter treats specially. If you emit 'error' and there's no listener for it, emit throws the error. Any other event with no listeners is silently ignored. The reasoning: an error nobody is listening for is a bug you need to know about, so Node makes it loud instead of losing it.

This matters because almost every Node object that does I/O — streams, sockets, servers, child processes — reports failures by emitting 'error'. Forgetting the listener on one of them is the classic "the whole server died because one upload failed" bug.

error-handled.cjs
const { EventEmitter } = require('node:events');
const myEmitter = new EventEmitter();

myEmitter.on('error', (err) => {
  console.error('An error occurred:', err.message);
});

const result = myEmitter.emit('error', new Error('Something went wrong!'));
console.log('emit returned', result, '- process keeps running');
// An error occurred: Something went wrong!
// emit returned true - process keeps running
What’s happening
  1. An 'error' listener is registered, so 'error' is now an ordinary event for this emitter.
  2. emit('error', err) calls the listener with the Error object, which logs the message. Nothing is thrown.
  3. emit returns true and the script carries on. The listener handled the error — deciding whether that means log, retry, or shut down cleanly is up to you.
AdvancedNo 'error' listener: the process crashes

Remove the listener and the same emit crashes the process:

error-crash.cjs
const { EventEmitter } = require('node:events');
const myEmitter = new EventEmitter();

console.log('before');
myEmitter.emit('error', new Error('Something went wrong!'));
console.log('after'); // never runs
AdvancedOutput: unhandled 'error' event
Output
before
node:events:505
    throw er; // Unhandled 'error' event
    ^

Error: Something went wrong!
    at Object.<anonymous> (/…/rv-ee/error-crash.cjs:5:25)
    …
Emitted 'error' event at:
    at Object.<anonymous> (/…/rv-ee/error-crash.cjs:5:11)
    …

Node.js v26.7.0
What’s happening
  1. emit('error', err) finds no 'error' listeners. Instead of returning false, it throws err — that's the throw er; // Unhandled 'error' event line from Node's own events source.
  2. Nothing catches it, so it becomes an uncaught exception and the process exits with code 1. after never prints.
  3. Node adds an Emitted 'error' event at: section pointing to the emit call (column 11), separate from where the Error was created (column 25). For a stream error those two places are far apart, so both are useful.
  4. A try/catch around this emit would catch it — but for real I/O objects, the emit('error') happens later inside a Node callback, where your try/catch isn't on the stack. The only reliable fix is a listener.
AdvancedNon-Error values get wrapped

If the value emitted isn't an Error, Node wraps it so you still get a stack trace:

error-string.cjs
const { EventEmitter } = require('node:events');
const myEmitter = new EventEmitter();

try {
  myEmitter.emit('error', 'disk full');      // not an Error object
} catch (err) {
  console.log(err.code);     // ERR_UNHANDLED_ERROR
  console.log(err.message);  // Unhandled error. ('disk full')
  console.log(err.context);  // disk full
}
What’s happening
  1. emit('error', 'disk full') has no listener, but 'disk full' is a string, and throwing a string would lose the stack trace.
  2. So Node throws a new Error with code: 'ERR_UNHANDLED_ERROR' and the message Unhandled error. ('disk full') — the value is formatted with util.inspect, hence the quotes.
  3. The original value is kept on err.context. Uncaught, this is the ERR_UNHANDLED_ERROR crash you see when some library emits a non-Error value as an error.
AdvancederrorMonitor: observe without handling

Sometimes you want to see every error — to count it in metrics, say — without changing whether it crashes. Listening on events.errorMonitor does that. Monitor listeners run before regular 'error' listeners and don't count as handling:

error-monitor.cjs
const { EventEmitter, errorMonitor } = require('node:events');
const conn = new EventEmitter();

conn.on(errorMonitor, (err) => console.log('metrics: counted error', err.message));

try {
  conn.emit('error', new Error('ECONNRESET'));
} catch (err) {
  console.log('still thrown:', err.message);
}

conn.on('error', (err) => console.log('handled:', err.message));
conn.emit('error', new Error('ETIMEDOUT'));
Output
metrics: counted error ECONNRESET
still thrown: ECONNRESET
metrics: counted error ETIMEDOUT
handled: ETIMEDOUT
What’s happening
  1. conn.on(errorMonitor, …) registers under a special symbol, not under 'error'. So conn.listenerCount('error') is still 0.
  2. The first emit('error') calls the monitor (metrics: counted error ECONNRESET) and then, finding no real 'error' listener, throws as usual — caught here as still thrown: ECONNRESET.
  3. After a real listener is added, the second emit calls the monitor first and then the handler. The monitor never prevented the crash and never caused one — it only watched.
  4. This is the right tool for libraries like APM agents that want visibility without accidentally swallowing errors the application should handle.

The real-world version is a stream. Its 'error' comes from the event loop, long after your code returned — so only a listener can catch it:

stream-error.cjs
const fs = require('node:fs');

const stream = fs.createReadStream('/no/such/file.txt');
stream.on('error', (err) => console.log('stream error handled:', err.code));
console.log('stream created; the error arrives later');
// stream created; the error arrives later
// stream error handled: ENOENT
What’s happening
  1. createReadStream returns immediately; the file hasn't even been opened yet.
  2. The listener is attached, and the synchronous script finishes.
  3. On a later event-loop turn, the open fails and the stream calls this.emit('error', err) with an ENOENT error. The listener logs it. Without that line, the process would crash with the same "Unhandled 'error' event" trace as above. (pipeline() attaches these for you — see pipe vs pipeline.)

#on, once, off and removeListener

Every subscription has a lifecycle: it's added, it fires some number of times, and it should be removed when whatever it belongs to is finished. Getting the removal wrong is the main way emitters leak memory, so the details matter.

Mental model: each event name maps to an ordered array of function references. on pushes, prependListener unshifts, off deletes one matching entry, and emit iterates over a copy of the array. Everything below follows from that.

lifecycle.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

function logData(d) { console.log('logData', d); }

emitter.on('data', logData);
emitter.on('data', logData);                    // duplicates are allowed
emitter.once('data', (d) => console.log('once', d));
emitter.prependListener('data', (d) => console.log('first!', d));

console.log('count before:', emitter.listenerCount('data'));
emitter.emit('data', 1);
console.log('count after 1st emit:', emitter.listenerCount('data'));

emitter.off('data', logData);                   // removes ONE copy (the most recently added)
emitter.emit('data', 2);

emitter.off('data', (d) => console.log('first!', d)); // a new function: matches nothing
console.log('count now:', emitter.listenerCount('data'));
console.log(emitter.off === emitter.removeListener);
AdvancedOutput: listener counts and once
Output
count before: 4
first! 1
logData 1
logData 1
once 1
count after 1st emit: 3
first! 2
logData 2
count now: 2
true
What’s happening
  1. After registration the 'data' array is [first!, logData, logData, onceWrapper] — prependListener put first! at the front, and logData is in there twice, because Node doesn't de-duplicate. Count: 4.
  2. emit('data', 1) calls all four in array order. logData 1 prints twice. The once wrapper removes itself and then calls its function, so the count drops to 3.
  3. off('data', logData) removes one entry — Node searches from the end, so it removes the most recently added copy. The second emit calls first! and the remaining logData.
  4. off with a fresh arrow function removes nothing. It's a different function object from the one passed to prependListener, even though the source text is identical. Removal is by reference, so if you plan to remove a listener, keep it in a named variable.
  5. off and removeListener are the same function — off is just the shorter alias, matching on. My notes had a heading about "off vs removeListener"; there's no behavioural difference.
AdvancedHow once works, and removing it early

once doesn't register your function directly. It registers a wrapper that removes itself and then calls your function, and it stores your function on the wrapper so off can still find it:

once-internals.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

function onReady() { console.log('ready!'); }
emitter.once('ready', onReady);

const [wrapper] = emitter.rawListeners('ready');
console.log(wrapper === onReady, wrapper.listener === onReady);   // false true
console.log(emitter.listeners('ready')[0] === onReady);          // true

emitter.off('ready', onReady);        // works: Node matches wrapper.listener too
console.log(emitter.emit('ready'));   // false: nothing left to call
What’s happening
  1. once('ready', onReady) stores a wrapper function in the array. rawListeners shows the real contents: the wrapper is not onReady, but wrapper.listener is.
  2. listeners('ready') unwraps for you and returns [onReady], which is what you usually want to see when inspecting.
  3. off('ready', onReady) compares each entry against onReady and against entry.listener, so it finds and removes the wrapper. You can cancel a once before it fires using the function you originally passed.
  4. emit('ready') finds nothing and returns false — ready! never prints.
AdvancedChanging listeners during an emit

Because emit iterates over a copy of the array, listeners added or removed during an emit only affect the next emit:

mutate.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();

function second() { console.log('second (removed mid-emit, still runs this time)'); }

emitter.on('tick', () => {
  console.log('first');
  emitter.off('tick', second);
  emitter.on('tick', () => console.log('late joiner'));
});
emitter.on('tick', second);

console.log('--- emit 1');
emitter.emit('tick');
console.log('--- emit 2');
emitter.emit('tick');
Output
--- emit 1
first
second (removed mid-emit, still runs this time)
--- emit 2
first
late joiner
What’s happening
  1. Emit 1 snapshots [first, second] and calls first, which removes second from the live array and adds a late joiner.
  2. The snapshot still contains second, so it runs anyway. late joiner isn't in the snapshot, so it doesn't run this time.
  3. Emit 2 snapshots the live array, now [first, lateJoiner1]. first runs (adding a second late joiner to the live array) and then late joiner prints once — the newly added one waits for emit 3.
  4. The snapshot rule keeps a single emit predictable — every emit calls exactly the listeners that were registered when it started. But don't build business logic on mid-emit mutation; it's correct but hard to read.
AdvancedRemoving listeners when the owner is done

The real-world rule from my notes: when you subscribe on behalf of something short-lived (a request, a WebSocket connection, a component), unsubscribe when it ends. The leak is the long-lived emitter holding references to short-lived closures — and everything those closures captured:

leak-server.cjs
const http = require('node:http');
const { EventEmitter } = require('node:events');

const priceFeed = new EventEmitter();           // long-lived, app-wide
const fixed = process.argv.includes('--fixed');

const server = http.createServer((req, res) => {
  const onPrice = (p) => res.write(`price ${p}\n`);
  priceFeed.on('price', onPrice);               // one subscription per request

  if (fixed) {
    res.on('close', () => priceFeed.off('price', onPrice)); // unsubscribe when the response ends
  }
  res.end('subscribed\n');                      // short demo: respond right away
});

server.listen(0, async () => {
  const { port } = server.address();
  for (let i = 0; i < 12; i++) {
    await fetch(`http://localhost:${port}/`).then((r) => r.text());
  }
  console.log('listeners left on priceFeed:', priceFeed.listenerCount('price'));
  server.close();
});
Output
$ node leak-server.cjs
(node:75773) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 price listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
listeners left on priceFeed: 12

$ node leak-server.cjs --fixed
listeners left on priceFeed: 0
What’s happening
  1. priceFeed lives for the whole process. Each request creates an onPrice closure that captures that request's res, and subscribes it to the feed.
  2. Without --fixed, nothing ever unsubscribes. After 12 requests the feed holds 12 closures — and through them, 12 finished res objects that can never be garbage collected. On request 11, Node prints MaxListenersExceededWarning (next topic).
  3. With --fixed, each response's 'close' event (fired once the response is finished or the client disconnects) calls priceFeed.off('price', onPrice) with the same function reference, so the count returns to 0. It printed 0 on every one of five runs.
  4. res.on('close', …) itself doesn't leak, because res and its listeners become garbage together. Leaks only happen when the emitter outlives the listener's owner. More patterns are in Common causes of memory leaks.
AdvancedPromise and async-iterator helpers

Two helpers in node:events turn events into promise-shaped code, and both clean up their own listeners:

once-promise.mjs
import { EventEmitter, once } from 'node:events';

const job = new EventEmitter();
setTimeout(() => job.emit('done', 'result-1', 'extra'), 20);

const args = await once(job, 'done');     // resolves with the array of emit arguments
console.log('once resolved with', args);

setTimeout(() => job.emit('error', new Error('boom')), 20);
try {
  await once(job, 'done');                // an 'error' event rejects the promise
} catch (err) {
  console.log('once rejected:', err.message);
}

const ac = new AbortController();
setTimeout(() => ac.abort(), 50);         // nobody will emit 'done' this time
try {
  await once(job, 'done', { signal: ac.signal });
} catch (err) {
  console.log('gave up waiting:', err.name);
}
console.log('listeners left:', job.listenerCount('done'), job.listenerCount('error'));
Output
once resolved with [ 'result-1', 'extra' ]
once rejected: boom
gave up waiting: AbortError
listeners left: 0 0
What’s happening
  1. once(job, 'done') adds a temporary listener and returns a promise. When 'done' fires with two arguments, the promise resolves with an array of them: ['result-1', 'extra'] — usually destructured as const [value] = await once(…).
  2. once also listens for 'error' while it waits. When 'error' fires first, the promise rejects with that error, so a plain try/catch around await handles it. That's a big part of why this helper is nicer than hand-rolling a promise around .once().
  3. With { signal }, aborting the controller rejects the promise with an AbortError and removes the listeners. Use it for timeouts — see Cancelling work with AbortController.
  4. In every case once removed both of its temporary listeners: the counts are 0 0.

events.on(emitter, name) is the many-events version: an async iterator that yields each emit's arguments array, and unsubscribes when you break:

on-iter.mjs
import { EventEmitter, on } from 'node:events';

const feed = new EventEmitter();
let n = 0;
const timer = setInterval(() => feed.emit('price', 100 + n++), 10);

for await (const [price] of on(feed, 'price')) {
  console.log('price', price);
  if (price === 102) break;               // break unsubscribes
}
clearInterval(timer);
console.log('listeners after break:', feed.listenerCount('price'));
// price 100
// price 101
// price 102
// listeners after break: 0
What’s happening
  1. on(feed, 'price') subscribes immediately and buffers events in a queue, so none are lost between loop iterations.
  2. Each iteration of for await takes the next queued event and yields its arguments array; destructuring [price] takes the first one.
  3. break calls the iterator's return(), which removes the listener — the count is 0. An 'error' event on the emitter would make the loop throw instead.
  4. More on this pattern in Async generators and for await.

#MaxListenersExceededWarning

Every emitter tracks how many listeners each event has. When one event goes past the limit — 10 by default — Node prints a MaxListenersExceededWarning, once per event name per emitter. It's a heuristic: most events in a healthy app have a handful of listeners, so eleven on the same event usually means something is subscribing repeatedly and never unsubscribing, like the per-request leak in the previous topic.

It's only a warning. Node doesn't refuse the 11th listener, doesn't remove anything, and doesn't fix the leak. Think of it as a smoke alarm: it tells you to go and look, and turning it off doesn't put out the fire.

Here's the example from my notes, run on Node 26:

max.cjs
const EventEmitter = require('node:events');
const emitter = new EventEmitter();

function doSomething() {
  console.log('Doing something...');
}

for (let i = 0; i < 15; i++) {
  emitter.on('task', doSomething);
}
console.log('registered:', emitter.listenerCount('task'), '| limit:', emitter.getMaxListeners());
AdvancedOutput: the warning at 11 listeners
Output (Node 26)
registered: 15 | limit: 10
(node:75925) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 task listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
What’s happening
  1. The loop adds doSomething 15 times. Duplicates are allowed, so the count really is 15 — all 15 registrations succeed.
  2. On the 11th, Node notices the count passed getMaxListeners() (10) and creates the warning. It only does this once for 'task', so registrations 12–15 are silent.
  3. The warning prints after registered: 15 because process.emitWarning delivers warnings asynchronously, on a later tick. The number after node: is the process ID.
  4. The message names the event (task), the count at the moment it tripped (11), and the emitter's class in brackets — [EventEmitter] here; a class PriceFeed extends EventEmitter shows up as [PriceFeed]. Run with node --trace-warnings to get the stack of the on call that crossed the line, which usually points straight at the leak.
  5. If this ran inside a request handler, emit('task') would call doSomething 15 times — duplicate listeners are often a correctness bug as well as a memory one.
AdvancedCorrecting my notes: warning text and timing
AdvancedCatching the warning programmatically

You can inspect the warning programmatically, which helps when you want to log it with context or fail a test on it:

max2.cjs
const { EventEmitter } = require('node:events');
const emitter = new EventEmitter();
process.on('warning', (w) => {
  console.log(w.name, '|', w.emitter === emitter, w.type, w.count);
});
for (let i = 0; i < 11; i++) emitter.on('task', () => {});
for (let i = 0; i < 11; i++) emitter.on('other', () => {});
for (let i = 0; i < 30; i++) emitter.on('task', () => {});   // no second warning for 'task'
AdvancedOutput: the warning object's fields
Output
(node:75927) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 task listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
MaxListenersExceededWarning | true task 11
(node:75927) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 other listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
MaxListenersExceededWarning | true other 11
What’s happening
  1. Node's warning object carries the details as properties: name, emitter (the actual object), type (the event name) and count.
  2. 'task' crosses 10 and warns; 'other' crosses 10 on the same emitter and warns separately — the limit is per event name.
  3. Adding 30 more 'task' listeners (41 in total) produces no new warning: each event warns at most once per emitter. A quiet log after the first warning doesn't mean the leak stopped.
  4. Adding a 'warning' listener doesn't suppress Node's default printing to stderr — both appear. To make tests fail on a leak, listen for 'warning' in the test setup and throw (or record a failure) when w.name === 'MaxListenersExceededWarning'.
AdvancedWhen raising the limit is correct

When is raising the limit correct? When the number of listeners is by design and bounded — say, one shared emitter with exactly 20 plugins subscribed at startup. Then emitter.setMaxListeners(20) before registering documents that intent. setMaxListeners(0) (or Infinity) disables the check for that emitter, and events.defaultMaxListeners changes the default for every emitter in the process — both are blunt instruments that also hide real leaks. If the count grows with traffic, the fix is always to remove listeners when their owner finishes.

#Building your own EventEmitter

Writing a mini emitter is a common interview exercise, and the JavaScript site has a clean general version at Observer / pub-sub (EventEmitter) — Set-based, with an unsubscribe function returned from on. That version deliberately differs from Node. Here the goal is to match node:events semantics exactly, because those details are what a Node interviewer probes:

  • Listeners are stored in an array, so duplicates are allowed and order is kept.
  • once registers a wrapper that removes itself before calling the listener, and exposes the original as wrapper.listener so off(name, original) works.
  • off removes one matching registration, searching from the newest.
  • emit iterates over a copy, calls listeners with this = the emitter, returns a boolean, and throws on an unhandled 'error'.
  • on, once and off return the emitter, so calls chain.
mini-emitter.cjs
const { inspect } = require('node:util');

class MiniEmitter {
  #events = new Map(); // event name -> array of listeners (duplicates allowed, order kept)

  on(name, listener) {
    if (typeof listener !== 'function') throw new TypeError('listener must be a function');
    const list = this.#events.get(name) ?? [];
    list.push(listener);
    this.#events.set(name, list);
    return this; // chainable, like Node
  }

  once(name, listener) {
    const wrapper = (...args) => {
      this.off(name, wrapper);          // remove first, so a throw can't leave it behind
      return listener.apply(this, args);
    };
    wrapper.listener = listener;        // lets off(name, listener) find the wrapper
    return this.on(name, wrapper);
  }

  off(name, listener) {
    const list = this.#events.get(name);
    if (!list) return this;
    for (let i = list.length - 1; i >= 0; i--) {   // newest match first, one copy only
      if (list[i] === listener || list[i].listener === listener) {
        list.splice(i, 1);
        break;
      }
    }
    if (list.length === 0) this.#events.delete(name);
    return this;
  }

  emit(name, ...args) {
    const list = this.#events.get(name);
    if (!list) {
      if (name === 'error') {
        const err = args[0];
        if (err instanceof Error) throw err;
        const wrapped = new Error(`Unhandled error. (${inspect(err)})`);
        wrapped.context = err;
        throw wrapped;
      }
      return false;
    }
    for (const listener of [...list]) {   // snapshot: changes during emit apply next time
      listener.apply(this, args);        // `this` is the emitter, like Node
    }
    return true;
  }

  listenerCount(name) {
    return this.#events.get(name)?.length ?? 0;
  }
}

module.exports = { MiniEmitter };
What’s happening
  1. #events maps each name to an array. on validates the listener (Node throws a TypeError too), pushes it, and returns this so e.on('a', f).on('b', g) works.
  2. once builds wrapper, an arrow function so this inside it is the emitter. When called, it removes itself first and then calls the real listener with apply(this, args). Removing first matters twice: a listener that throws doesn't stay registered, and a listener that re-emits the same event (the quiz in emit runs listeners synchronously) doesn't call itself again.
  3. wrapper.listener = listener is how off(name, original) can cancel a once before it fires. Without it, callers would need the wrapper, which they never see.
  4. off loops from the end and removes the first match it meets, so with duplicates it takes away the most recent one — Node's rule. Deleting empty arrays keeps emit on the fast "no listeners" path.
  5. emit handles the no-listener case first: false for normal events; for 'error', throw the error, or wrap a non-Error the way Node does (Unhandled error. ('disk full'), with the value on .context).
  6. Otherwise it spreads the array into a copy and calls each listener with this as the emitter. The copy is what makes "removed mid-emit still runs this time, added mid-emit waits for next time" true. It returns true.
AdvancedTesting the mini emitter against node:events

Then check it against the real thing, running the same scenario on both:

compare.cjs
const { EventEmitter } = require('node:events');
const { MiniEmitter } = require('./mini-emitter.cjs');

function scenario(Emitter) {
  const out = [];
  const e = new Emitter();
  const log = (x) => out.push(`log:${x}`);
  e.on('x', log).on('x', log);                       // chaining + duplicates
  e.once('x', (x) => out.push(`once:${x}`));
  e.on('x', function () {
    out.push(`this ok:${this === e}`);
    e.off('x', log);                                  // removal mid-emit
  });
  out.push(`emit1:${e.emit('x', 1)}`);
  out.push(`emit2:${e.emit('x', 2)}`);
  const f = () => out.push('never');
  e.once('y', f);
  e.off('y', f);                                      // remove a once by its original fn
  out.push(`emit y:${e.emit('y')}`, `count:${e.listenerCount('x')}`);
  try { e.emit('error', new Error('boom')); } catch (err) { out.push(`threw:${err.message}`); }
  return out.join(' ');
}

const node = scenario(EventEmitter);
const mine = scenario(MiniEmitter);
console.log(node);
console.log(node === mine ? 'MiniEmitter matches node:events' : mine);
AdvancedOutput: both emitters match
Output
log:1 log:1 once:1 this ok:true emit1:true log:2 this ok:true emit2:true emit y:false count:1 threw:boom
MiniEmitter matches node:events
What’s happening
  1. Registration leaves 'x' as [log, log, onceWrapper, remover]. Chaining works because on returns the emitter.
  2. Emit 1 runs over a snapshot of all four: log:1 twice, once:1 (the wrapper has already removed itself), and the remover, which checks this and removes one log. Live array after: [log, remover].
  3. Emit 2 runs log:2 and the remover again, which removes the last log. Live array: [remover], so count:1.
  4. once('y', f) followed by off('y', f) cancels it through wrapper.listener, so emit('y') returns false and never is never pushed.
  5. The unhandled 'error' throws boom on both. The two output strings are identical, so for this scenario the mini version matches Node's ordering, return values, this and error rules.
AdvancedWhat the mini emitter leaves out

What the mini version leaves out, so you can mention it: prependListener, removeAllListeners, rawListeners, the newListener/removeListener meta-events, the max-listeners warning, captureRejections, and errorMonitor. Node also stores a single listener without an array as a speed optimisation; that changes nothing visible.

#Alternatives: callbacks, promises, observers and pub/sub

My notes listed four alternatives to event emitters: callbacks, promises/async-await, the Observer pattern, and pub/sub. They're not really interchangeable — each one is the natural fit for a different shape of communication. The useful question is: how many results, how many receivers, and does the sender know who they are?

MechanismResultsReceiversSender knows receivers?Typical use
CallbackDefined by the API (often one)OneYes — it was passed infs.readFile(path, cb), array.map(fn)
PromiseExactly one: value or errorAny number of .thensN/A — it's a valueAny one-shot async result
EventEmitterZero or more, per named eventAny number, localNoStreams, sockets, lifecycle events
Observer patternRepeated state changesAny numberIt holds references to themUI models, reactive state
Pub/sub with a brokerMessages on a topicAny number, can be remoteNo — broker in betweenCross-service events (Redis, Kafka, SNS)

The same app often needs two of them. Here's a job that has many progress notifications (emitter-shaped) and one final result (promise-shaped):

styles.mjs
import { EventEmitter, once } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';

// 1. Promise: exactly one outcome
async function fetchUser(id) {
  await sleep(10);
  return { id, name: 'Rohit' };
}

// 2. EventEmitter: many notifications over time, then an end
function importRows(rows) {
  const job = new EventEmitter();
  (async () => {
    for (let i = 1; i <= rows; i++) {
      await sleep(5);
      job.emit('progress', i, rows);
    }
    job.emit('done', rows);
  })();
  return job; // returned before any event fires, so callers can subscribe in time
}

console.log(await fetchUser(7));

const job = importRows(3);
job.on('progress', (i, total) => console.log(`progress ${i}/${total}`));
const [count] = await once(job, 'done');  // bridge the final event back into a promise
console.log('imported', count);
AdvancedOutput: a promise result and progress events
Output
{ id: 7, name: 'Rohit' }
progress 1/3
progress 2/3
progress 3/3
imported 3
What’s happening
  1. fetchUser produces one value, so it's a promise. A promise also can't fire twice or be "missed" — if you attach .then late, you still get the value. An event emitted before you subscribed is gone.
  2. importRows starts its work in an async IIFE and returns the emitter immediately. The first await sleep(5) guarantees no event fires before the caller has subscribed — the same concern as emitting from a constructor.
  3. The progress listener prints three times as rows are processed: that's repeated, open-ended notification, which a single promise can't represent.
  4. await once(job, 'done') converts the final event into a promise, so the end of the job reads like normal async code (and an 'error' would reject it). Streams work the same way: emitter events for chunks, finished()/pipeline() promises for completion.
AdvancedObserver pattern without an emitter

The Observer pattern deserves its own look, because my notes said it's the same thing as an event emitter:

observer.cjs
class Thermostat {                 // the "subject" in the classic Observer pattern
  #observers = new Set();
  #temp = 20;
  attach(observer) { this.#observers.add(observer); }
  detach(observer) { this.#observers.delete(observer); }
  set(temp) {
    this.#temp = temp;
    for (const o of this.#observers) o.update(this);   // one kind of change, one method
  }
  get temp() { return this.#temp; }
}

const display = { update: (t) => console.log('display shows', t.temp) };
const heater = { update: (t) => console.log('heater', t.temp < 18 ? 'on' : 'off') };

const thermostat = new Thermostat();
thermostat.attach(display);
thermostat.attach(heater);
thermostat.set(16);
// display shows 16
// heater on
What’s happening
  1. In the classic (Gang of Four) Observer, the subject keeps a list of observer objects that all implement one agreed interface — here, an update(subject) method.
  2. set(16) changes the state and calls update on every observer, passing the subject itself so each one can read what it needs (t.temp).
  3. The display prints 16; the heater decides 16 < 18, so it prints on. Notification is synchronous, exactly like emit.
  4. Compare with EventEmitter: no interface to implement (any function will do), many named channels on one object instead of one update, explicit arguments instead of "here's the subject, go read it", plus Node-specific rules — the 'error' event, once, the listener warning.
AdvancedCorrecting my notes: observer vs emitter
AdvancedChoosing between the alternatives

#Local events vs distributed messaging

An EventEmitter is an ordinary JavaScript object. Its listeners are function references in one heap, and emit is a function call. So local events only reach listeners in the same JavaScript environment — not other processes, not other pods, not even worker threads in the same process. And they aren't stored anywhere: if the process dies between the save and the emit, the event is gone.

That's fine for what emitters are for — in-process lifecycle and side effects. It's a design bug when an emitter is quietly carrying work that has to happen, like telling the warehouse to ship an order.

cross-worker.cjs
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
const bus = require('./bus.cjs');            // bus.cjs: module.exports = new EventEmitter();

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (m) => {
    if (m === 'subscribed') {
      bus.emit('orderCreated', { id: 1 });   // only this thread's listeners hear it
      console.log('main: listeners on my bus =', bus.listenerCount('orderCreated'));
      worker.postMessage({ type: 'orderCreated', id: 2 }); // explicit message crosses over
    }
  });
} else {
  bus.on('orderCreated', (o) => {
    console.log('worker: heard order', o.id);
    process.exit(0);
  });
  parentPort.on('message', (m) => bus.emit(m.type, m)); // bridge messages into local events
  parentPort.postMessage('subscribed');
}
AdvancedOutput: the worker's bus is separate
Output
main: listeners on my bus = 0
worker: heard order 2
What’s happening
  1. Both threads require('./bus.cjs'), but each thread has its own module cache, so each gets its own emitter (the same effect shown in Shared state and the singleton pattern).
  2. The worker subscribes to orderCreated on its bus and tells the main thread it's ready.
  3. The main thread emits orderCreated with id: 1 on its bus, which has zero listeners — the event goes nowhere, and listenerCount confirms 0. Order 1 is silently lost.
  4. Then it sends { type: 'orderCreated', id: 2 } with postMessage, which copies the data to the worker. The worker's message handler re-emits it locally, and the listener prints worker: heard order 2. (process.exit in a worker ends only that thread.)
  5. Crossing a boundary always takes an explicit transport — postMessage between threads, IPC between processes, a broker between machines. With five pods behind a load balancer, emit in pod A is never heard by pods B–E.
AdvancedReliable work needs more than emit

Even inside one process, an emitter gives you no persistence, no retry and no acknowledgement. Consider await saveOrder(order); events.emit('orderCreated', order). If the process crashes right after the save — a deploy, an OOM kill — the order exists but the warehouse never hears about it. Moving to Redis Pub/Sub doesn't fix that on its own: Redis Pub/Sub is at-most-once — a subscriber that's disconnected when the message is published never receives it.

The standard fix is the transactional outbox: write the event to an outbox table in the same database transaction as the business change, and have a separate relay publish unsent rows to the broker. Here's a runnable version with Node's built-in node:sqlite (no flag needed on Node 26), using a local emitter as a stand-in for the broker:

outbox.cjs
const { DatabaseSync } = require('node:sqlite');
const { EventEmitter } = require('node:events');

const db = new DatabaseSync(':memory:');
db.exec(`
  CREATE TABLE orders (id INTEGER PRIMARY KEY, user TEXT);
  CREATE TABLE outbox (id INTEGER PRIMARY KEY, type TEXT, payload TEXT, sent INTEGER DEFAULT 0);
`);

function createOrder(user) {
  db.exec('BEGIN');
  try {
    const { lastInsertRowid: id } = db.prepare('INSERT INTO orders (user) VALUES (?)').run(user);
    db.prepare('INSERT INTO outbox (type, payload) VALUES (?, ?)')
      .run('orderCreated', JSON.stringify({ id: Number(id), user }));
    db.exec('COMMIT');               // the order and its message are saved together, or not at all
    return Number(id);
  } catch (err) {
    db.exec('ROLLBACK');
    throw err;
  }
}

// A relay publishes unsent rows. Here the "broker" is a local emitter; in production it's Kafka, SQS, etc.
const broker = new EventEmitter();
broker.on('orderCreated', (o) => console.log('warehouse got order', o.id, 'for', o.user));

function relayOnce() {
  const rows = db.prepare('SELECT id, type, payload FROM outbox WHERE sent = 0 ORDER BY id').all();
  for (const row of rows) {
    broker.emit(row.type, JSON.parse(row.payload));       // publish (at least once)
    db.prepare('UPDATE outbox SET sent = 1 WHERE id = ?').run(row.id); // then mark it sent
  }
  return rows.length;
}

createOrder('rohit');
createOrder('neha');
console.log('unsent before relay:', db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent = 0').get().n);
console.log('relayed:', relayOnce());
console.log('relayed on the next poll:', relayOnce());
Output
unsent before relay: 2
warehouse got order 1 for rohit
warehouse got order 2 for neha
relayed: 2
relayed on the next poll: 0
What’s happening
  1. createOrder opens a transaction, inserts the order, inserts an orderCreated row into outbox with the order's new ID, and commits. Either both rows exist or neither does — there is no moment where the order is saved but the event is lost.
  2. Nothing is published yet: unsent before relay: 2. If the process crashed now, both messages would still be in the database when it restarts.
  3. relayOnce reads unsent rows in order, publishes each (here, broker.emit, which synchronously runs the warehouse listener), then marks the row sent = 1.
  4. If the relay crashed between publishing and marking, the next poll would publish that row again. That's why outbox delivery is at-least-once, and consumers must be idempotent — see Delivery guarantees and idempotency.
  5. The second poll finds nothing unsent and relays 0. In production the relay runs on a timer or tails the database log (change data capture), and the broker is Kafka, RabbitMQ, SQS or similar — see Kafka vs RabbitMQ.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.