zephyr-events
v1.6.0
Published
Tiny typed event emitter with race-condition safety
Maintainers
Readme
Zephyr Events — Tiny TypeScript Event Emitter
A lightweight, type-safe event emitter for TypeScript and JavaScript. Zero dependencies, under 1KB gzipped, with built-in race-condition safety that larger alternatives like EventEmitter3 and Node.js EventEmitter lack.
Why Zephyr Events?
Most event emitter libraries break when handlers modify the listener list during emission — a handler that calls off() on itself can skip the next handler, or adding a new listener mid-emit can cause infinite loops. Zephyr Events solves this with snapshot-based iteration, making it safe for real-world use in UI frameworks, state machines, and plugin systems.
- Under 1KB gzipped — 4.5KB ESM, 866B gzipped, zero dependencies, tree-shakeable
- Race-condition safe — handlers can subscribe, unsubscribe, or clear during emit without side effects
- Fast mode —
zephyrEventsFastskips the snapshot for ~50% faster emit once an event has four or more handlers - Full TypeScript support — generic event maps, strict handler signatures, IDE autocompletion
- Universal builds — ESM, CommonJS, and UMD for browsers, Node.js, and bundlers
- Wildcard listeners — subscribe to all events with
* - Shared state — pass a handler map between emitters for cross-module communication
Installation
npm install zephyr-eventsyarn add zephyr-eventspnpm add zephyr-eventsQuick Start
TypeScript Event Emitter
import zephyrEvents from 'zephyr-events';
// Define your event types
type AppEvents = {
'user:login': { id: number; name: string }
'user:logout': { id: number }
'error': Error
}
const emitter = zephyrEvents<AppEvents>();
// Subscribe — returns an unsubscribe function for easy cleanup
const unsubscribe = emitter.on('user:login', (user) => {
console.log(`Welcome, ${user.name}`);
});
// Emit with full type checking
emitter.emit('user:login', { id: 1, name: 'Alice' });
// Clean up when done
unsubscribe();Fast Mode
zephyrEventsFast walks the live handler array instead of copying it. That pays off once an event type has four or more handlers — the copy is what zephyrEvents spends its time on:
import { zephyrEventsFast } from 'zephyr-events';
const emitter = zephyrEventsFast<AppEvents>();
// Same API — on(), off(), emit(), allBelow four handlers, don't. zephyrEvents snapshots one to three handlers into locals without allocating, which makes it faster than fast mode there — by 11% at one handler and by over 70% at three. Reach for fast mode only once you have measured a hot event with four or more listeners. See Performance Benchmarks.
JavaScript Event Emitter
const zephyrEvents = require('zephyr-events');
const { zephyrEventsFast } = require('zephyr-events');
const emitter = zephyrEvents(); // safe (snapshot)
const fast = zephyrEventsFast(); // fast (no snapshot)
emitter.on('message', (data) => {
console.log('Received:', data);
});
emitter.emit('message', { text: 'Hello' });API Reference
zephyrEvents<Events>(all?)
Creates a new typed event emitter with snapshot-safe emission. Optionally accepts an existing handler map to share event state between emitters.
const emitter = zephyrEvents<{
message: string
data: { value: number }
}>();
// Share handlers between emitters
const shared = new Map();
const emitterA = zephyrEvents(shared);
const emitterB = zephyrEvents(shared);zephyrEventsFast<Events>(all?)
Creates a non-snapshot event emitter. Same API as zephyrEvents, but emit() iterates the live handler array instead of a copy — roughly 50% faster once an event has four or more handlers, and slower below that.
Because the array is live, handlers that modify the listener list change the cycle in flight. Removing a handler shifts the rest down, so one that had not run yet can be skipped, and a handler added during the cycle can land in a vacated slot and run. It will not crash and it will not run away: the loop never invokes more handlers than were registered when emit() was called. But the delivery set is not stable — use zephyrEvents when handlers may subscribe or unsubscribe during emission.
import { zephyrEventsFast } from 'zephyr-events';
const emitter = zephyrEventsFast<{ tick: number }>();emitter.on(type, handler): Unsubscribe
Registers an event handler. Returns an unsubscribe function for automatic cleanup — no need to keep a reference to the handler. The returned function is idempotent: calling it more than once is harmless and never removes another registration of the same handler.
// Type-safe subscription
const unsub = emitter.on('message', (msg) => {
console.log(msg);
});
// Wildcard listener — receives every event
emitter.on('*', (type, event) => {
console.log(`[${String(type)}]`, event);
});
// Unsubscribe when no longer needed
unsub();emitter.off(type, handler?)
Removes a specific handler, or all handlers for an event type.
// Remove a specific handler
emitter.off('message', myHandler);
// Remove all handlers for an event type
emitter.off('message');
// Remove every wildcard listener
emitter.off('*');emitter.emit(type, event)
Emits an event to all registered handlers. Handlers run synchronously from a snapshot, so the listener list can be safely modified during emission.
emitter.emit('message', 'Hello World!');
emitter.emit('data', { value: 42 });Handlers are called in registration order, and matching wildcard listeners run after them.
Errors are not caught. If a handler throws, the exception propagates out of emit() and every remaining handler — including wildcard listeners — is skipped. This matters when a wildcard is doing audit logging or metrics: an unrelated subscriber that throws will silently take it down with it. Guard handlers you do not control:
emitter.on('order:created', (order) => {
try {
riskyThirdPartyHook(order);
} catch (err) {
logger.error({ err }, 'hook failed');
}
});emitter.all
The underlying Map<EventType, Handler[]> that stores all registered handlers. Can be inspected, serialized, or shared between emitter instances.
Use Cases
Event Bus for React Components
import zephyrEvents from 'zephyr-events';
type UIEvents = {
'modal:open': { id: string }
'modal:close': { id: string }
'toast': { message: string; severity: 'info' | 'error' }
}
// Create a shared event bus
export const uiBus = zephyrEvents<UIEvents>();
// In a React component — clean up on unmount
useEffect(() => {
const unsub = uiBus.on('toast', (toast) => {
showToast(toast.message, toast.severity);
});
return unsub;
}, []);Pub/Sub in Node.js Microservices
import zephyrEvents from 'zephyr-events';
type ServiceEvents = {
'order:created': { orderId: string; total: number }
'order:shipped': { orderId: string; trackingId: string }
'inventory:low': { sku: string; remaining: number }
}
const events = zephyrEvents<ServiceEvents>();
// Multiple subscribers
events.on('order:created', sendConfirmationEmail);
events.on('order:created', updateAnalytics);
events.on('inventory:low', notifyWarehouse);
// Audit logging with wildcard
events.on('*', (type, data) => {
logger.info({ event: type, payload: data });
});Plugin System
import zephyrEvents from 'zephyr-events';
type PluginEvents = {
'init': { config: Record<string, unknown> }
'transform': { input: string }
'destroy': undefined
}
function createPluginHost() {
const emitter = zephyrEvents<PluginEvents>();
return {
register(plugin: (on: typeof emitter.on) => void) {
plugin(emitter.on.bind(emitter));
},
emit: emitter.emit.bind(emitter),
};
}Race-Condition Safety
Unlike most event emitters, Zephyr Events handles listener modification during emission safely. Each emit() call captures both the typed handler list and the wildcard list when it is entered, so adding or removing handlers mid-emit never causes skipped handlers or infinite loops:
const emitter = zephyrEvents();
// Safe: handler removes itself during emit
emitter.on('data', function once(data) {
emitter.off('data', once);
process(data); // next handler still fires
});
// Safe: handler adds new listeners during emit
emitter.on('init', () => {
emitter.on('init', () => {
// this will NOT fire during the current emit cycle
});
emitter.on('*', () => {
// neither will a wildcard added mid-emit
});
});The rule is the same in both directions and for both lists: the set of handlers an emit() delivers to is fixed at the moment emit() is called. A listener added during the cycle waits for the next event; a listener removed during the cycle still receives the current one.
This applies to zephyrEvents only. zephyrEventsFast deliberately drops it — see Fast Mode.
Comparison with Other Event Emitters
| Feature | Zephyr Events | mitt | EventEmitter3 | Node.js EventEmitter |
|---------|:---:|:---:|:---:|:---:|
| Bundle size | 866B gzip | ~200B | ~7KB | Built-in |
| TypeScript types | Native | Native | Bundled | @types/node |
| Race-condition safe | Yes | No | No | No |
| Fast (no-snapshot) mode | Yes | No | No | No |
| Wildcard listeners | Yes | Yes | No | No |
| Unsubscribe function | Yes | No | No | No |
| Shared handler maps | Yes | Yes | No | No |
| Zero dependencies | Yes | Yes | Yes | N/A |
| Tree-shakeable ESM | Yes | Yes | Yes | No |
Sizes are not measured the same way: Zephyr Events ships unminified, so 866B is the gzipped size of the published ESM bundle as-is (a bundler's minifier shrinks it further). The mitt and EventEmitter3 figures are the minified+gzipped numbers those projects publish.
Performance Benchmarks
Tested on Apple Silicon M-series (ARM64), Node.js v25.2.1. Every benchmark runs in its own V8 process, median of 3 runs. Run node benchmark-compare.js to reproduce.
Safe vs Fast
| Operation | Safe (snapshot) | Fast (no snapshot) | Delta | |-----------|----------------:|-------------------:|------:| | Creation | 52.2M ops/s | 51.9M ops/s | −1% | | Emit (1 handler) | 97.5M ops/s | 79.8M ops/s | −18% | | Emit (2 handlers) | 94.3M ops/s | 82.2M ops/s | −13% | | Emit (3 handlers) | 88.6M ops/s | 78.6M ops/s | −11% | | Emit (4 handlers) | 38.1M ops/s | 67.3M ops/s | +77% | | Emit (10 handlers) | 27.8M ops/s | 41.8M ops/s | +51% | | Emit (100 handlers) | 7.7M ops/s | 11.5M ops/s | +50% | | Emit (1 handler + wildcard) | 76.7M ops/s | 77.9M ops/s | +2% | | Wildcard only | 90.0M ops/s | 87.7M ops/s | −3% | | On + unsub cycle | 11.3M ops/s | 11.4M ops/s | +1% | | Off (specific handler) | 12.0M ops/s | 12.1M ops/s | +1% | | Mixed (on/emit/unsub) | 9.7M ops/s | 9.5M ops/s | −1% |
The crossover is at four handlers. zephyrEvents snapshots one, two, or three handlers by holding them in locals — an exact snapshot with no allocation — so below four there is nothing for fast mode to save and the safe emitter is 11–18% ahead. From four handlers up, the array copy starts to dominate and fast mode pulls ahead by 50–77%.
Unrolling further was tried and rejected: extending the locals path to four or six handlers won that case but made the one-handler and wildcard-only paths about 8% slower, which are far more common.
Each benchmark runs in a fresh process with its own warmup. That isolation matters: measured in a single shared process, ordering and accumulated GC pressure moved these numbers by more than 2×. No-op handlers measure emitter overhead only — real-world throughput depends on handler complexity.
Bundle Formats
Zephyr Events ships three bundle formats for maximum compatibility:
| Format | File | Size (raw / gzip) | Use case |
|--------|------|------------------:|----------|
| ESM | dist/zephyr-events.mjs | 4.5KB / 866B | Modern bundlers (Vite, Rollup, webpack 5+) |
| CommonJS | dist/zephyr-events.js | 4.5KB / 886B | Node.js require(), older bundlers |
| UMD | dist/zephyr-events.umd.js | 4.9KB / 1013B | Script tags, AMD loaders, legacy environments |
All three are generated from the same compiled source by build.js, which loads and smoke-tests each one before the build succeeds.
Requirements
- Node.js >= 18
- TypeScript >= 4.7 (for type features; runtime has no TS dependency)
- Works in all modern browsers (ES2020+ support)
Changelog
Release history and upgrade notes are in CHANGELOG.md.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md before opening a pull request.
Acknowledgments
Zephyr Events is a modernization of mitt by Jason Miller. Built on the same simple API with added type safety, race-condition handling, and universal module support.
License
Original mitt: MIT (c) Jason Miller
