npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@npclfg/nano-emitter

v1.0.0

Published

Tiny event emitter with priorities, async emission, and pattern listeners.

Readme

NanoEmitter

A tiny event emitter with priorities, pattern matching, and async support.

  • Zero dependencies
  • TypeScript-first with full type inference
  • ~420 lines of code
  • ESM and CommonJS support

Why NanoEmitter?

| Feature | Node EventEmitter | NanoEmitter | |---------|-------------------|-------------| | Unsubscribe function | ❌ | ✅ on() returns off() | | Listener priorities | ❌ | ✅ Global ordering across all listener types | | Pattern matching | ❌ | ✅ task:* wildcards | | Async sequencing | ❌ | ✅ emitAsync() awaits in order | | AbortSignal support | ❌ | ✅ Modern cancellation | | Promise-based waiting | ❌ | ✅ waitFor() with timeout | | Listener filters | ❌ | ✅ Conditional listener execution | | Dispose lifecycle | ❌ | ✅ dispose() for cleanup | | Listener introspection | ✅ | ✅ listenerCount(), hasListeners() |

Trade-off: NanoEmitter is ~1.3–1.9x slower in microbenchmarks (emit has the larger gap). It is not faster than native EventEmitter — the win is features and types, not speed. For I/O-bound apps, the overhead is negligible.

Table of Contents

Installation

npm install @npclfg/nano-emitter

Requirements: Node.js 16+ or modern browsers (ES2020)

Quick Start

Basic Usage

import { NanoEmitter } from "@npclfg/nano-emitter";

const bus = new NanoEmitter();

// Subscribe - returns unsubscribe function
const off = bus.on("user:login", (user) => {
  console.log(`${user.name} logged in`);
});

// Emit
bus.emit("user:login", { name: "Alice" });

// Unsubscribe
off();

CommonJS

const { NanoEmitter } = require("@npclfg/nano-emitter");

With Priorities

Priorities are global across all listener types (on, onAny, onPattern).

const bus = new NanoEmitter();

bus.on("save", () => console.log("write to disk"), { priority: 1 });
bus.onAny(() => console.log("log all events"), { priority: 100 });
bus.on("save", () => console.log("validate first"), { priority: 10 });

bus.emit("save");
// Output:
// log all events     (priority 100)
// validate first     (priority 10)
// write to disk      (priority 1)

With Patterns

const bus = new NanoEmitter();

// Match any event starting with "task:"
bus.onPattern("task:*", (event, data) => {
  console.log(`Task event: ${event}`);
});

bus.emit("task:start", { id: 1 });  // matches
bus.emit("task:done", { id: 1 });   // matches
bus.emit("task");                    // does NOT match (wildcard needs segment after)

With AbortSignal

const bus = new NanoEmitter();
const controller = new AbortController();

bus.on("update", (data) => {
  console.log("Update:", data);
}, { signal: controller.signal });

bus.emit("update", "first");  // logs
controller.abort();            // auto-unsubscribes
bus.emit("update", "second"); // doesn't log

Async Sequencing

const bus = new NanoEmitter();

bus.on("deploy", async () => {
  await runTests();
  console.log("tests passed");
});

bus.on("deploy", async () => {
  await uploadBuild();
  console.log("uploaded");
});

// Listeners run sequentially, awaiting each one
await bus.emitAsync("deploy");
// Output:
// tests passed
// uploaded

Promise-Based Waiting

const bus = new NanoEmitter();

// Wait for an event with timeout
try {
  const [user] = await bus.waitFor("user:login", { timeout: 5000 });
  console.log(`${user.name} logged in`);
} catch (err) {
  if (err.name === "TimeoutError") {
    console.log("Login timed out");
  }
}

Listener Filters

const bus = new NanoEmitter();

// Only react to important messages
bus.on("message", (msg) => {
  console.log("Important:", msg.text);
}, { filter: (msg) => msg.priority === "high" });

