Skip to main content

Guide

hookable vs tapable vs emittery 2026

Compare hookable, tapable, and emittery for building plugin systems in JavaScript. Hook-based architecture, async hooks, waterfall patterns, and which hook.

·PkgPulse Team·
0

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 — lightweight hook system:

Basic usage

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

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

// 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 — webpack's hook engine:

Hook types

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

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

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 — modern async event emitter:

Basic usage

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

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

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

Featurehookabletapableemittery
Hook/event typesNamed hooks7+ hook typesNamed 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)
Dependencies000
Used byNuxt, Nitrowebpack, rspackSindre 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 →

See also: AVA vs Jest and ohash vs object-hash vs hash-wasm, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.