Emittery: deferred listeners, disposable cleanup, two debug switches
Simple and modern async event emitter
At a glance
- What is it?
- Emittery is a zero-dependency event emitter that never runs a listener inside the emit call, which buys a non-blocking emit and gives up the ordering a synchronous one provided. Every cleanup path goes through the value on() hands back, and the package installs only on Node.js 22 with ES modules.
- Who is it for?
- Emittery earns its place in Node.js 22 and up code that already ships ES modules and wants every listener moved off the current tick, and it fits browser builds put together by a bundler. It does not fit CommonJS services, older runtimes, or scripts that need listener effects visible on the next line without awaiting the emit call.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 3 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Star history
- sindresorhus/emittery2,077 ★ · +2
| Repository | 2026-10-14 | 2026-10-22 |
|---|---|---|
| sindresorhus/emittery | 2.1k | 2.1k |
2 Stars in the last 8 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Why Emittery defers listeners instead of calling them
Emittery is built around one claim: a synchronous emit is a liability in a single-threaded runtime. While JavaScript performs synchronous work nothing else runs, so on a server that window blocks other requests and cancels out the reason the platform scales through async work, and in a browser the same window can freeze interaction. The library answers with scheduling rather than with advice. Every listener handed to an emit call is deferred to the next microtask, and the caller gets back a promise it can await when that deferred work has to finish before the next statement.
That contract is aimed at Node.js and at browser code assembled by a bundler. Payloads arrive as a single object instead of positional arguments, the implementation carries no runtime dependencies, and type declarations sit beside the code so an event keeps its payload shape when it is consumed through async iteration.
npm install emittery, then the first real emit
The install command is the whole setup, because the package declares no runtime dependencies.
npm install emitteryThe first program is four steps: import the default export, construct an instance, register a listener, then emit a name with a payload. The listener destructures `data` from the object it receives, which is where the payload lands. The opening example binds one listener to a plain string name and a second to a name held in a variable, and the only difference between the two prints is formatting, which is the point. Nothing about the emitter changes when a name becomes a symbol.
Two habits are worth taking from that example. Keep event names in variables, so a name made with `Symbol` cannot collide with something a subclass adds later. And await the emit call whenever the statement after it has to observe what the listener did.
One listener registered twice runs once per event
A subscription call takes a single event name or an array of them, followed by the listener and an optional object whose documented shape is a `signal`. Handing it an array registers one listener function under every name in that array. In the example given for this call, a listener bound to two names prints once for the first emit alongside the listener bound to that one name alone, and prints again for the second emit carrying the name of the event that fired. That payload object carries `name` next to `data`, so one shared handler can report which subscription it came through.
The rule sitting beside that example is the one a skim misses. Using the same listener several times for the same event produces a single method call per emitted event, so a repeated registration neither doubles the work nor duplicates the output. That removes a duplicate-listener bug and it removes a tool at the same time: a handler cannot be made to run twice for one event by registering it twice.
Three ways a listener goes away: return value, signal, using
Removal hangs off the value that subscription returns, not off the event name. The subscribe call hands back an unsubscribe method, and that method is also a `Disposable`, so it works with the `using` keyword: inside a block the subscription is disposed when the scope ends, and an emit issued after that block prints nothing. Three routes exist in total, the returned function, the `using` form that consumes it, and an `AbortSignal` passed through the options object. In the signal example the listener is registered with `{signal: abortController.signal}`, it goes quiet once `abortController.abort()` has run, and the emit that follows reaches nothing.
The documented removal path is that return value alone. No entry pairs an event name with a listener function the way a synchronous emitter would, even though the keyword list in package.json names off among its terms and the examples directory ships an emitonce.js file. The feature list also promises `Symbol.dispose` and `Symbol.asyncDispose` support, which is what makes the scope form legal.
Two switches decide whether a debug line prints
Debug output runs on two switches, and their defaults are not the same. The per-instance `enabled` option under `debug` is a boolean defaulting to false, while the class-level `Emittery.isDebugEnabled` toggle is true when the `DEBUG` environment variable is set to `emittery` or an asterisk, and false otherwise. So a false `enabled` does not mean silence. Flipping the static toggle makes instances log even when they pass only a `name`, and leaving the static toggle off makes an instance log only when it passes `enabled: true`. Two examples pin that down, one setting the static toggle and showing both named instances printing, the other omitting it and printing from the one instance that carries `enabled: true`.
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: undefinedThe `logger` option is where two quieter losses sit. Its documented signature is `Function(string, string, EventName?, Record<string, any>?) => void`, and the default implementation serialises the payload with JSON.stringify, printing the keys it failed to serialise when that call throws. It also converts symbol and number event names with toString, so two symbols sharing one description produce identical debug lines.
Symbol names and the exported meta events
Event names may be strings, symbols or numbers, and symbols are the preferred form because they survive a class being extended without colliding with names the subclass or its callers already use. That reasoning matters most for internal events, where a hardcoded string such as error is exactly the kind of name a downstream package will also pick.
The package exports symbols that stand for meta events and can be passed to `Emittery.on` and the methods beside it. `Emittery.listenerAdded` fires when an event listener was added, and the next entry in that list is `Emittery.listenerRemoved`. Those names give you a way to observe the subscription table itself, which is the only mechanism here that reports on listeners that are already registered rather than on events you emit yourself.
Node 22, ESM only, two files in the tarball
The install gate is narrow enough to read before choosing it. package.json sets `engines` to `>=22`, marks the package as `module` in the `type` field, and gives the exports map exactly two conditions:
"exports": {
"types": "./index.d.ts",
"default": "./index.js"
},
"sideEffects": false,There is no require condition and no CommonJS build, so a CommonJS service cannot load the package through require, and an older runtime stops before the code runs. The `files` list publishes `index.js` and `index.d.ts` and nothing else, so no bundled browser artifact is shipped, which fits the line that browser use goes through a bundler. `sideEffects` is set to false, the flag bundlers read when deciding whether an untouched import can be dropped from a build.
Everything else at the top level is repository scaffolding: `.editorconfig`, `.gitattributes`, a `.github` directory, `.gitignore`, `.npmrc`, `examples/`, `index.test-d.ts`, a license file, `media/`, `readme.md` and `test/`. The examples directory ships clock.js, clocktyped.ts, emit.js, emitonce.js and eventdata.js, and one script, `xo && ava && tsd`, runs lint, unit and type checks in sequence.
The repository is MIT licensed and its last push landed on 2026-09-26. The version field reads 2.1.0, and the tag for it was cut at 11:06:01Z on the same date, two minutes after the push recorded at 11:04:11Z, while the major before it, v2.0.0, landed on 2026-03-04.
The only other emitter named in the files
`p-event` appears in the devDependencies at version 7.1.0, beside ava 6.4.1, delay 7.0.0, tsd 0.33.0, xo 1.2.3 and the Node type definitions. It is absent from the `files` list and the package declares no runtime dependencies at all, so nothing a consumer installs pulls it in. No other emitter is named anywhere, and nothing in the documentation lines the two up against each other.
The approach difference is ordering. A synchronous emitter runs each listener inside the emit call and drops one by pairing an event name with the same function reference. Emittery defers every listener by a microtask and hands back the removal function instead, so code that expects an effect to be visible on the next line has to await the emit call. That trade suits request handling and browser work, and it is the wrong shape for a startup script that has to finish before it reads a file. No throughput or size figure is given anywhere except a bundlephobia badge, and the README documents no listener cap and no leak warning to configure.
Editorial conclusion
Emittery earns its place in Node.js 22 and up code that already ships ES modules and wants every listener moved off the current tick, and it fits browser builds put together by a bundler. It does not fit CommonJS services, older runtimes, or scripts that need listener effects visible on the next line without awaiting the emit call. Before adopting it, read the engines field and the exports map in package.json, then check whether any code of yours registers one listener twice to get two calls, because the dedupe rule turns the second registration into a no-op.
Frequently asked questions
What Node.js version does emittery need?
The `engines` field in package.json requires Node.js `>=22`, so older runtimes are excluded at install time. The same file sets `type` to `module`, which rules out a CommonJS consumer as well.
Does emittery ship a browser build?
No. The `files` field lists only `index.js` and `index.d.ts`, and the browser is covered by the statement that the package works there using a bundler. Without a bundler there is no browser artifact to load.
How do I remove a listener in emittery?
The method returned by the subscribe call is an unsubscribe function that is also a `Disposable`, so it works with `using` or with an `AbortSignal` passed in the options object. Once abort has run on that controller, a later emit reaches the listener no more.
Why does registering the same emittery listener twice not log twice?
The documentation states that using the same listener multiple times for the same event results in only one method call per emitted event. Registering a function twice for one event is a no-op rather than a way to run it twice.
How is emittery debug logging switched on?
`Emittery.isDebugEnabled` is true when the `DEBUG` environment variable is set to `emittery` or an asterisk, and the per-instance `enabled` option under `debug` defaults to false. Each instance also takes a `name` and a `logger` that decide how a line is labelled and printed.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/sindresorhus-emittery)Star history chart for your README
[](https://hysenlabs.com/projects/sindresorhus-emittery)