<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/hookable-vs-tapable-vs-emittery-plugin-hook-systems-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/hookable-vs-tapable-vs-emittery-plugin-hook-systems-2026/raw.md -->
<!-- Source path: content/guides/hookable-vs-tapable-vs-emittery-plugin-hook-systems-2026.mdx -->

---
og_image: "/images/guides/hookable-vs-tapable-vs-emittery-plugin-hook-systems-2026.webp"
title: "hookable vs tapable vs emittery 2026"
description: "Compare hookable, tapable, and emittery for building plugin systems in JavaScript. Hook-based architecture, async hooks, waterfall patterns, and which hook."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["javascript", "typescript", "developer-tools", "nodejs"]
---

## TL;DR

**hookable** is the UnJS hook system — lightweight, TypeScript-first, async hooks, used by Nuxt and Nitro for their plugin architecture. **tapable** is webpack's hook engine — synchronous and async hook types (SyncHook, AsyncSeriesHook, AsyncParallelHook), waterfall and bail patterns, powers webpack's entire plugin system. **emittery** is a modern async event emitter — typed events, async listeners, `onAny` catch-all, unsubscribe support. In 2026: hookable for building tool plugin systems, tapable for webpack-style plugin architectures, emittery for async event-driven patterns.

## Key Takeaways

- **hookable**: ~5M weekly downloads — UnJS, TypeScript hooks, simple API, Nuxt/Nitro plugins
- **tapable**: ~20M weekly downloads — webpack core, multiple hook types, waterfall/bail patterns
- **emittery**: ~8M weekly downloads — async events, typed, unsubscribe, no inheritance needed
- hookable and tapable are for plugin architectures — define named hooks, plugins tap into them
- emittery is for event-driven patterns — publish/subscribe, decouple components
- tapable is more complex but more powerful (waterfall, bail, loop patterns)

---

## hookable

