# nanoevents is 108 bytes because a size limit fails the build

> The event emitter from Evil Martians is small, but the interesting part is the machinery around the smallness: a size budget wired into the test command, a hundred percent coverage threshold, an explicit refusal of Node EventEmitter compatibility, and a node engine range that excludes a large part of the currently deployed fleet.

**ai/nanoevents** — Simple and tiny (107 bytes) event emitter library for JavaScript

- Repository: https://github.com/ai/nanoevents
- Stars: 1,635 · Forks: 52
- Language: TypeScript
- License: MIT
- Published: 2026-09-30 · Updated: 2026-09-30 · Language: en
- Canonical page: https://hysenlabs.com/projects/ai-nanoevents

## Two methods, one of which returns something useful

The whole API is an on method and an emit method, and there are no aliases, which the readme states as a feature rather than a limitation. Installation is a single package install, and everything else follows from those two methods:

```bash
npm install nanoevents
```

The design decision that actually matters is on. In most emitters, subscribing returns nothing, so unsubscribing requires holding a reference to the callback and passing the same reference back to a remove method. Here, on returns an unbind function, so the reference is captured in a closure you are handed, and the readme is explicit that you do not need to save the callback to a variable for removal. The example in the readme makes the sequence concrete: create an emitter, bind a tick listener that accumulates into a summary, emit once and see the summary change, call the unbind function, emit again and see the summary unchanged. That is a small ergonomic win that pays off in components, hooks and anything that mounts and unmounts repeatedly, because the teardown path is a value rather than a lookup. What you give up is the ability to remove a listener you did not keep, which is the standard mitigation for the double-subscription bug, and it means duplicate subscriptions are your problem to solve rather than the library's.

## The size limit is a test, not a promise

The readme says the library is 108 bytes minified and brotlied, and that it uses Size Limit to control size. The package manifest is where that stops being a claim. It carries a size-limit configuration block that imports a named export, the create function, and sets the limit to 108 B, and it wires a size script into the test command. So the number is not something a maintainer measures occasionally and writes in a readme, it is a threshold that a test run enforces, and the same test command that runs coverage, linting and type checks also fails the build if the bundle grows. That is the actual explanation for the library's shape. Every convenience someone proposes has a byte cost against a fixed budget, which is why there is no once method, no event name arrays, no listener inspection beyond a property, and no compatibility layer. The events property exists, giving you an object whose keys are the event names in use and whose values are arrays of functions, and the readme's example shows it after a single subscription. Clearing every listener is done by assigning an empty object to that property. Both are terse in a way that only makes sense if the byte budget is the design constraint, and both are the kind of thing a larger library would hide behind a method.

## Once is a snippet, and the readme says so

The table of contents lists a Once section, and what the section contains is a class with a once method written by you, using the unbind function to remove itself before invoking the callback. That is the readme being straightforward about scope: fire-and-forget subscription is four lines of your code, not a feature of the library. The snippet is instructive for a second reason. Because on returns unbind, the pattern is self-removal with no reference to the callback stored anywhere, which is why it fits in a class method without extra bookkeeping. The readme also notes that if a listener relies on a particular context, such as using this inside itself, you have to bind the required context explicitly before passing the function as a callback, and that binding with the bind method will not work as you might expect and is therefore not recommended. That warning is unusual to see stated so plainly, and it means the emitter does not carry a this binding on dispatch, so an object method passed directly as a listener will not see its own object. Two behaviours you would have to write yourself if you needed them: no once, and no automatic context. Both are consequences of the budget rather than oversights.

## The engine range is the constraint most adopters will hit

The manifest declares engines as node 22, node 24, or anything at or above 26, using caret and range syntax rather than a permissive floor. That is a deliberately narrow statement, and for a package whose whole pitch is smallness and modernity it is consistent, but it is the field most likely to cause friction. A library with a broad engine range is portable; a library pinned to recent releases assumes you are on a current runtime and expects you to keep up. The same attitude shows in the devDependencies, where the type checker and the linting tools are themselves current-generation entries, and in the presence of a pnpm workspace file and a pnpm lock file rather than a yarn or npm lock, which means the maintainer's workflow is pnpm based even though the published package has no runtime dependencies at all. A consumer is not obliged to use pnpm, but anyone cloning the repository to run the tests will need it, and that is worth knowing before you start. There is a development container configuration and a GitHub directory at the top level, so the intended local environment is reproducible; the lock file is what makes it so.

