A tiny, fast, typed event emitter for JavaScript and TypeScript.
430 bytes · zero dependencies · no eval · optional React hook
import { CozyEvent } from 'cozyevent';
const events = new CozyEvent<{ ping: number }>();
const off = events.on('ping', (n) => console.log('ping', n));
events.emit('ping', 1); // ping 1
off();COZY stands for Compact, On-point, Zero-overhead, Yet-powerful: a small family of TypeScript libraries for everyday needs. No bloat, just performance.
- Install
- Quick start
- Guide
- API reference
- Behaviour in detail
- Why is it so fast?
- Why is it so small?
- Benchmarks
- Size comparison
- FAQ
- Migrating from v1
- Changelog · License
npm install cozyevent
# or
yarn add cozyevent
# or
pnpm add cozyeventThere is one package. The core never imports React, so non-React projects never download or need it.
Works in Node.js 14+ and every modern browser: anything with class fields, ?. / ?? and queueMicrotask. ESM and CommonJS builds are both included, with TypeScript types for each.
import { CozyEvent } from 'cozyevent';
// CommonJS: const { CozyEvent } = require('cozyevent');
const emitter = new CozyEvent();
const unsubscribe = emitter.on('message', (text) => {
console.log(`Received: ${text}`);
});
emitter.once('ready', () => console.log('Ready (only logged once)'));
emitter.emit('ready');
emitter.emit('message', 'Hello, World!');
emitter.emitAsync('message', 'Hello from a microtask');
unsubscribe();Pass an event map as the type parameter. Each key is an event name and its value is the payload type. Use void for events without a payload.
import { CozyEvent } from 'cozyevent';
interface User {
id: string;
name: string;
}
const auth = new CozyEvent<{
login: User;
logout: void;
}>();
auth.on('login', (user) => console.log(`Hi ${user.name}`)); // user: User
auth.on('logout', () => console.log('Bye'));
auth.emit('login', { id: '1', name: 'Ada' }); // OK
auth.emit('logout'); // OK
// auth.emit('login', 42); // type error: 42 is not a User
// auth.emit('sign-in', ...); // type error: unknown eventWithout a type parameter, any string is an event name and payloads are any. The Listener type is exported too:
import type { Listener } from 'cozyevent';
const onLogin: Listener<User> = (user) => console.log(user.id);once listeners run on the next emit and are removed before they run, so they never run twice, not even when the listener emits the same event again.
const onInit = () => console.log('Initialized');
const cancel = emitter.once('init', onInit);
// Either of these removes it before it has run:
cancel();
emitter.off('init', onInit); // the original function works tooon and once return an unsubscribe function. It removes exactly that registration, and calling it again does nothing.
const stop = emitter.on('tick', render);
stop(); // removed
stop(); // no-opoff(event, listener) works too. It removes the most recently added registration of that function, whether it was added with on or once. To clear everything:
emitter.removeAllListeners('chat'); // one event
emitter.removeAllListeners(); // every eventemitAsync takes a snapshot of the listeners right away and calls them later, in one microtask.
emitter.emitAsync('data', { id: 1 });
console.log('this logs first');It schedules nothing when the event has no listeners, and it never throws because listeners were removed in the meantime.
Subclass it, with or without a constructor of your own:
import { CozyEvent } from 'cozyevent';
class Task extends CozyEvent<{ progress: number; done: string }> {
run() {
this.emit('progress', 50);
this.emit('done', 'Task completed');
}
}
const task = new Task();
task.on('progress', (pct) => console.log(`${pct}%`));
task.on('done', (message) => console.log(message));
task.run();CozyEvent reserves four internal names:
_e(listener storage),_k(the event emptied last), the helper method_r, and_T(a type-only marker that lets TypeScript infer a subclass's event map). Don't define, read or write them in a subclass; TypeScript reports an error if you redeclare one. Overriding public methods such asoffis fine, becauseonceand the unsubscribe functions don't go through them.
React support is a single hook in the cozyevent/react subpath. React is an optional peer dependency (>=16.8.0), needed only if you import this subpath. There is no provider, context or registry: create an emitter anywhere, usually at module level, and pass it in.
// events.ts
import { CozyEvent } from 'cozyevent';
export const appEvents = new CozyEvent<{
notify: string;
'cart:add': { id: string; qty: number };
}>();// Toasts.tsx
import { useState } from 'react';
import { useCozyEvent } from 'cozyevent/react';
import { appEvents } from './events';
export function Toasts() {
const [messages, setMessages] = useState<string[]>([]);
// Inline listeners are fine: the hook does not resubscribe on re-render.
useCozyEvent(appEvents, 'notify', (text) => {
setMessages((m) => [...m, text]);
});
return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}// AddToCart.tsx
import { appEvents } from './events';
export function AddToCart({ id }: { id: string }) {
return <button onClick={() => appEvents.emit('cart:add', { id, qty: 1 })}>Add to cart</button>;
}How the hook works:
- It subscribes in
useEffect, keyed on[emitter, event], and unsubscribes on unmount, or when either of them changes. - The latest
listeneris kept in a ref, so every emit calls the current listener and an inline arrow function never causes a resubscribe. - It doesn't use
useLayoutEffect, so it is safe with server rendering, and it leaves exactly one live subscription underStrictMode.
type Listener<T = any> = (payload: T) => void;
class CozyEvent<Events extends Record<string, any> = Record<string, any>> {
on(event, listener): () => void;
once(event, listener): () => void;
off(event, listener): void;
emit(event, payload?): void;
emitAsync(event, payload?): void;
removeAllListeners(event?): void;
}
// cozyevent/react
function useCozyEvent(emitter, event, listener): void;| Method | Returns | What it does |
|---|---|---|
on(event, listener) |
unsubscribe function | Calls listener on every emit of event. Registering the same function twice runs it twice. |
once(event, listener) |
unsubscribe function | Calls listener on the next emit only. It is removed before it runs. |
off(event, listener) |
void |
Removes the most recently added registration of listener (from on or once). Unknown event or listener: nothing happens. |
emit(event, payload?) |
void |
Calls the listeners synchronously, in registration order, with payload. |
emitAsync(event, payload?) |
void |
Snapshots the listeners now and calls them in one microtask. |
removeAllListeners(event?) |
void |
Removes the listeners of event, or of every event when called without an argument. '' is a normal event name. |
- One argument. Listeners get exactly one argument, the payload. Extra arguments are dropped, so to send several values pass an object:
emit('move', { x, y }). - Order. Listeners run in the order they were registered, with
onandoncelisteners mixed in that order. - Snapshots. An emit calls the listeners that were registered when it started.
- A listener added during an emit is not called in that emit.
- A listener removed during an emit that has not run yet still runs in that emit. Later emits don't call it.
- A nested
emitinside a listener uses the listeners registered at that moment. emitAsynctakes its snapshot when it is called. Listeners removed before the microtask still run, exceptoncelisteners that have already run; listeners added before it don't run.
this. Listeners are called as plain functions:thisisundefinedin strict-mode code (globalThisin sloppy scripts), never the emitter. Use arrow functions orbind.- Errors. There is no try/catch. If a listener throws, the listeners after it are skipped and the error reaches the caller of
emit. InemitAsyncthe error is uncaught inside the microtask. - Any event name.
'__proto__','constructor','toString','hasOwnProperty'and''all work like any other name. - Memory. When an event loses its last listener, its entry isn't deleted right away. It is marked empty, and deleted when the next event is emptied, unless it got listeners again in between. Each emitter keeps at most one empty entry, however many dynamic event names it goes through, so memory doesn't grow without limit.
removeAllListeners(event)deletes an entry at once, andremoveAllListeners()drops all storage. (Why? See delayed cleanup below.)
An emit is mostly "look up a list, call each function". The trick is making each of those steps as cheap as the JavaScript engine allows, without generating code at runtime. Every technique below was kept only after it won an interleaved A/B benchmark (see benchmark/ab.js).
1. A dedicated call site for each of the first 12 listeners. V8 remembers, per call site, which function was called there (its inline cache). If one loop calls ten different listeners, that single call site sees ten different functions, becomes megamorphic, and V8 stops inlining. CozyEvent calls the first 12 listeners from 12 separate call sites, then continues with a six-wide loop:
listener 1 → call site 1 (always sees the same function → monomorphic, inlinable)
listener 2 → call site 2
…
listener 12 → call site 12
listener 13+ → six-wide loop
This is why CozyEvent is about 4x faster than other CSP-safe emitters when every listener is a different function, which is what real apps do. Libraries like tseep get the same effect by generating code with new Function, which a strict Content Security Policy blocks. CozyEvent's call sites are ordinary code.
2. A flat record array.
Each event's listeners live in one flat array, [listener, token, listener, token, …]. There is no wrapper object per registration, so emit reads each function straight out of the array.
3. Zero allocations per emit, snapshots for free.
Listener arrays are append-only while in use: on pushes in place, and a removal replaces the array with a copy (copy-on-write). An emit only remembers the array and its length when it starts, and that pair is the snapshot. Nothing is copied or allocated on emit.
4. Cheap unsubscribe.
The unsubscribe function is a bound method with a numeric token, not a new closure capturing the listener. Removing scans from the end of the array, and the copy is a plain slice plus a shift, which measured 1.3x to 2x faster than splice or filter.
5. Delayed cleanup.
Deleting an object property and adding it back, which is exactly what once + emit in a loop does, makes V8 rebuild how it stores that object. CozyEvent keeps the emptied entry as an empty marker instead of deleting it straight away, and cleans it up when the next event empties. That made once about 2.8x faster, while memory stays bounded to one empty entry per emitter.
6. One microtask per emitAsync.
emitAsync schedules a single queueMicrotask for the whole snapshot, not one per listener, and no Promise.
7. Safe storage that is also fast.
Storage objects share one empty, null-prototype object as their prototype. That makes names like __proto__ safe with no extra checks, and creating an emitter cheap (1.7x faster than v1).
- One class, no dependencies, no runtime helpers. The whole core is one file.
- Compression-friendly code. The 12 call sites are written as identical source text (
(0, a[i])(p)afteri += 2), so gzip stores the pattern once. That spelling alone saved 52 bytes after gzip. - React lives elsewhere. The hook is a separate 136-byte entry point (
cozyevent/react), so core users never pay for it. - Bytes are measured, not guessed. Every change was checked with
npm run size, and among equally fast designs the smallest one won.
Of the emitters measured that support once, CozyEvent v2 is the smallest. Only its own v1 is smaller, and v1 had bugs that v2 fixes.
Measured on Apple M4 (10 cores), Node v22.14.0 (V8 12.4.254.21-node.22), Darwin 25.5.0 arm64, 2026-09-18. Each library and scenario ran in its own process, 3 rounds in round-robin order, benchmark.js with maxTime 0.5 s. The machine was not idle, so treat differences under about 10% as noise.
Millions of ops/s, median of 3 isolated runs. Higher is better, and the fastest in each row is in bold. "–" means the library has no such feature. "No codegen" leaves out tseep's default build, which generates code with new Function and can't run under a strict Content Security Policy.
| scenario | cozyevent v2 | cozyevent v1.4.2 | tseep | tseep (CSP-safe build) | nanoevents | emitix | @braintree/ event-emitter | eventemitter3 | eventemitter2 | node:events | v2 rank | v2 rank, no codegen |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| emit, 0 listeners | 239 | 216 | 240 | 255 | 161 | 235 | 264 | 160 | 137 | 95.7 | 4 of 13 | 3 of 12 |
| emit, 1 listener | 162 | 95.5 | 161 | 152 | 153 | 152 | 90.6 | 85.0 | 90.3 | 84.8 | 1 of 13 | 1 of 12 |
| emit, 3 listeners | 141 | 84.8 | 163 | 104 | 105 | 101 | 88.8 | 43.2 | 31.9 | 42.2 | 2 of 13 | 1 of 12 |
| emit, 3 distinct listeners | 142 | 38.3 | 154 | 58.8 | 61.8 | 50.6 | 48.8 | 44.8 | 31.5 | 40.6 | 2 of 13 | 1 of 12 |
| emit, 10 listeners | 101 | 49.0 | 111 | 58.7 | 61.7 | 33.0 | 42.3 | 17.0 | 11.2 | 15.4 | 2 of 13 | 1 of 12 |
| emit, 10 distinct listeners | 82.2 | 11.2 | 96.5 | 21.0 | 20.5 | 19.7 | 18.6 | 17.8 | 15.0 | 15.3 | 2 of 13 | 1 of 12 |
| emit, 100 listeners | 8.09 | 6.47 | 21.4 | 7.40 | 7.73 | 4.42 | 6.51 | 1.76 | 1.53 | 1.68 | 2 of 13 | 1 of 12 |
| app bus: 20 events, 1-3 listeners each | 45.3 | 39.2 | 50.8 | 55.4 | 58.1 | 48.8 | 48.0 | 33.9 | 36.1 | 47.4 | 7 of 13 | 6 of 12 |
| on + off (5 other listeners) | 56.5 | 39.8 | 21.6 | 33.0 | 38.4 | 39.1 | 41.6 | 43.6 | 26.4 | 37.8 | 1 of 13 | 1 of 12 |
| once + emit | 48.9 | 12.3 | 136 | 102 | 29.1 | 21.6 | 24.0 | 16.5 | 12.5 | 13.4 | 3 of 13 | 2 of 12 |
| emitAsync, 10 listeners | 13.1 | 12.2 | – | – | – | – | – | – | 1.64 | – | 1 of 3 | 1 of 3 |
| new emitter + on + emit | 82.7 | 46.9 | 10.5 | 13.6 | 43.6 | 83.6 | 65.7 | 59.2 | 60.8 | 17.9 | 2 of 13 | 2 of 12 |
mitt, @protobufjs/eventemitter and event-emitter were measured too; they are in the full results. "Distinct" rows use a different listener function per registration, and the other emit rows register copies of one function. In on + off, v2 calls off(event, fn); with the returned unsubscribe function it measures 47.8M ops/s. Libraries without once get a small userland wrapper in the once row. eventemitter2's emitAsync returns a Promise, which is a different contract.
Isolated medians can move 10% or more between runs, so this summary comes from interleaved A/B runs (node benchmark/ab.js, 5 rounds, every library once per round in a fresh process, rotating order). Ratios within about 5% are noise.
- Where v2 is fastest. Of the libraries that don't generate code, v2 is first at
emitwith 3, 10 and 100 listeners,on+offandemitAsync.- With 3 and 10 listeners, the next libraries (nanoevents and tseep's CSP-safe build) reach 0.58 to 0.71 of v2's speed with copies of one function, and 0.18 to 0.39 with distinct functions.
- With 100 listeners, the next is @braintree/event-emitter at 0.86, then tseep's CSP-safe build (0.84), nanoevents (0.82) and v1.4.2 (0.80).
- For
on+off, the next is @braintree/event-emitter at 0.76. - v2 ties for first with 1 listener, and on the 20-event app bus (within noise of tseep's CSP-safe build and nanoevents). The single isolated run in the table ranked the app bus lower, but the interleaved rounds did not reproduce that gap.
- Where v2 is behind.
- tseep's default build generates a specialised
emitwithnew Function. That isn't allowed under a strict CSP, and its bundle is 7x larger. It is 2.6x faster with 100 listeners, 1.04x to 1.06x faster with 3 to 10, about as fast with 1, and 3.3x faster atonce. - For
once, v2 is second of the libraries that don't generate code. tseep's CSP-safe build is 2.2x faster; the next library, nanoevents, reaches 0.73 of v2. - Creating an emitter is second to emitix (1.12x).
- tseep's default build generates a specialised
- Compared with v1.4.2. 1.7x faster with 1 listener, 1.8x to 5.9x with 3 and 10 listeners, 1.25x with 100, 1.15x on the app bus and on
on+off, 3.6x ononce, 1.14x onemitAsyncand 1.7x on creating an emitter. With no listeners they are equal within noise.
- Isolated processes. Each (library, scenario) pair runs in its own Node process, so JIT state from one library can't affect another.
- Realistic listener counts. 0, 1, 3, 10 and 100 listeners, the same or distinct functions, plus an app-style bus with 20 event names, on + off cycles, once + emit,
emitAsyncand creating an emitter. There are no scenarios with millions of listeners. - Checked before timing. Each scenario verifies how many listeners were called before it is measured, then warms up before benchmark.js records ops/s and ±RME.
- Libraries. cozyevent v2 and v1.4.2, tseep (default and CSP-safe builds), eventemitter3, eventemitter2, emitix, @braintree/event-emitter, @protobufjs/eventemitter, event-emitter,
node:events, mitt and nanoevents. mitt and nanoevents aren't indevDependencies; they are included automatically when installed.
Run them yourself:
npm run build
npm run benchmark # speed, writes benchmark/reports/results.{json,md}
node benchmark/ab.js # quick interleaved A/B comparison
npm run size # raw / gzip / brotli bytes of the built files
node benchmark/size.js # bundle-size comparison, writes benchmark/reports/size.{json,md}Numbers vary by machine. If yours differ, or you'd like a scenario added, open a benchmark discussion.
Bytes of a rollup + terser ESM bundle of the same small consumer file for each library (create an emitter, on, emit, off), from node benchmark/size.js with Node v22.14.0. The consumer code adds about 26 gzip bytes to every row. Smaller is better.
| # | library | min | gzip | brotli | has once |
|---|---|---|---|---|---|
| 1 | nanoevents | 249 | 184 | 158 | no |
| 2 | mitt | 339 | 203 | 180 | no |
| 3 | cozyevent v1.4.2 (core class only) | 515 | 269 | 245 | yes |
| 4 | @braintree/event-emitter | 637 | 332 | 282 | no |
| 5 | @protobufjs/eventemitter | 753 | 391 | 335 | no |
| 6 | cozyevent v2 | 1241 | 456 | 411 | yes |
| 7 | emitix | 2253 | 951 | 853 | yes |
| 8 | eventemitter3 | 2958 | 1112 | 1004 | yes |
| 9 | event-emitter | 4819 | 1920 | 1733 | yes |
| 10 | tseep | 18381 | 3514 | 3214 | yes |
| 11 | eventemitter2 | 18670 | 5691 | 5131 | yes |
nanoevents, mitt, @braintree/event-emitter and @protobufjs/eventemitter are smaller, but have no once and no emitAsync. v1.4.2's core class is smaller too, but has the bugs listed in the changelog; v1's main entry as published (with its React code) was 760 bytes gzip.
The published files themselves, from npm run size:
| file | min | gzip | brotli |
|---|---|---|---|
dist/index.js (ESM core) |
1191 | 430 | 378 |
dist/index.cjs (CommonJS core) |
1203 | 434 | 382 |
dist/react.js (ESM hook) |
153 | 136 | 119 |
dist/react.cjs (CommonJS hook) |
155 | 134 | 112 |
Does it work with a strict Content Security Policy?
Yes. CozyEvent never uses eval, new Function or WebAssembly.
Does it work with server rendering?
Yes. The core has no DOM or browser dependencies, and the React hook only uses useEffect.
Why not WebAssembly or the GPU, to be even faster?
Listeners are JavaScript functions. WebAssembly and GPU shaders can't call them directly, so every listener call would cross a boundary that costs more than the whole emit does today. The speed comes from giving the JavaScript engine code it can optimise well.
tseep is faster in some rows. Why not do what it does?
tseep's default build generates a custom emit with new Function, which strict-CSP sites block, and its bundle is about 7x larger. CozyEvent gets most of the same benefit with fixed call sites (see Why is it so fast?).
Why don't listeners receive multiple arguments? One argument keeps every call site identical and fast, and types simpler. Pass an object for several values.
Can I catch errors thrown by listeners?
Wrap your listener in a try/catch, or wrap the emit call. CozyEvent doesn't catch errors itself, so they surface right where they happen.
v2 is a breaking release. In short:
- React:
CozyEventProvider,CozyEventContext, the instance registry (registerCozyEventInstance,getCozyEventInstanceById), the global instance and namespaces are gone. ImportuseCozyEventfromcozyevent/reactand pass the emitter:useCozyEvent(emitter, 'auth:login', cb). - Install: plain
npm install cozyevent. Thelatest-lighttag is no longer needed. onandoncenow return an unsubscribe function. This doesn't break existing code.offremoves one registration (the most recently added one), and can now removeoncelisteners.removeAllListeners('')now clears only the''event.
See MIGRATION.md for before/after code covering every change.
See CHANGELOG.md.
Copyright (c) 2025 Mehmet Ergin Turk. Licensed under the MIT license.
X / Twitter: @papa_alpha_papa · Mastodon: @papa_alpha_papa · Bluesky: @erginturk.bsky.social