[hookable](https://github.com/unjs/hookable) — lightweight hook system:

### Basic usage

```typescript
import { createHooks } from "hookable"

// Define hook types:
interface BuildHooks {
  "build:before": (config: BuildConfig) => void | Promise<void>
  "build:after": (result: BuildResult) => void | Promise<void>
  "build:error": (error: Error) => void | Promise<void>
  "module:resolve": (id: string) => string | void
}

const hooks = createHooks<BuildHooks>()

// Register hooks (plugins):
hooks.hook("build:before", async (config) => {
  console.log("Preparing build...")
  config.minify = true
})

hooks.hook("build:after", (result) => {
  console.log(`Built in ${result.duration}ms`)
})

// Call hooks:
await hooks.callHook("build:before", config)
// All registered "build:before" hooks run in order

await hooks.callHook("build:after", { duration: 1234 })
```

### Plugin pattern

```typescript
import { createHooks } from "hookable"

interface AppHooks {
  "app:init": () => void | Promise<void>
  "request:before": (req: Request) => void | Promise<void>
  "request:after": (req: Request, res: Response) => void | Promise<void>
  "app:close": () => void | Promise<void>
}

// App with plugin system:
function createApp() {
  const hooks = createHooks<AppHooks>()

  return {
    // Register a plugin (object with hook methods):
    use(plugin: Partial<AppHooks>) {
      hooks.addHooks(plugin)
    },

    // Lifecycle:
    async start() {
      await hooks.callHook("app:init")
      console.log("App started")
    },

    async handleRequest(req: Request) {
      await hooks.callHook("request:before", req)
      const res = await processRequest(req)
      await hooks.callHook("request:after", req, res)
      return res
    },

    async stop() {
      await hooks.callHook("app:close")
    },
  }
}

// Usage:
const app = createApp()

// Plugin as object:
app.use({
  "app:init": () => console.log("Logger plugin initialized"),
  "request:before": (req) => console.log(`→ ${req.method} ${req.url}`),
  "request:after": (req, res) => console.log(`← ${res.status}`),
})

// Plugin as hook:
app.use({
  "request:before": async (req) => {
    // Authentication check
    if (!req.headers.authorization) {
      throw new Error("Unauthorized")
    }
  },
})
```

### How Nuxt uses hookable

```typescript
// Nuxt modules register hooks:
export default defineNuxtModule({
  hooks: {
    "build:before": () => {
      console.log("Custom build step...")
    },
    "pages:extend": (pages) => {
      pages.push({ name: "custom", path: "/custom", file: "~/pages/custom.vue" })
    },
    "nitro:config": (config) => {
      config.prerender.routes.push("/sitemap.xml")
    },
  },
})
```

---

## tapable

[tapable](https://github.com/webpack/tapable) — webpack's hook engine:

### Hook types

```typescript
import {
  SyncHook,
  SyncBailHook,
  SyncWaterfallHook,
  AsyncSeriesHook,
  AsyncParallelHook,
  AsyncSeriesBailHook,
  AsyncSeriesWaterfallHook,
} from "tapable"

// SyncHook — call plugins synchronously in order:
const initHook = new SyncHook<[string]>(["config"])
initHook.tap("LoggerPlugin", (config) => console.log("Init:", config))
initHook.call("production")

// SyncBailHook — stop at first non-undefined return:
const resolveHook = new SyncBailHook<[string], string>(["request"])
resolveHook.tap("AliasPlugin", (request) => {
  if (request.startsWith("@/")) return request.replace("@/", "./src/")
})
resolveHook.tap("DefaultPlugin", (request) => request)
const resolved = resolveHook.call("@/utils/helpers")
// → "./src/utils/helpers" (AliasPlugin handled it, DefaultPlugin skipped)

// SyncWaterfallHook — each plugin transforms the result:
const transformHook = new SyncWaterfallHook<[string]>(["code"])
transformHook.tap("MinifyPlugin", (code) => code.replace(/\s+/g, " "))
transformHook.tap("BannerPlugin", (code) => `/* v1.0 */\n${code}`)
const result = transformHook.call("const x = 1;  const y = 2;")
// → "/* v1.0 */\nconst x = 1; const y = 2;"
```

### Async hooks

```typescript
import { AsyncSeriesHook, AsyncParallelHook } from "tapable"

// AsyncSeriesHook — plugins run one after another:
const buildHook = new AsyncSeriesHook<[BuildConfig]>(["config"])

buildHook.tapPromise("TypeCheckPlugin", async (config) => {
  await runTypeCheck()
  console.log("Type check passed")
})

buildHook.tapPromise("BundlePlugin", async (config) => {
  await bundle(config)
  console.log("Bundle complete")
})

// Run in series: TypeCheck → Bundle
await buildHook.promise(config)

// AsyncParallelHook — plugins run simultaneously:
const lintHook = new AsyncParallelHook<[string[]]>(["files"])

lintHook.tapPromise("ESLintPlugin", async (files) => {
  await eslint(files)
})

lintHook.tapPromise("PrettierPlugin", async (files) => {
  await prettier(files)
})

// Run in parallel: ESLint + Prettier simultaneously
await lintHook.promise(["src/index.ts"])
```

### Webpack-style plugin system

```typescript
import { SyncHook, AsyncSeriesHook, AsyncSeriesWaterfallHook } from "tapable"

class Compiler {
  hooks = {
    initialize: new SyncHook(),
    beforeCompile: new AsyncSeriesHook<[CompileOptions]>(["options"]),
    compile: new AsyncSeriesHook<[Compilation]>(["compilation"]),
    emit: new AsyncSeriesHook<[Assets]>(["assets"]),
    afterEmit: new AsyncSeriesHook<[Stats]>(["stats"]),
    processAssets: new AsyncSeriesWaterfallHook<[Assets]>(["assets"]),
  }

  async run() {
    this.hooks.initialize.call()
    await this.hooks.beforeCompile.promise(this.options)
    const compilation = new Compilation()
    await this.hooks.compile.promise(compilation)
    let assets = compilation.getAssets()
    assets = await this.hooks.processAssets.promise(assets)
    await this.hooks.emit.promise(assets)
  }
}

// Plugin:
class MinifyPlugin {
  apply(compiler: Compiler) {
    compiler.hooks.processAssets.tapPromise("MinifyPlugin", async (assets) => {
      // Transform and return assets (waterfall pattern):
      return minifyAssets(assets)
    })
  }
}

// Usage:
const compiler = new Compiler()
new MinifyPlugin().apply(compiler)
await compiler.run()
```

---

## emittery

[emittery](https://github.com/sindresorhus/emittery) — modern async event emitter:

### Basic usage

```typescript
import Emittery from "emittery"

const emitter = new Emittery<{
  "build:start": { config: BuildConfig }
  "build:complete": { duration: number }
  "build:error": Error
}>()

// Subscribe:
emitter.on("build:start", async ({ config }) => {
  console.log("Building with config:", config)
})

emitter.on("build:complete", ({ duration }) => {
  console.log(`Built in ${duration}ms`)
})

// Emit (waits for all async listeners):
await emitter.emit("build:start", { config: myConfig })
await emitter.emit("build:complete", { duration: 1234 })
```

### Unsubscribe and once

```typescript
import Emittery from "emittery"

const emitter = new Emittery()

// Unsubscribe:
const off = emitter.on("data", (value) => {
  console.log(value)
})
off()  // Unsubscribe

// Once — listen for one event:
const value = await emitter.once("ready")
// Resolves when "ready" is emitted

// onAny — catch all events:
emitter.onAny((eventName, data) => {
  console.log(`Event: ${eventName}`, data)
})
```

### Event-driven architecture

```typescript
import Emittery from "emittery"

class PackageAnalyzer {
  events = new Emittery<{
    "analyze:start": { package: string }
    "analyze:progress": { package: string; step: string; percent: number }
    "analyze:complete": { package: string; score: number }
    "analyze:error": { package: string; error: Error }
  }>()

  async analyze(packageName: string) {
    await this.events.emit("analyze:start", { package: packageName })

    try {
      await this.events.emit("analyze:progress", {
        package: packageName,
        step: "Fetching metadata",
        percent: 25,
      })
      const metadata = await fetchMetadata(packageName)

      await this.events.emit("analyze:progress", {
        package: packageName,
        step: "Calculating score",
        percent: 75,
      })
      const score = calculateScore(metadata)

      await this.events.emit("analyze:complete", { package: packageName, score })
    } catch (error) {
      await this.events.emit("analyze:error", { package: packageName, error })
    }
  }
}
```

---

## Feature Comparison

| Feature | hookable | tapable | emittery |
|---------|---------|--------|---------|
| Hook/event types | Named hooks | 7+ hook types | Named events |
| Async support | ✅ | ✅ (tapPromise) | ✅ (native) |
| Waterfall | ❌ | ✅ | ❌ |
| Bail (early exit) | ❌ | ✅ | ❌ |
| Parallel execution | ✅ (callHookParallel) | ✅ (AsyncParallel) | ✅ (emit) |
| TypeScript | ✅ (native) | ✅ (generics) | ✅ (native) |
| Unsubscribe | ✅ | ❌ | ✅ |
| onAny (catch-all) | ❌ | ❌ | ✅ |
| Plugin objects | ✅ (addHooks) | ✅ (apply) | ❌ |
| Dependencies | 0 | 0 | 0 |
| Used by | Nuxt, Nitro | webpack, rspack | Sindre tools |
| Weekly downloads | ~5M | ~20M | ~8M |

---

## When to Use Each

**Use hookable if:**
- Building a tool/framework with a plugin system
- Want simple TypeScript-first hooks
- In the UnJS ecosystem (Nuxt, Nitro patterns)
- Need async hooks with clean API

**Use tapable if:**
- Building a webpack-style plugin architecture
- Need waterfall pattern (each plugin transforms data)
- Need bail pattern (first plugin to return stops chain)
- Want sync + async + parallel hook combinations
- Building a complex build tool with many extension points

**Use emittery if:**
- Need event-driven pub/sub pattern
- Want typed events with easy unsubscribe
- Building observable/reactive systems
- Need `onAny` catch-all for logging/debugging
- Don't need the complexity of a plugin architecture

---

## Building Real Plugin Systems with hookable

hookable's simplicity makes it deceptively powerful for framework authors. The `addHooks` method accepts a plain object whose keys are hook names, which means a plugin is just a typed object — no class inheritance, no `apply` method, no registration boilerplate. This design makes plugins trivially serializable and easy to unit test in isolation.

The `callHook` method awaits all registered listeners in sequence, which means the order of `hook()` calls determines execution order. Nuxt modules exploit this: modules registered earlier in `nuxt.config.ts` run their `build:before` hooks before modules registered later, giving a deterministic layering model. If you need parallel execution, `callHookParallel` fires all listeners concurrently and awaits `Promise.all`, which suits independent side effects like notifying multiple external systems after a build.

One hookable feature worth knowing is `removeHook` and `removeAllHooks`. In long-running servers (like Nitro dev mode), hot-reloading a module should unregister its previous hooks before registering new ones. Without cleanup, every file save accumulates duplicate hook listeners, causing increasingly slow hook execution and subtle double-execution bugs. hookable's explicit API makes this lifecycle management tractable — something that pure EventEmitter-based solutions struggle with because listener identity is not tracked by default.

---

## tapable's Waterfall and Bail Patterns in Practice

tapable's hook variety exists because plugin architectures have fundamentally different coordination needs. Understanding when to choose each type prevents subtle bugs that appear only at scale.

`SyncWaterfallHook` is designed for transformation pipelines where each plugin receives the output of the previous one. webpack's asset processing uses this pattern: each plugin in the `processAssets` stage receives the current asset map, may transform it (minify, hash filenames, inject banners), and returns the modified map. The final result is whatever the last plugin produced. This only works correctly if every plugin returns the value it receives — a plugin that returns `undefined` accidentally truncates the pipeline.

`SyncBailHook` is for resolution and decision chains. webpack's module resolver uses bail hooks so that the first plugin that can resolve a module path wins; subsequent plugins are skipped. This is more efficient than running every resolver and picking a winner, and it maps naturally to priority-ordered rule systems.

`AsyncParallelHook` is for independent side effects that should run concurrently. If your build system needs to upload assets to S3, notify a webhook, and write a manifest file after compilation, these three tasks have no dependencies on each other — `AsyncParallelHook` fires them simultaneously and the overall hook completes when all three finish. If any one fails, the hook rejects. This matters at scale: sequential side effects that could be parallel silently add latency to every build.

---

## emittery vs Node.js EventEmitter: Why the API Differences Matter

Node.js's built-in `EventEmitter` is synchronous — `emit()` calls listeners inline and returns before they complete. This works for simple notification patterns but creates subtle bugs when listeners do async work: exceptions thrown in async listeners become unhandled rejections, and there is no way to know when async work triggered by an event has finished.

emittery's `emit()` returns a Promise that resolves only after all async listeners complete. This makes event-driven code genuinely awaitable: you can `await emitter.emit("build:complete", result)` and know that all downstream work (logging, cache invalidation, metrics) has finished before your code continues. This is the correct model for any async listener, and it is impossible to express cleanly with `EventEmitter`.

The typed generics in emittery provide a second advantage over `EventEmitter`: you cannot emit an event with the wrong payload shape, and TypeScript narrows the event data type inside each listener based on the event name. In `EventEmitter`, both the event name and payload are untyped at the call site. For long-lived codebases where event names and shapes evolve, the compile-time checking emittery provides catches refactoring errors that `EventEmitter` silently passes through to runtime.

The `onAny` handler is emittery's most underrated feature for debugging. Adding a single `onAny` listener that logs every event name and serialized payload gives you a complete trace of all application events with one line of code — no need to manually add logging to each emitter call.

---

## TypeScript Generics and Type Safety Across All Three

TypeScript integration quality differs meaningfully between the libraries. hookable's generic type parameter on `createHooks<HookMap>()` makes the entire hooks object strongly typed — calling `hooks.callHook("build:before", wrongArgument)` produces a compile error, and TypeScript infers the parameter types of registered hook functions automatically. This eliminates a category of runtime errors in plugin systems where a hook is called with the wrong argument shape. tapable's TypeScript support is more verbose: each hook constructor requires explicit generic type parameters (`new SyncHook<[string, number]>(["name", "count"])`), which document the hook's contract but require careful maintenance as hook signatures evolve. emittery's generic map approach is the most ergonomic: define event types once as an interface and the emit/on API enforces correctness at every call site without repeated annotations. For library authors who want to expose a typed plugin API to external consumers, hookable and emittery both produce clean TypeScript interfaces that third-party plugins can depend on without reaching into library internals.

## Choosing Between a Plugin System and an Event System

The distinction between hookable and tapable on one side and emittery on the other maps to a fundamental architectural question: does your system need coordinated hooks or decoupled events? Plugin systems — where external code extends core behavior — benefit from hookable and tapable because the framework controls the execution model. Hooks run in registration order, return values can modify data (waterfall), and the framework awaits hook completion before continuing. Event emitters decouple producers from consumers: the emitting code does not know who is listening or how many listeners exist, and emittery's async emit awaits all listeners without the emitter having any visibility into what those listeners do. For a build tool where plugins must run in a defined sequence and each plugin can modify intermediate state, hookable or tapable is the right model. For a component that needs to notify multiple independent subsystems — logging, analytics, cache invalidation — without coupling to them, emittery's event model keeps the architecture cleaner. Mixing the two patterns is also valid: use hookable for the plugin lifecycle that frameworks expose, and use emittery internally for cross-component communication within the framework itself.

## Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on hookable v5.x, tapable v2.x, and emittery v1.x.

*[Compare event systems and developer tooling on PkgPulse →](https://www.pkgpulse.com)*

*See also: [AVA vs Jest](/compare/ava-vs-jest) and [ohash vs object-hash vs hash-wasm](/guides/ohash-vs-object-hash-vs-hash-wasm-object-hashing-2026), [acorn vs @babel/parser vs espree](/guides/acorn-vs-babel-parser-vs-espree-javascript-ast-parsers-2026).*
