TL;DR
mitt is the most popular tiny event emitter — 200 bytes, TypeScript generics for typed events, and a clean 3-method API (on, off, emit). nanoevents is even smaller — ~100 bytes, zero-overhead unbind, and the same minimal API. EventEmitter3 is the Node.js EventEmitter API compatible browser alternative — drop-in replacement for Node's built-in EventEmitter, 3x faster, and significantly smaller than the polyfill. For framework-agnostic event buses in the browser, use mitt. For Node.js-compatible EventEmitter behavior in the browser, use EventEmitter3.
Key Takeaways
- mitt: ~6M weekly downloads — 200 bytes, typed events via generics, wildcard support
- nanoevents: ~3M weekly downloads — ~100 bytes, smallest possible event emitter
- eventemitter3: ~12M weekly downloads — Node.js EventEmitter API, works in browser, 3x faster
- Use mitt or nanoevents when you want a tiny event bus with TypeScript generics
- Use EventEmitter3 when migrating code from Node.js EventEmitter to the browser
- Node.js 18+ built-in
EventEmitteris fine for server-side code — no library needed
Download Trends
| Package | Weekly Downloads | Size | TypeScript | Wildcard | Node.js API |
|---|---|---|---|---|---|
mitt | ~6M | ~200B | ✅ Generics | ✅ * | ❌ |
nanoevents | ~3M | ~100B | ✅ Generics | ❌ | ❌ |
eventemitter3 | ~12M | ~3KB | ✅ @types | ❌ | ✅ |
mitt
mitt is the classic tiny event emitter — ultra-small and TypeScript-friendly.
Basic usage
import mitt from "mitt"
// Define event types for TypeScript inference:
type Events = {
packageFetched: { name: string; downloads: number; score: number }
packageError: { name: string; error: Error }
batchComplete: { count: number; duration: number }
reset: undefined // Event with no payload
}
// Create typed emitter:
const emitter = mitt<Events>()
// Listen:
emitter.on("packageFetched", ({ name, downloads, score }) => {
// Fully typed — TypeScript knows all three fields
console.log(`${name}: ${downloads.toLocaleString()} downloads, score: ${score}`)
})
emitter.on("packageError", ({ name, error }) => {
console.error(`Failed to fetch ${name}: ${error.message}`)
})
// Emit:
emitter.emit("packageFetched", { name: "react", downloads: 25000000, score: 92 })
emitter.emit("packageError", { name: "broken-package", error: new Error("404") })
emitter.emit("reset", undefined) // No payload event
// Remove specific listener:
const handler = ({ name }: { name: string }) => console.log(name)
emitter.on("packageFetched", handler)
emitter.off("packageFetched", handler)
// Wildcard — listen to all events:
emitter.on("*", (type, event) => {
console.log(`Event: ${type}`, event)
})
// Remove all listeners for an event:
emitter.off("packageFetched") // No second arg = remove all
// Remove all listeners:
emitter.all.clear()
Global event bus pattern
// events.ts — shared event bus singleton:
import mitt from "mitt"
type AppEvents = {
packageSelected: { name: string }
compareStarted: { packages: string[] }
authChanged: { user: User | null }
themeChanged: { theme: "light" | "dark" }
}
export const bus = mitt<AppEvents>()
// Component A:
import { bus } from "./events"
function PackageCard({ name }: { name: string }) {
const handleClick = () => {
bus.emit("packageSelected", { name })
}
return <button onClick={handleClick}>{name}</button>
}
// Component B — listens without prop drilling:
import { bus } from "./events"
function PackageDetails() {
useEffect(() => {
const handler = ({ name }: { name: string }) => {
setSelectedPackage(name)
}
bus.on("packageSelected", handler)
return () => bus.off("packageSelected", handler) // Cleanup
}, [])
}
React hook for mitt
import { useEffect } from "react"
import { bus } from "./events"
import type { Events } from "./events"
// Typed hook for listening to mitt events:
function useMittEvent<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void
) {
useEffect(() => {
bus.on(event, handler)
return () => bus.off(event, handler)
}, [event, handler])
}
// Usage in component:
function ComparisonPanel() {
useMittEvent("packageSelected", ({ name }) => {
setSelectedPackage(name) // TypeScript knows name: string
})
}
nanoevents
nanoevents is the smallest option — ~100 bytes with a twist: unbind returns an unbind function (no need to keep a reference to the handler).
Basic usage
import { createNanoEvents } from "nanoevents"
interface Events {
packageFetched: (name: string, score: number) => void
packageError: (name: string, error: Error) => void
}
// Note: nanoevents uses function-based types (not object payloads like mitt):
const emitter = createNanoEvents<Events>()
// Listen:
const unbind = emitter.on("packageFetched", (name, score) => {
console.log(`${name}: score ${score}`)
})
// Unbind via returned function (no need to store handler reference):
unbind() // Clean and simple — no off(event, handler) pattern needed
Key difference from mitt
// mitt: store handler reference for removal
const handler = (data: any) => console.log(data)
emitter.on("event", handler)
emitter.off("event", handler) // Must pass same reference
// nanoevents: no reference needed — unbind() returned from on()
const unbind = emitter.on("event", (data) => console.log(data))
unbind() // Simpler — works even with inline arrow functions
EventEmitter3
EventEmitter3 is the Node.js EventEmitter API, optimized and browser-compatible:
Drop-in Node.js replacement
import { EventEmitter } from "eventemitter3"
// Exactly the same API as Node.js EventEmitter:
class PackageAnalyzer extends EventEmitter {
async analyze(packageName: string) {
this.emit("start", { packageName })
try {
const data = await fetchPackageData(packageName)
this.emit("data", data)
this.emit("complete", { packageName, score: data.score })
} catch (error) {
this.emit("error", error)
}
}
}
const analyzer = new PackageAnalyzer()
analyzer.on("start", ({ packageName }) => console.log(`Analyzing ${packageName}...`))
analyzer.on("data", (data) => processData(data))
analyzer.on("complete", ({ packageName, score }) => console.log(`Done: ${score}`))
analyzer.on("error", (err) => console.error(err))
// Once listener (fires once, then auto-removed):
analyzer.once("complete", (data) => saveToCache(data))
// Remove listener:
const handler = (data: any) => console.log(data)
analyzer.on("data", handler)
analyzer.removeListener("data", handler) // Node.js API
// or: analyzer.off("data", handler) // Alias
// Remove all listeners:
analyzer.removeAllListeners("data") // All "data" listeners
analyzer.removeAllListeners() // All listeners for all events
// Get listener count:
console.log(analyzer.listenerCount("data"))
// Prepend listener (fires before existing ones):
analyzer.prependListener("data", highPriorityHandler)
analyzer.prependOnceListener("complete", oneTimeHighPriorityHandler)
Typed events with EventEmitter3
// TypeScript with EventEmitter3:
interface PackageEvents {
packageFetched: [data: PackageData] // Tuple for event args
packageError: [name: string, err: Error]
batchComplete: [count: number]
}
class TypedEmitter extends EventEmitter<PackageEvents> {
fetchPackage(name: string) {
// this.emit is typed:
this.emit("packageFetched", { name, score: 92 }) // PackageData
this.emit("packageError", name, new Error("404")) // Correct tuple
}
}
const emitter = new TypedEmitter()
// Handlers are fully typed:
emitter.on("packageFetched", (data) => {
console.log(data.name, data.score) // TypeScript knows this is PackageData
})
emitter.on("packageError", (name, err) => {
console.error(name, err.message) // Typed as (string, Error)
})
Feature Comparison
| Feature | mitt | nanoevents | EventEmitter3 |
|---|---|---|---|
| Bundle size | ~200B | ~100B | ~3KB |
| TypeScript | ✅ Generics | ✅ Generics | ✅ @types |
| Wildcard events | ✅ * | ❌ | ❌ |
| Unbind pattern | off(event, handler) | unbind() | off(event, handler) |
| Node.js EventEmitter API | ❌ | ❌ | ✅ |
| once() | ❌ | ❌ | ✅ |
| prependListener() | ❌ | ❌ | ✅ |
| listenerCount() | ❌ | ❌ | ✅ |
| Browser compatible | ✅ | ✅ | ✅ |
| Extends/subclass | ❌ | ❌ | ✅ |
When to Use Each
Choose mitt if:
- You need a simple typed event bus in browser code
- Wildcard listener (
*catches all events) is useful for logging/debugging - Tiny bundle size is critical
- React app cross-component communication without prop drilling
Choose nanoevents if:
- Maximum minimalism — smallest possible event emitter
- You prefer the unbind-returns-function pattern over storing handler references
- No wildcard support needed
Choose EventEmitter3 if:
- Migrating Node.js code to the browser that uses EventEmitter
- You need
once(),prependListener(),listenerCount()— the full Node.js API - You're extending EventEmitter in a class (inheritance pattern)
- Already familiar with Node.js EventEmitter and want identical API in the browser
Stick with Node.js built-in EventEmitter if:
- Server-side Node.js code — the built-in is zero cost and perfectly capable
- No browser compatibility needed
Migration Guide
From Node.js EventEmitter to mitt for browser code
When sharing server-side event-driven code with browser environments, replacing Node.js's built-in EventEmitter with mitt keeps the semantics while removing the Node.js dependency:
// Node.js EventEmitter (server-only)
import { EventEmitter } from "node:events"
class PackageMonitor extends EventEmitter {
async check(name: string) {
this.emit("checking", name)
const data = await fetch(`/api/packages/${name}`).then(r => r.json())
this.emit("result", data)
}
}
// mitt (universal — works in browser and Node.js)
import mitt from "mitt"
type Events = { checking: string; result: PackageData }
const emitter = mitt<Events>()
async function check(name: string) {
emitter.emit("checking", name)
const data = await fetch(`/api/packages/${name}`).then(r => r.json())
emitter.emit("result", data)
}
// emitter.on, emitter.off work in any environment
The key behavioral differences: mitt has no once() method (add/remove manually), no prependListener(), and no listenerCount(). For class inheritance patterns (extends EventEmitter), EventEmitter3 is a closer replacement.
Community Adoption in 2026
mitt reaches approximately 5 million weekly downloads, making it the most popular micro event bus in the JavaScript ecosystem. Its growth tracks Vite's and Vue's popularity — it is the recommended cross-component event bus in Vue documentation and widely used in React projects that need lightweight inter-component communication without a state management library. The wildcard * listener is particularly useful for debugging: adding emitter.on("*", console.log) during development gives a live trace of all events.
nanoevents sits at approximately 1 million weekly downloads, serving developers in the sindresorhus and lukeed ecosystems who value zero-dependency, minimum-byte packages. Its unbind-return pattern (const unbind = emitter.on(...)) is the primary differentiator — avoiding the common bug where an inline arrow function handler cannot be removed because no reference was stored.
EventEmitter3 reaches approximately 8 million weekly downloads, driven by its use as a drop-in Node.js EventEmitter replacement in browser-targeting libraries. Primus (WebSocket library), socket.io's browser build, and several other browser compatibility libraries use EventEmitter3 to expose the familiar Node.js event API without requiring the full Node.js events module polyfill.
Memory Leak Prevention and Listener Lifecycle Management
Event emitters are a common source of memory leaks in long-running Node.js processes and React applications. Understanding listener lifecycle is essential for production reliability.
React component cleanup requires removing event listeners when components unmount. All three libraries follow the pattern of storing a return value from the subscription call and invoking it on cleanup:
useEffect(() => {
const unsub = emitter.on('update', handleUpdate);
return unsub; // Called on unmount
}, []);
nanoevents and mitt both return an unsubscribe function directly. EventEmitter3 uses .off() or .removeListener() instead. The difference matters when writing hooks: you cannot use EventEmitter3's method directly as a React cleanup function since it requires bound arguments, whereas nanoevents' and mitt's return value is a zero-argument cleanup function.
Node.js EventEmitter's default warning at 10 listeners (configurable via setMaxListeners()) does not apply to mitt, nanoevents, or EventEmitter3 — these are userland implementations that do not inherit from Node.js's EventEmitter base class. However, the underlying memory concern remains: listeners that are not removed accumulate in the emitter's listener map, preventing garbage collection of everything the listener closure references. For long-lived processes, always implement cleanup for every listener registered.
Typed event maps in TypeScript reduce the risk of listener mismatches (subscribing to an event that is never emitted, or handling the wrong payload type). All three libraries support typed event maps, though the API differs. A typed emitter with strict event definitions catches misspelled event names at compile time — a common source of hard-to-debug "event never fires" bugs.
For global application state events (theme changes, authentication state, toast notifications), a module-singleton emitter is the simplest pattern. Export a single emitter instance from a module and import it wherever events need to be emitted or handled. This avoids prop drilling and Context complexity for low-frequency cross-cutting events without the overhead of a full state management library.
Performance Characteristics and Bundle Considerations
All three libraries are small enough that raw performance differences between them are negligible for application code. The performance consideration that matters in practice is listener management at scale: what happens when an emitter has hundreds of listeners for the same event? mitt and nanoevents use simple arrays — emitting an event iterates the array linearly, which is O(n) in the number of listeners. EventEmitter3 uses the same approach. For typical application event buses with 1–20 listeners per event type, this is irrelevant.
The bundle size difference is significant for browser applications. mitt at 200 bytes gzipped and nanoevents at 130 bytes gzipped are effectively zero cost in any bundle. EventEmitter3 at 2.1KB gzipped is still small, but it is 10–15x larger than mitt. For a utility library that might be used in 50 different micro-frontends or NPM packages, this multiplies. Package authors who want to include an event emitter as a dependency should default to mitt or nanoevents to avoid polluting their consumers' bundles with heavier dependencies.
Cross-Framework State Sharing with Event Buses
A practical use case for event buses in modern web applications is communication between independently deployed micro-frontend islands or between a web application and a Chrome extension or iframe. In these scenarios, a shared event emitter instance is not possible — the code runs in separate JavaScript contexts. Instead, the emitter pattern is replicated using the browser's native message-passing APIs (BroadcastChannel, window.postMessage, or chrome.runtime.sendMessage), with the emitter library used as the local event bus within each island.
mitt's wildcard event handler (the * event type) is particularly useful for debugging cross-island communication: add a single emitter.on('*', (type, event) => console.log(type, event)) handler during development to log every event emitted anywhere in the local context, providing visibility into event flow without instrumenting each handler individually. This debugging pattern works for understanding the event sequence during complex user interactions and is easy to remove before shipping to production.
For React applications, the recommended state management tools (Zustand, Jotai, Redux Toolkit) are usually better choices than a standalone event bus for shared state. However, event buses remain the right tool for fire-and-forget notifications — "toast shown", "analytics event tracked", "sidebar opened" — where the emitter does not need to know if there are listeners, and listeners do not need to know the current state. These ephemeral events map naturally to the pub/sub model that mitt, nanoevents, and EventEmitter3 all implement cleanly.
Methodology
Download data from npm registry (weekly average, February 2026). Bundle sizes from bundlephobia. Feature comparison based on mitt v3.x, nanoevents v3.x, and EventEmitter3 v5.x.
Compare event and utility packages on PkgPulse →
See also: AVA vs Jest and ohash vs object-hash vs hash-wasm, acorn vs @babel/parser vs espree.