## The test command is four tools and a coverage threshold of 100

The test script is not a single runner call. It is a pattern invocation that runs every script whose name begins with test, and there are four of them. One runs the test runner with a coverage requirement of 100 percent, excluding test files and spec files from the measurement. One runs oxlint as the linter. One runs check-dts, which validates the type declarations. One runs size-limit. That composition is a statement about what this project considers done. A hundred percent coverage threshold on a library this small is achievable, and it removes the usual argument about whether an uncovered branch matters when the entire library is a few dozen lines. Type declaration checking matters more than it would in a larger package, because the TypeScript story is a headline feature and the declaration file is the public contract; a declaration that does not typecheck is a bug that only shows up in a consumer's build. Linting and formatting are handled by oxlint and by an oxfmt configuration file rather than by the more familiar eslint and prettier, and a devcontainer is present for local parity. If you contribute, the bar is clear and mechanically checked, which is a good property to have in a package this small.

## Framework usage is documented as a hook you write, not a binding you import

The last section of the readme is a custom React hook, and it is provided as source to copy rather than as a package to import, which is consistent with everything else here. The hook keeps the callback in a ref that is updated on every render, so the listener always calls the latest closure without re-subscribing, and it subscribes in an effect keyed on the emitter and the event name, returning the unbind function as the effect cleanup. That last detail is the payoff of the whole design. Because on hands back unbind, the hook's teardown is a one-liner with no reference tracking, and there is no separate unmount path to get wrong. A declaration file is shown alongside it, typing the hook generically over the event map and the event name, so a consumer gets a typed hook without the library shipping React types. The example then wires two components together, one subscribing to an increment event and one emitting it, with the emitter created at module scope. The readme frames this as usable with front-end frameworks in general, and the mechanism is framework-agnostic, but the code shown is React, so treat the other frameworks as your own integration work. The type declaration file at the repository root and the exports map in the manifest are what a consumer's tooling reads, and the manifest also declares the package as ES module only with no side effects, so a CommonJS consumer needs a bundler that can consume that.

## Conclusion

Adopt nanoevents if you want an event emitter whose entire surface is two methods and a return value you can store, since the unbind function removes the reference bookkeeping that Node's emitter pushes onto the caller, and if you are building for the browser or an edge runtime where bundle size is a measured constraint. Do not adopt it as a drop-in replacement for the Node EventEmitter, because the readme states there is no compatibility and no aliases, and the API surface will not satisfy code that expects once, off, listeners or event name arrays. Four things to verify. That your node version is inside the declared engine range, which is a narrow set of current releases rather than anything recent, and that this is a runtime constraint on your build as well as your CI. That your bundler honours the sideEffects flag, since tree shaking is what turns 108 bytes into your actual cost. That the event map you declare in TypeScript covers every event you emit, because that is the one place the library gives you a compile-time error rather than a runtime surprise. And that you are comfortable with a major version line where the readme quotes a byte count the repository description contradicts. The licence is MIT, version 10.0.0 was released on 2026-07-22, and the last push was on 2026-07-22.

## FAQ

### How do I install nanoevents?

Run npm install nanoevents. The package is published as an ES module with no runtime dependencies, and the manifest exposes a named createNanoEvents factory plus type declarations for TypeScript users.

### How do I remove a listener in nanoevents?

The on method returns an unbind function. Call it and that listener is removed, so you do not need to keep the callback in a variable and pass it back to a remove method.

### Is nanoevents a drop-in replacement for the Node EventEmitter?

No. The readme states there are no aliases, just emit and on methods, and no Node EventEmitter compatibility. Features such as once, context binding and listener removal by reference are either documented as snippets you write yourself or absent.

### What node versions does nanoevents support?

The manifest declares engines as node ^22.0.0 or ^24.0.0 or >=26.0.0, so recent releases only. The repository uses pnpm with a workspace file, and a development container configuration is included for local development.

### How is the 108 byte size of nanoevents enforced?

The manifest contains a size-limit configuration that imports the createNanoEvents export with a limit of 108 B, and the test command runs every script beginning with test, which includes size-limit, a 100 percent coverage run, linting and type declaration checks.

## Sources

- [ai/nanoevents on GitHub](https://github.com/ai/nanoevents)
- [Issues](https://github.com/ai/nanoevents/issues)
- [License: MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
- [README](https://github.com/ai/nanoevents/blob/main/README.md)
- [Releases](https://github.com/ai/nanoevents/releases)

---

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