bus.emit("message", { text: "Hello", priority: "low" });   // filtered out
bus.emit("message", { text: "Alert!", priority: "high" }); // logs

Lifecycle Management

const bus = new NanoEmitter();

bus.on("update", () => console.log("update"));

// Clean up when done
bus.dispose();

// After dispose, subscribe/emit throws
bus.emit("update"); // Error: Emitter has been disposed

TypeScript Usage

NanoEmitter is fully typed. Define your event map for type-safe emit/subscribe:

import { NanoEmitter, EventMap } from "@npclfg/nano-emitter";

// Define your events
interface MyEvents extends EventMap {
  "user:login": [user: { id: string; name: string }];
  "user:logout": [userId: string];
  "message": [from: string, text: string];
}

const bus = new NanoEmitter<MyEvents>();

// ✅ Type-safe - TypeScript knows the argument types
bus.on("user:login", (user) => {
  console.log(user.name);  // user is typed as { id: string; name: string }
});

// ✅ Type-safe emit
bus.emit("user:login", { id: "1", name: "Alice" });

// ❌ Type error - wrong argument type
bus.emit("user:login", "wrong");

// ❌ Type error - unknown event
bus.emit("unknown:event");

Type Exports

import {
  NanoEmitter,
  EventMap,           // Base type for event maps
  ListenerOptions,    // { once?, priority?, signal?, filter? }
  EmitterOptions,     // { delimiter?: string }
  WaitForOptions,     // { timeout?, signal? }
  Unsubscribe,        // () => void
} from "@npclfg/nano-emitter";

API Reference

Constructor

new NanoEmitter(options?: EmitterOptions)

| Option | Type | Default | Description | |--------|------|---------|-------------| | delimiter | string | ":" | Separator for pattern matching segments |

Subscribe Methods

on(event, listener, options?): Unsubscribe

Subscribe to an event. Returns an unsubscribe function.

const off = bus.on("ping", (data) => console.log(data), { priority: 5 });
off(); // unsubscribe

| Parameter | Type | Description | |-----------|------|-------------| | event | string | Event name | | listener | (...args) => any | Callback function | | options.once | boolean | Auto-remove after first call (default: false) | | options.priority | number | Higher = runs first (default: 0) | | options.signal | AbortSignal | Auto-unsubscribe when aborted | | options.filter | (...args) => boolean | Only invoke if filter returns true |

once(event, listener, options?): Unsubscribe

Subscribe to an event, automatically unsubscribe after first emit.

bus.once("init", () => console.log("runs once"));

onAny(listener, options?): Unsubscribe

Subscribe to all events. Listener receives event name as first argument.

bus.onAny((event, ...args) => {
  console.log(`Event: ${event}`, args);
});

onPattern(pattern, listener, options?): Unsubscribe

Subscribe to events matching a pattern. Use * as trailing wildcard.

bus.onPattern("user:*", (event, ...args) => {
  // matches "user:login", "user:logout", "user:update:profile"
});

Pattern rules:

  • task:* matches task:done, task:error, task:a:b:c
  • task:* does NOT match task (wildcard requires at least one segment)
  • task:done matches only task:done exactly
  • Wildcard must be the last segment (a:*:b is invalid)

Emit Methods

emit(event, ...args): unknown[]

Emit an event synchronously. Returns array of listener return values.

const results = bus.emit("calculate", 5);
// results = [10, 15] if listeners returned those values

emitAsync(event, ...args): Promise<unknown[]>

Emit an event, awaiting each listener in priority order.

const results = await bus.emitAsync("deploy");

Unsubscribe Methods

off(event, listener): void

Remove a specific listener (first matching instance only).

const handler = (data) => console.log(data);
bus.on("ping", handler);
bus.off("ping", handler);

offAny(listener): void

Remove a wildcard listener (first matching instance only).

offPattern(pattern, listener): void

Remove a pattern listener (first matching instance only).

