Skip to main content

mitt vs nanoevents vs EventEmitter3: Event Bus Libraries in JavaScript (2026)

·PkgPulse Team

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 EventEmitter is fine for server-side code — no library needed

PackageWeekly DownloadsSizeTypeScriptWildcardNode.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

FeaturemittnanoeventsEventEmitter3
Bundle size~200B~100B~3KB
TypeScript✅ Generics✅ Generics✅ @types
Wildcard events*
Unbind patternoff(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.

Compare event and utility packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.