These libraries implement the Publish-Subscribe (Pub/Sub) pattern, allowing decoupled communication between different parts of an application. Unlike Node.js's built-in EventEmitter, these packages are designed to be lightweight, tree-shakeable, and safe for browser environments. They handle event registration, emission, and cleanup, but differ significantly in execution models (sync vs async), API ergonomics, and TypeScript support.
When building modern frontend applications, decoupling components is critical for maintainability. While Node.js provides a built-in EventEmitter, it is too heavy for the browser and lacks tree-shaking support. The emittery, eventemitter3, mitt, and nanoevents packages fill this gap, offering lightweight alternatives. However, they solve the problem in different ways. Let's compare how they handle execution, subscription management, and developer experience.
The most critical architectural difference is how these libraries handle event emission. This dictates whether your event flow blocks the main thread or awaits asynchronous tasks.
emittery is fully asynchronous. When you emit an event, it returns a Promise that resolves only after all listeners have finished executing. This prevents race conditions in async flows.
// emittery: Async emission
const emitter = new Emittery();
emitter.on('load', async () => {
await fetchData();
});
// Waits for listener to finish
await emitter.emit('load');
console.log('Data loaded');
eventemitter3 is strictly synchronous. It executes listeners immediately in the call stack. If a listener is async, the emitter does not wait for it.
// eventemitter3: Sync emission
const emitter = new EventEmitter3();
emitter.on('load', async () => {
await fetchData();
});
// Does NOT wait for listener
emitter.emit('load');
console.log('Event fired');
mitt is also synchronous, optimized for speed and simplicity. It iterates over listeners and fires them immediately.
// mitt: Sync emission
const emitter = mitt();
emitter.on('load', async () => {
await fetchData();
});
// Does NOT wait for listener
emitter.emit('load');
console.log('Event fired');
nanoevents follows the synchronous model as well, focusing on minimal overhead during emission.
// nanoevents: Sync emission
const emitter = new NanoEvents();
emitter.on('load', async () => {
await fetchData();
});
// Does NOT wait for listener
emitter.emit('load');
console.log('Event fired');
How you clean up listeners affects memory leak prevention and code readability. Some libraries use explicit removal methods, while others return cleanup functions.
emittery uses explicit on and off methods with the same function reference. You must keep a reference to the handler.
// emittery: Explicit off
const handler = () => console.log('hi');
emitter.on('msg', handler);
// Must pass same reference
emitter.off('msg', handler);
eventemitter3 also uses explicit on and off, but supports passing context to bind this automatically.
// eventemitter3: Explicit off with context
const handler = () => console.log(this.id);
emitter.on('msg', handler, { id: 123 });
// Remove using same reference
emitter.off('msg', handler);
mitt requires the exact function reference for removal, similar to standard DOM events.
// mitt: Explicit off
const handler = () => console.log('hi');
emitter.on('msg', handler);
// Must pass same reference
emitter.off('msg', handler);
nanoevents returns an unbind function directly from on. This is often cleaner as you don't need to store the handler reference separately.
// nanoevents: Unbind function
const unbind = emitter.on('msg', () => console.log('hi'));
// Call returned function to cleanup
unbind();
thisIn class-based architectures, maintaining the correct this context in callbacks is a common pain point. Only one of these libraries solves this natively.
emittery does not support context binding. You must use arrow functions or .bind() manually.
// emittery: Manual bind
emitter.on('msg', this.handleMsg.bind(this));
eventemitter3 has built-in context support. You pass the context as the third argument, and it applies it automatically.
// eventemitter3: Native context
emitter.on('msg', this.handleMsg, this);
mitt does not support context binding. You are responsible for managing scope.
// mitt: Manual bind
emitter.on('msg', this.handleMsg.bind(this));
nanoevents does not support context binding. Arrow functions are the standard approach here.
// nanoevents: Manual bind
emitter.on('msg', this.handleMsg.bind(this));
Type safety is non-negotiable in large frontend codebases. All four libraries offer TypeScript definitions, but the quality varies.
emittery ships with first-class TypeScript support. It is written in TypeScript, ensuring types are always up to date with the implementation.
// emittery: Typed events
const emitter = new Emittery<{
load: { id: number };
}>();
emitter.on('load', (data) => {
// data.id is number
});
eventemitter3 has community-maintained types that are generally reliable but may lag behind major version changes.
// eventemitter3: Generic types
const emitter = new EventEmitter3();
emitter.on('load', (data: { id: number }) => {});
mitt uses a type map approach similar to emittery, providing strong type inference for event payloads.
// mitt: Type map
const emitter = mitt<{
load: { id: number };
}>();
emitter.on('load', (data) => {
// data.id is number
});
nanoevents provides basic types but requires more manual annotation for complex event payloads compared to mitt or emittery.
// nanoevents: Basic types
const emitter = new NanoEvents();
emitter.on('load', (data: any) => {});
| Feature | emittery | eventemitter3 | mitt | nanoevents |
|---|---|---|---|---|
| Execution | π Async (Promise) | βοΈ Sync | βοΈ Sync | βοΈ Sync |
| Cleanup | off() method | off() method | off() method | Unbind function |
| Context | β Manual | β Native | β Manual | β Manual |
| Bundle Size | Medium | Small | Tiny | Smallest |
| Node Compat | β Yes | β Yes | β Yes | β Yes |
emittery is the architectural choice for complex async flows. If your events trigger API calls or database writes that the rest of the app needs to wait for, this is the only safe option. It prevents the "fire and forget" bugs common in sync emitters.
eventemitter3 is the enterprise standard. If you are migrating from Node.js EventEmitter or need context binding for legacy class structures, this offers the smoothest transition with high performance.
mitt is the modern default for React/Vue apps. It strikes the best balance between size and developer experience, offering great TypeScript support without the complexity of async handling.
nanoevents is the specialist tool. Use it when you are building a library yourself, working on embedded web views, or need to shave every kilobyte off your bundle. Its unbind pattern is elegant but less familiar to some teams.
Final Thought: For most modern frontend applications, mitt provides the best balance of simplicity and type safety. However, if your architecture relies heavily on async event coordination, emittery is worth the extra bytes to prevent race conditions.
Choose emittery if you need asynchronous event handling where listeners might perform async operations (like API calls) before the emitter continues. It is ideal for scenarios where the order of resolution matters or when you need to await the completion of all listeners.
Choose eventemitter3 if you need a robust, battle-tested emitter that supports context binding (this) and mimics the Node.js EventEmitter API closely. It is best for complex applications requiring fine-grained control over listener scope and high performance in synchronous flows.
Choose mitt if you prioritize minimal bundle size and a simple, functional API without extra features like context binding. It is perfect for small to medium projects where you just need basic pub/sub functionality without the overhead of class-based emitters.
Choose nanoevents if you want the smallest possible footprint and prefer managing subscriptions via returned unbind functions rather than explicit off calls. It suits extremely performance-critical applications where every byte counts and the event logic is straightforward.