clear(event?): void

Remove listeners. If event specified, clears only that event's direct listeners. Otherwise clears all.

bus.clear("ping");  // clear all "ping" listeners
bus.clear();        // clear everything (on, onAny, onPattern)

Introspection Methods

listenerCount(event?): number

Returns the number of listeners. Without argument, returns total count. With event, returns count of listeners that would fire for that event (including onAny and matching patterns).

bus.on("ping", () => {});
bus.onAny(() => {});
bus.listenerCount();       // 2
bus.listenerCount("ping"); // 2 (direct + onAny)
bus.listenerCount("pong"); // 1 (only onAny)

hasListeners(event?): boolean

Returns true if there are any listeners. With event, checks if that specific event would trigger any listeners.

if (bus.hasListeners("expensive:operation")) {
  const data = prepareExpensiveData();
  bus.emit("expensive:operation", data);
}

eventNames(): string[]

Returns array of event names that have direct listeners (excludes patterns and onAny).

bus.on("ping", () => {});
bus.on("pong", () => {});
bus.onAny(() => {});
bus.eventNames(); // ["ping", "pong"]

Promise-Based Waiting

waitFor(event, options?): Promise<Args>

Wait for an event to be emitted. Returns a promise that resolves with the event arguments as a tuple.

// Basic usage
const [user, timestamp] = await bus.waitFor("user:login");

// With timeout (rejects with TimeoutError)
const args = await bus.waitFor("ready", { timeout: 5000 });

// With AbortSignal (rejects with AbortError)
const controller = new AbortController();
const args = await bus.waitFor("data", { signal: controller.signal });

| Parameter | Type | Description | |-----------|------|-------------| | event | string | Event name to wait for | | options.timeout | number | Timeout in milliseconds (rejects with TimeoutError if exceeded) | | options.signal | AbortSignal | Cancel waiting (rejects with AbortError if aborted) |

Error handling:

try {
  const args = await bus.waitFor("response", { timeout: 3000 });
} catch (err) {
  if (err.name === "TimeoutError") {
    console.log("Request timed out");
  } else if (err.name === "AbortError") {
    console.log("Request was cancelled");
  }
}

Lifecycle Methods

dispose(): void

Disposes the emitter, removing all listeners and preventing further use. After disposal, any attempt to subscribe or emit will throw an error.

const bus = new NanoEmitter();
bus.on("ping", () => {});

bus.dispose();

bus.emit("ping");       // throws: "Emitter has been disposed"
bus.on("pong", () => {}); // throws: "Emitter has been disposed"

disposed: boolean (getter)

Returns true if the emitter has been disposed.

const bus = new NanoEmitter();
console.log(bus.disposed); // false

bus.dispose();
console.log(bus.disposed); // true

Patterns & Recipes

Cleanup with AbortController

class Component {
  private controller = new AbortController();

  mount() {
    const { signal } = this.controller;
    bus.on("update", this.handleUpdate, { signal });
    bus.onPattern("data:*", this.handleData, { signal });
  }

  unmount() {
    this.controller.abort(); // removes all listeners at once
  }
}

Request/Response Pattern

bus.on("request", async (req) => {
  const result = await processRequest(req);
  bus.emit(`response:${req.id}`, result);
});

// Make a request and wait for response (using waitFor)
async function request(data: RequestData, timeout = 5000): Promise<Response> {
  const id = crypto.randomUUID();
  const responsePromise = bus.waitFor(`response:${id}`, { timeout });
  bus.emit("request", { ...data, id });
  const [result] = await responsePromise;
  return result;
}

Metrics Collection

const metrics = { total: 0, byType: new Map() };

bus.onAny((event) => {
  metrics.total++;
  metrics.byType.set(event, (metrics.byType.get(event) || 0) + 1);
}, { priority: 1000 }); // high priority = runs first

Conditional Emit

