hookable vs tapable vs emittery: Plugin Hook Systems in JavaScript (2026)
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
| 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
onAnycatch-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.