Skip to main content

hookable vs tapable vs emittery: Plugin Hook Systems in JavaScript (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

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