mitt vs nanoevents vs EventEmitter3: Event Bus Libraries in JavaScript (2026)
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
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.