Simple and modern async event emitter
It works in Node.js and the browser (using a bundler).
Highlights
for await...of supportinit/deinit) for lazy resource setup and teardownAbortSignal support for cancellationSymbol.dispose / Symbol.asyncDispose support for automatic cleanupEmitting events asynchronously is important for production code where you want the least amount of synchronous operations. Since JavaScript is single-threaded, no other code can run while doing synchronous operations. For Node.js, that means it will block other requests, defeating the strength of the platform, which is scalability through async. In the browser, a synchronous operation could potentially cause lags and block user interaction.
npm install emittery
import Emittery from 'emittery';
const emitter = new Emittery();
emitter.on('π¦', ({data}) => {
console.log(data);
});
const myUnicorn = Symbol('π¦');
emitter.on(myUnicorn, ({data}) => {
console.log(`Unicorns love ${data}`);
});
emitter.emit('π¦', 'π'); // Will trigger printing 'π'
emitter.emit(myUnicorn, 'π¦'); // Will trigger printing 'Unicorns love π¦'
Emittery accepts strings, symbols, and numbers as event names.
Symbol event names are preferred given that they can be used to avoid name collisions when your classes are extended, especially for internal events.
Toggle debug mode for all instances.
Default: true if the DEBUG environment variable is set to emittery or *, otherwise false.
Example:
import Emittery from 'emittery';
Emittery.isDebugEnabled = true;
const emitter1 = new Emittery({debug: {name: 'myEmitter1'}});
const emitter2 = new Emittery({debug: {name: 'myEmitter2'}});
emitter1.on('test', () => {
// β¦
});
emitter2.on('otherTest', () => {
// β¦
});
emitter1.emit('test');
//=> [16:43:20.417][emittery:subscribe][myEmitter1] Event Name: test
// data: undefined
emitter2.emit('otherTest');
//=> [16:43:20.417][emittery:subscribe][myEmitter2] Event Name: otherTest
// data: undefined
Create a new instance of Emittery.
Type: object
Configure the new instance of Emittery.
Type: object
Configure the debugging options for this instance.
Type: string
Default: undefined
Define a name for the instance of Emittery to use when outputting debug data.
Example:
import Emittery from 'emittery';
Emittery.isDebugEnabled = true;
const emitter = new Emittery({debug: {name: 'myEmitter'}});
emitter.on('test', () => {
// β¦
});
emitter.emit('test');
//=> [16:43:20.417][emittery:subscribe][myEmitter] Event Name: test
// data: undefined
Type: boolean
Default: false
Toggle debug logging just for this instance.
Example:
import Emittery from 'emittery';
const emitter1 = new Emittery({debug: {name: 'emitter1', enabled: true}});
const emitter2 = new Emittery({debug: {name: 'emitter2'}});
emitter1.on('test', () => {
// β¦
});
emitter2.on('test', () => {
// β¦
});
emitter1.emit('test');
//=> [16:43:20.417][emittery:subscribe][emitter1] Event Name: test
// data: undefined
emitter2.emit('test');
Type: Function(string, string, EventName?, Record<string, any>?) => void
Default:
(type, debugName, eventName, eventData) => {
try {
eventData = JSON.stringify(eventData);
} catch {
eventData = `Object with the following keys failed to stringify: ${Object.keys(eventData).join(',')}`;
}
if (typeof eventName === 'symbol' || typeof eventName === 'number') {
eventName = eventName.toString();
}
const currentTime = new Date();
const logTime = `${currentTime.getHours()}:${currentTime.getMinutes()}:${currentTime.getSeconds()}.${currentTime.getMilliseconds()}`;
console.log(`[${logTime}][emittery:${type}][${debugName}] Event Name: ${eventName}\n\tdata: ${eventData}`);
}
Function that handles debug data.
Example:
import Emittery from 'emittery';
const myLogger = (type, debugName, eventName, eventData) => {
console.log(`[${type}]: ${eventName}`);
};
const emitter = new Emittery({
debug: {
name: 'myEmitter',
enabled: true,
logger: myLogger
}
});
emitter.on('test', () => {
// β¦
});
emitter.emit('test');
//=> [subscribe]: test
Subscribe to one or more events.
Returns an unsubscribe method (which is also Disposable, so it can be used with using).
Using the same listener multiple times for the same event will result in only one method call per emitted event.
import Emittery from 'emittery';
const emitter = new Emittery();
emitter.on('π¦', ({data}) => {
console.log(data);
});
emitter.on(['π¦', 'πΆ'], ({name, data}) => {
console.log(name, data);
});
emitter.emit('π¦', 'π'); // log => 'π' and 'π¦ π'
emitter.emit('πΆ', 'π'); // log => 'πΆ π'
You can pass an abort signal to unsubscribe too:
import Emittery from 'emittery';
const emitter = new Emittery();
const abortController = new AbortController();
emitter.on('π', ({data}) => {
console.log(data);
}, {signal: abortController.signal});
abortController.abort();
emitter.emit('π', 'π'); // Nothing happens
Or use using for automatic cleanup when leaving scope:
import Emittery from 'emittery';
const emitter = new Emittery();
{
using off = emitter.on('π¦', ({data}) => {
console.log(data);
});
await emitter.emit('π¦', 'π'); // Logs 'π'
}
await emitter.emit('π¦', 'π'); // Nothing happens
Emittery exports some symbols which represent "meta" events that can be passed to Emittery.on and similar methods.
Emittery.listenerAdded - Fires when an event listener was added.Emittery.listenerRemoved - Fires when an event listener was removed.import Emittery from 'emittery';
const emitter = new Emittery();
emitter.on(Emittery.listenerAdded, ({data: {listener, eventName}}) => {
console.log(listener);
//=> ({data}) => {}
console.log(eventName);
//=> 'π¦'
});
emitter.on('π¦', ({data}) => {
// Handle data
});
listener - The listener that was added.eventName - The name of the event that was added or removed if .on() or .off() was used, or undefined if .onAny() or .offAny() was used.Only events that are not of this type are able to trigger these events.
Remove one or more event subscriptions.
import Emittery from 'emittery';
const emitter = new Emittery();
const listener = ({data}) => {
console.log(data);
};
emitter.on(['π¦', 'πΆ', 'π¦'], listener);
await emitter.emit('π¦', 'a');
await emitter.emit('πΆ', 'b');
await emitter.emit('π¦', 'c');
emitter.off('π¦', listener);
emitter.off(['πΆ', 'π¦'], listener);
await emitter.emit('π¦', 'a'); // Nothing happens
await emitter.emit('πΆ', 'b'); // Nothing happens
await emitter.emit('π¦', 'c'); // Nothing happens
Subscribe to one or more events only once. It will be unsubscribed after the first event that matches the predicate (if provided).
The second argument can be a predicate function or an options object with predicate and/or signal.
Returns a promise for the event data when eventName is emitted and predicate matches (if provided). This promise is extended with an off method.
import Emittery from 'emittery';
const emitter = new Emittery();
const {data} = await emitter.once('π¦');
console.log(data);
//=> 'π'
// With multiple event names
const {name, data} = await emitter.once(['π¦', 'πΆ']);
console.log(name, data);
// With predicate
const event = await emitter.once('data', ({data}) => data.ok === true);
console.log(event.data);
//=> {ok: true, value: 42}
You can pass an abort signal to cancel the subscription. If the signal is aborted before the event fires, the returned promise rejects with the signal's reason. This is useful for timeouts:
import Emittery from 'emittery';
const emitter = new Emittery();
// Reject if 'ready' doesn't fire within 5 seconds
await emitter.once('ready', {signal: AbortSignal.timeout(5000)});
The returned promise has an off method to cancel the subscription without rejecting:
import Emittery from 'emittery';
const emitter = new Emittery();
const promise = emitter.once('π¦');
// Cancel the subscription (the promise will never resolve)
promise.off();
Get an async iterator which buffers data each time an event is emitted.
Call return() on the iterator to remove the subscription. You can also pass an abort signal to cancel the subscription externally, or use await using for automatic cleanup.
import Emittery from 'emittery';
const emitter = new Emittery();
for await (const {data} of emitter.events('π¦')) {
console.log(data);
if (data === 'π2') {
break; // Revoke the subscription when we see the value 'π2'.
}
}
It accepts multiple event names:
import Emittery from 'emittery';
const emitter = new Emittery();
for await (const {name, data} of emitter.events(['π¦', 'π¦'])) {
console.log(name, data);
}
You can use await using for automatic cleanup when leaving scope:
import Emittery from 'emittery';
const emitter = new Emittery();
{
await using iterator = emitter.events('π¦');
for await (const {data} of iterator) {
console.log(data);
}
} // Subscription is automatically revoked
Since Emittery requires Node.js 22+, you can use the built-in async iterator helpers to transform events:
import Emittery from 'emittery';
const emitter = new Emittery();
for await (const {data} of emitter.events('π¦').filter(event => event.data > 3).take(5)) {
console.log(data);
}
Trigger an event asynchronously, optionally with some data. Listeners are called in the order they were added, but executed concurrently.
Returns a promise that resolves when all the event listeners are done. Done meaning executed if synchronous or resolved when an async/promise-returning function. You usually wouldn't want to wait for this, but you could for example catch possible errors. If any listeners throw/reject, the returned promise rejects with an AggregateError β all listener errors are collected in error.errors, so no errors are silently lost. All listeners always run to completion, even if some throw or reject.
Same as above, but it waits for each listener to resolve before triggering the next one. This can be useful if your events depend on each other. Although ideally they should not. Prefer emit() whenever possible.
If any of the listeners throw/reject, the returned promise will be rejected with the error and the remaining listeners will not be called.
import Emittery from 'emittery';
const emitter = new Emittery();
emitter.on('π¦', async () => {
console.log('listener 1 start');
await new Promise(resolve => setTimeout(resolve, 100));
console.log('listener 1 done');
});
emitter.on('π¦', () => {
console.log('listener 2'); // Only runs after listener 1 is done
});
await emitter.emitSerial('π¦');
Subscribe to be notified about any event.
Returns a method to unsubscribe (which is also Disposable). Abort signal is respected too.
import Emittery from 'emittery';
const emitter = new Emittery();
const off = emitter.onAny(({name, data}) => {
console.log(name, data);
});
emitter.emit('π¦', 'π'); // log => 'π¦ π'
emitter.emit('πΆ', 'π'); // log => 'πΆ π'
off();
Remove an onAny subscription.
import Emittery from 'emittery';
const emitter = new Emittery();
const listener = ({name, data}) => {
console.log(name, data);
};
emitter.onAny(listener);
emitter.emit('π¦', 'π'); // log => 'π¦ π'
emitter.offAny(listener);
emitter.emit('π¦', 'π'); // Nothing happens
Get an async iterator which buffers an event object each time an event is emitted.
Call return() on the iterator to remove the subscription. You can also pass an abort signal to cancel the subscription externally, or use await using for automatic cleanup.
import Emittery from 'emittery';
const emitter = new Emittery();
for await (const {name, data} of emitter.anyEvent()) {
console.log(name, data);
}
You can use await using for automatic cleanup when leaving scope:
import Emittery from 'emittery';
const emitter = new Emittery();
{
await using iterator = emitter.anyEvent();
for await (const {name, data} of iterator) {
console.log(name, data);
}
} // Subscription is automatically revoked
Clear all event listeners on the instance.
If eventNames is given, only the listeners for those events are cleared. Accepts a single event name or an array.
import Emittery from 'emittery';
const emitter = new Emittery();
emitter.on('π¦', listener);
emitter.on('πΆ', listener);
emitter.on('π¦', listener);
// Clear a single event
emitter.clearListeners('π¦');
// Clear multiple events
emitter.clearListeners(['πΆ', 'π¦']);
// Clear all events
emitter.clearListeners();
Register a function to be called when the first .on() listener subscribes to eventName. The initFn can optionally return a cleanup (deinit) function, which is called when the last .on() listener unsubscribes (or when clearListeners() removes all listeners for that event).
If .on() listeners already exist when init() is called, initFn is called immediately.
Returns an unsubscribe function (which is also Disposable). Calling it removes the init/deinit hooks, and if the init is currently active, it calls deinit immediately.
[!NOTE] Lifecycle hooks only apply to
.on()listeners. Subscriptions via.events()async iterators do not trigger the init or deinit functions.
import Emittery from 'emittery';
const emitter = new Emittery();
emitter.init('mouse', () => {
terminal.grabInput({mouse: 'button'});
terminal.on('mouse', (name, data) => {
emitter.emit('mouse', data);
});
// Optional: return cleanup (deinit) function
return () => {
terminal.releaseInput();
};
});
// Init is called when the first listener subscribes
const off = emitter.on('mouse', handler);
// Adding more listeners does not call init again
emitter.on('mouse', anotherHandler);
// Removing one listener does not call deinit yet
off();
// Deinit is called when the last listener unsubscribes
emitter.off('mouse', anotherHandler);
You can use using for automatic cleanup of the init/deinit hooks:
import Emittery from 'emittery';
const emitter = new Emittery();
{
using removeInit = emitter.init('mouse', () => {
startListening();
return () => stopListening();
});
} // init/deinit hooks are automatically removed
The number of listeners for the eventNames or all events if not specified.
import Emittery from 'emittery';
const emitter = new Emittery();
emitter.on('π¦', listener);
emitter.on('πΆ', listener);
emitter.listenerCount('π¦'); // 1
emitter.listenerCount(); // 2
Bind the given methodNames, or all Emittery methods if methodNames is not defined, into the target object.
import Emittery from 'emittery';
const object = {};
new Emittery().bindMethods(object);
object.emit('event');
The default Emittery class has generic types that can be provided by TypeScript users to strongly type the list of events and the data passed to their event listeners.
import Emittery from 'emittery';
const emitter = new Emittery<
// Pass `{[eventName]: undefined | <eventArg>}` as the first type argument for events that pass data to their listeners.
// A value of `undefined` in this map means the event listeners should expect no data, and a type other than `undefined` means the listeners will receive one argument of that type.
{
open: string,
close: undefined
}
>();
// Typechecks just fine because the data type for the `open` event is `string`.
emitter.emit('open', 'foo\n');
// Typechecks just fine because `close` is present but points to undefined in the event data type map.
emitter.emit('close');
// TS compilation error because `1` isn't assignable to `string`.
emitter.emit('open', 1);
// TS compilation error because `other` isn't defined in the event data type map.
emitter.emit('other');
A decorator which mixins Emittery as property emitteryPropertyName and methodNames, or all Emittery methods if methodNames is not defined, into the target class.
import Emittery from 'emittery';
@Emittery.mixin('emittery')
class MyClass {}
const instance = new MyClass();
instance.emit('event');
Listeners are not invoked for events emitted before the listener was added. Removing a listener will prevent that listener from being invoked, even if events are in the process of being (asynchronously!) emitted. This also applies to .clearListeners(), which removes all listeners. Listeners will be called in the order they were added. So-called any listeners are called after event-specific listeners.
Listeners always fire asynchronously β they are deferred to the next microtask, so any synchronous code after an unawaited emit() call runs first. If ordering matters, use await emit().
Note that when using .emitSerial(), a slow listener will delay invocation of subsequent listeners. It's possible for newer events to overtake older ones.
Emittery can collect and log debug information.
To enable this feature set the DEBUG environment variable to 'emittery' or '*'. Additionally you can set the static isDebugEnabled variable to true on the Emittery class, or myEmitter.debug.enabled on an instance of it for debugging a single instance.
See API for more details on how debugging works.
EventEmitter in Node.js?There are many things to not like about EventEmitter: its huge API surface, synchronous event emitting, magic error event, flawed memory leak detection. Emittery has none of that.
EventEmitter synchronous for a reason?Mostly backwards compatibility reasons. The Node.js team can't break the whole ecosystem.
It also allows silly code like this:
let unicorn = false;
emitter.on('π¦', () => {
unicorn = true;
});
emitter.emit('π¦');
console.log(unicorn);
//=> true
But I would argue doing that shows a deeper lack of Node.js and async comprehension and is not something we should optimize for. The benefit of async emitting is much greater.
emit()?No. Async emission is Emittery's core design principle. If you need synchronous event emission (for example, proxying DOM events like React's onChange), use a synchronous event emitter.
emit()?No, just use destructuring:
emitter.on('π¦', ({data: [foo, bar]}) => {
console.log(foo, bar);
});
emitter.emit('π¦', [foo, bar]);