# event-pubsub keeps subscriber arrays live while an emit walks them

> A synchronous publish/subscribe module for Node.js and browsers built around five fluent methods, where one-shot handlers are removed before they run and the subscriber array stays mutable during dispatch. Node 22.12 is the floor, and the browser path needs an import map for one dependency.

**RIAEvangelist/event-pubsub** — Event Pubsub for Javascript will work in node js or browser

- Repository: https://github.com/RIAEvangelist/event-pubsub
- Website: https://riaevangelist.github.io/event-pubsub/
- Stars: 21 · Forks: 17
- Language: JavaScript
- License: MIT
- Published: 2026-08-24 · Updated: 2026-08-24 · Language: en
- Canonical page: https://hysenlabs.com/projects/riaevangelist-event-pubsub

## One-shot handlers are unregistered before the handler body ever runs

`once(type, handler)` removes its handler immediately before the first call, not after it returns. That ordering is what makes reentrant emits safe: a one-shot handler that publishes the same event type again will not be re-entered by its own second emit, because the registration is already gone by the time the body executes.

```js
import EventPubSub from 'event-pubsub';

const events = new EventPubSub();

events.on('ready', (payload) => {
    console.log(payload);
});

events.emit('ready', {fast: true});
```

The persistent form is `on(type, handler, once = false)`, so the same method covers both cases and the third argument is where the distinction lives. Event types must be strings, and both `on` and `once` validate the handler immediately at registration rather than at first dispatch, which moves a bad handler to the point where the mistake was made. All public mutators return the current instance, so calls chain.

## The subscriber array stays live while emit walks it

Dispatch is synchronous and runs in registration order, but the array being iterated is not a snapshot. A handler added from inside a handler can run during that same emit if its position allows. A handler removed with `off` before its turn is skipped rather than called anyway.

Removing an entire event type, or calling `reset()` to clear the registry, behaves differently again: those detach the bucket from future lookup, while an array already being walked finishes its current pass. The distinction matters for teardown. Unregistering everything for a component that is unmounting during a dispatch stops later emits from finding the handlers without cutting short the emit already in progress.

```js
events.on('*', (type, ...payload) => {
    console.log(type, payload);
});

events.emit('invoice.paid', {id: 42});
```

`reset()` clears the complete registry rather than one type, so it is the blunt instrument of the five methods.

## __proto__ is a legal event name and list hands back a null-prototype object

Event names taken from outside the program, a request body or a config file, do not need to be filtered. `__proto__`, `constructor` and `toString` all work as ordinary event names because the registry does not key itself on a plain object in a way that lets those strings collide with inherited members.

The `list` getter returns an isolated null-prototype object of handler arrays. Null-prototype is the point: the snapshot carries no inherited properties for a consumer to trip over, and being a copy rather than the registry itself, reading it cannot mutate live state.

```
| `list` | `get list()` | Return an isolated null-prototype object snapshot of handler arrays. |
```

Wildcard subscribers sit under `Symbol.for('event-pubsub-all')`, and only the exact public string `*` maps to that internal symbol. A literal string of `Symbol(event-pubsub-all)` stays an ordinary typed event name and never aliases wildcard removal, so two similarly spelled strings cannot silently unsubscribe each other.

```
| `off` | `off(type = '*', handler = '*')` | Remove every matching handler or a whole event type. |
```

## Node 22.12 is the floor, and one index.js serves import and require

The package manifest declares `engines.node` as `>=22.12.0`, and both `main` and `browser` point at `./index.js` with `type` set to module. There is no separate CommonJS build to fall back to and no transpiled output.

On Node.js 22.12 or newer the same file is reachable through `require`:

```js
const EventPubSub = require('event-pubsub');
```

That CommonJS reachability is a property of the Node version floor rather than a second artifact in the tarball. An older runtime has no path to this source, so the engine field is the honest constraint and not a formality.

The repository layout matches that single entry point: `index.js` sits at the root beside `benchmark/`, `site/`, `test/`, `scripts/`, and the `CHANGELOG.md` and `MIGRATION.md` files that a 6.0.0 major release and a 6.1.x line would need. `vanilla-test.config.json` drives the test runs, and `.npmignore` sits next to `.gitignore` because what ships to npm is narrower than what sits in the repository.

## Without a bundler the browser needs an import map for strong-type

Version 6.1.1 imports the validator by package name rather than by relative path. A bundler resolves that through package metadata, and bundlers may use the dependency's explicit `browser` entry. A native browser does not read npm package metadata, so the resolution has to happen in the document instead.

```html
<script type="importmap">
{
  "imports": {
    "event-pubsub": "./node_modules/event-pubsub/index.js",
    "strong-type": "./node_modules/strong-type/index.js"
  }
}
</script>
<script type="module">
  import EventPubSub from 'event-pubsub';
</script>
```