// Avoid expensive preparation if no one is listening
if (bus.hasListeners("analytics:pageview")) {
  const data = gatherAnalyticsData(); // expensive
  bus.emit("analytics:pageview", data);
}

Custom Delimiter

// Dot notation (common in enterprise apps)
const bus = new NanoEmitter({ delimiter: "." });
bus.onPattern("user.created.*", handler);
bus.emit("user.created.admin");

// Slash notation (URL-like)
const router = new NanoEmitter({ delimiter: "/" });
router.onPattern("api/users/*", handler);
router.emit("api/users/123");

Filtered Subscriptions

// Only log errors from a specific service
bus.onPattern("log:*", (event, entry) => {
  console.error(`[${event}]`, entry.message);
}, {
  filter: (event, entry) => entry.level === "error"
});

// React only to your own user's updates
const myUserId = getCurrentUserId();
bus.on("user:updated", (user) => {
  updateUI(user);
}, {
  filter: (user) => user.id === myUserId
});

Safe Cleanup with Dispose

class Service {
  private bus = new NanoEmitter();

  start() {
    this.bus.on("task:*", this.handleTask);
  }

  stop() {
    this.bus.dispose(); // removes all listeners, prevents accidental use
  }
}

Error Handling

Listener exceptions propagate to the caller. No internal try/catch.

// Errors bubble up
bus.on("risky", () => { throw new Error("fail"); });

try {
  bus.emit("risky");
} catch (err) {
  console.error("Caught:", err);
}

Async Error Handling

With emitAsync(), if a listener throws, subsequent listeners do NOT run:

bus.on("pipeline", () => console.log("step 1"));
bus.on("pipeline", () => { throw new Error("fail"); });
bus.on("pipeline", () => console.log("step 3")); // never runs

try {
  await bus.emitAsync("pipeline");
} catch (err) {
  // Only "step 1" was logged
}

Resilient Listeners

If you need all listeners to attempt execution:

bus.on("task", async (data) => {
  try {
    await riskyOperation(data);
  } catch (err) {
    console.error("Task failed:", err);
    // Don't rethrow - allows other listeners to run
  }
});

Performance

NanoEmitter is optimized for real-world usage. Benchmarks (Node 20, Apple M1):

| Operation | EventEmitter | NanoEmitter | Overhead | |-----------|--------------|-------------|----------| | emit (5 listeners) | ~27 ns | ~50 ns | 1.9x | | on + off | ~38 ns | ~48 ns | 1.3x |

When overhead matters:

  • Hot loops emitting 10,000+ events/second
  • Real-time systems with microsecond budgets

When overhead is negligible:

  • I/O-bound apps (network latency >> 10ns)
  • Moderate event frequency (< 1000/sec)
  • Internal tooling, CLI apps

Run benchmarks yourself:

npm run bench
ITERATIONS=500000 LISTENERS=10 npm run bench

Migration from EventEmitter

| EventEmitter | NanoEmitter | |--------------|-------------| | emitter.on("x", fn) | const off = bus.on("x", fn) | | emitter.removeListener("x", fn) | off() or bus.off("x", fn) | | emitter.once("x", fn) | bus.once("x", fn) | | emitter.emit("x", data) | bus.emit("x", data) | | emitter.removeAllListeners("x") | bus.clear("x") | | emitter.removeAllListeners() | bus.clear() | | emitter.listenerCount("x") | bus.listenerCount("x") | | emitter.eventNames() | bus.eventNames() |

Key Differences

  1. Global priorities - Priorities work across on, onAny, and onPattern
  2. No this binding - Listeners don't receive emitter as this
  3. No newListener/removeListener events - Use wrapper if needed
  4. No error event special handling - Errors propagate normally
  5. No max listeners - Use listenerCount() for leak detection

Development

npm install
npm run build    # Compile TypeScript (CJS + ESM)
npm test         # Run tests (76 tests)
npm run bench    # Run benchmarks
npm run verify   # Run all checks

License

MIT