The map has to be the first thing in the document that touches the module graph, and the paths are relative to the HTML file, so whatever serves it must expose those `node_modules` files over HTTP(S). Opening the page through `file://` will not work. A strict Content Security Policy needs the inline import map and module scripts authorized, through an allowed nonce or a hash.

## A conflicting strong-type at the root needs a scoped import map entry

If the application installs an incompatible `strong-type` at its own root, npm keeps the exact 2.0.0 copy that event-pubsub depends on nested inside it. The browser has no equivalent of that nesting rule, so the dependency boundary has to be restated in the import map with a scope:

```html
<script type="importmap">
{
  "imports": {
    "event-pubsub": "./node_modules/event-pubsub/index.js",
    "strong-type": "./node_modules/strong-type/index.js"
  },
  "scopes": {
    "./node_modules/event-pubsub/": {
      "strong-type": "./node_modules/event-pubsub/node_modules/strong-type/index.js"
    }
  }
}
</script>
```

The scope key is a URL prefix, and it is matched against the referrer of the importing module, which is why scoping it to the event-pubsub directory is what preserves the boundary rather than remapping the name globally. This is the single dependency reason the browser path needs a hand written map at all.

## The published dispatch numbers exclude everything but the emit

The benchmark page states what each figure covers before it states any figure. Bars carry the median nanoseconds per emit, whiskers span p25 to p75, and seven sample dots are plotted per entry. Every rate comes from the same execution-only measurement.

What that excludes is the list worth reading: setup, warmup, validation, verification, serialization, file I/O and CI orchestration are all outside the measured window. So a number here describes the cost of dispatch itself rather than the cost of a program that dispatches, which is the honest framing for a synchronous design but not a throughput estimate for an application.

The scripts reflect that split. `benchmark` runs the harness and then regenerates the chart, `benchmark:smoke` runs the harness alone with `--smoke`, and `benchmark:chart:check` verifies the generated chart, which is what `site:check` uses before publishing the site alongside the Node and Chrome test reports.

## Handler throws reach the publisher, and promises are ignored

Dispatch is synchronous, so an exception raised inside a handler unwinds straight into whoever called `emit`. There is no error boundary in between. Return values are discarded and returned promises are not awaited, so an async handler that rejects produces a rejection nobody is holding.

That makes the boundary the application's job. A subscriber that talks to a network or a database needs its own try and catch inside the handler, or its own rejection handler, because the publisher cannot see the failure and cannot distinguish a handled subscriber from a broken one.

One smaller compatibility detail survives in `off`: when the event type is missing, `off` returns early instead of validating its optional handler. This is kept for 5.x behavior, where a call such as removing a handler from a type that was never registered is a no-op rather than an error. Handlers still validate on the way in through `on` and `once`, so the leniency is scoped to removal only.

## Conclusion

Code that publishes events inside handlers, subscribes during a dispatch, or uses names taken from user input gets specific documented behavior here rather than silent surprises, and the same index.js serves Node import, Node require and browser modules without a transpile step. Before adopting it, check the node engine floor against your runtime, since 22.12 is the stated minimum, and plan for the fact that handler exceptions propagate to the publisher and promises are ignored, so async work inside a subscriber needs its own error handling. If your project ships without a bundler, budget for the import map, and if a conflicting strong-type sits at the root of your dependency tree, the scoped mapping is required rather than optional.

## FAQ

### What does event-pubsub stand for in coding terms?

It is publish/subscribe: publishers emit a named event and subscribers register a handler for that name. event-pubsub implements this synchronously, with `emit(type, ...payload)` running wildcard handlers first and then typed handlers in registration order.

### What does an event mean when this library emits one?

An event is a string name plus a payload. `emit('invoice.paid', {id: 42})` calls every handler registered for that exact string, preceded by any wildcard `*` handlers, which receive the emitted type as their first argument.

### How is event-pubsub different from a plain event-driven architecture?

event-pubsub is the mechanism, not the architecture: a synchronous in-process registry with five methods rather than a broker across processes. Events do not leave the Node.js process or the browser tab, and there is no transport, delivery guarantee or cross-service subscription.

### Does event-pubsub replace Google Cloud Pub/Sub or Eventarc?

It does not compare itself to either, and the differences are structural rather than a benchmark result. Pub/Sub and Eventarc move messages between services over a network; event-pubsub dispatches synchronously in one process, where handler throws propagate to the publisher and promises are ignored.

## Sources

- [Official documentation](https://riaevangelist.github.io/event-pubsub/)
- [Official README](https://github.com/RIAEvangelist/event-pubsub#readme)
- [Project repository](https://github.com/RIAEvangelist/event-pubsub)
- [Release notes](https://github.com/RIAEvangelist/event-pubsub/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/riaevangelist-event-pubsub
