Skip to main content
Tapable is the backbone of webpack’s plugin system. It provides a variety of hooks that allow plugins to tap into different stages of the compilation process.

What is Tapable?

Tapable is a small library that webpack uses to create hooks. These hooks are extension points where plugins can register callbacks to execute at specific times.

Hook Types

Tapable provides several hook types, each with different execution patterns:

Sync Hooks

SyncHook

The simplest hook - calls all taps sequentially.
const { SyncHook } = require('tapable');

const hook = new SyncHook(['arg1', 'arg2']);

// Tap into the hook
hook.tap('MyPlugin', (arg1, arg2) => {
  console.log('Hook called with:', arg1, arg2);
});

// Call the hook
hook.call('value1', 'value2');
Used in webpack:
// lib/Compiler.js:166
initialize: new SyncHook([])

// lib/Compiler.js:188
thisCompilation: new SyncHook(['compilation', 'params'])

SyncBailHook

Like SyncHook, but stops executing if any tap returns a non-undefined value.
const { SyncBailHook } = require('tapable');

const hook = new SyncBailHook(['value']);

hook.tap('Plugin1', (value) => {
  console.log('Plugin 1');
  if (value === 'stop') return true; // Stop here
});

hook.tap('Plugin2', (value) => {
  console.log('Plugin 2'); // Won't run if Plugin1 returned
});

hook.call('stop'); // Only Plugin1 runs
Used in webpack:
// lib/Compiler.js:169
shouldEmit: new SyncBailHook(['compilation'])

// lib/Compiler.js:237
entryOption: new SyncBailHook(['context', 'entry'])

SyncWaterfallHook

Passes the return value of each tap to the next tap.
const { SyncWaterfallHook } = require('tapable');

const hook = new SyncWaterfallHook(['value']);

hook.tap('Plugin1', (value) => {
  return value + ' -> Plugin1';
});

hook.tap('Plugin2', (value) => {
  return value + ' -> Plugin2';
});

const result = hook.call('Start');
console.log(result); // 'Start -> Plugin1 -> Plugin2'
Used in webpack:
// lib/Compilation.js:1012
assetPath: new SyncWaterfallHook(['path', 'options', 'assetInfo'])

SyncLoopHook

Keeps looping through all taps while any tap returns a non-undefined value.
const { SyncLoopHook } = require('tapable');

let count = 0;
const hook = new SyncLoopHook([]);

hook.tap('Loop', () => {
  count++;
  if (count < 3) {
    console.log('Loop again');
    return true; // Loop
  }
  console.log('Done');
  // Return undefined to stop
});

hook.call(); // Runs 3 times

Async Hooks

AsyncSeriesHook

Executes async taps in series.
const { AsyncSeriesHook } = require('tapable');

const hook = new AsyncSeriesHook(['data']);

// Using tapAsync
hook.tapAsync('Plugin1', (data, callback) => {
  setTimeout(() => {
    console.log('Plugin 1 done');
    callback();
  }, 100);
});

// Using tapPromise
hook.tapPromise('Plugin2', async (data) => {
  await new Promise(resolve => setTimeout(resolve, 100));
  console.log('Plugin 2 done');
});

// Call with callback
hook.callAsync('data', (err) => {
  console.log('All done');
});

// Or call with promise
await hook.promise('data');
Used in webpack:
// lib/Compiler.js:177
beforeRun: new AsyncSeriesHook(['compiler'])

// lib/Compiler.js:181
emit: new AsyncSeriesHook(['compilation'])

// lib/Compiler.js:197
beforeCompile: new AsyncSeriesHook(['params'])

AsyncSeriesBailHook

Like AsyncSeriesHook, but stops if a tap returns a non-undefined value.
const { AsyncSeriesBailHook } = require('tapable');

const hook = new AsyncSeriesBailHook(['input']);

hook.tapAsync('Plugin1', (input, callback) => {
  if (input === 'stop') {
    callback(null, 'stopped'); // Bail with result
  } else {
    callback(); // Continue
  }
});

hook.tapAsync('Plugin2', (input, callback) => {
  console.log('Plugin 2'); // Won't run if Plugin1 bailed
  callback();
});

hook.callAsync('stop', (err, result) => {
  console.log(result); // 'stopped'
});

AsyncSeriesWaterfallHook

Like SyncWaterfallHook but async.
const { AsyncSeriesWaterfallHook } = require('tapable');

const hook = new AsyncSeriesWaterfallHook(['value']);

hook.tapAsync('Plugin1', (value, callback) => {
  callback(null, value + ' -> Plugin1');
});

hook.tapPromise('Plugin2', async (value) => {
  return value + ' -> Plugin2';
});

hook.callAsync('Start', (err, result) => {
  console.log(result); // 'Start -> Plugin1 -> Plugin2'
});

AsyncParallelHook

Executes async taps in parallel.
const { AsyncParallelHook } = require('tapable');

const hook = new AsyncParallelHook(['data']);

hook.tapAsync('Plugin1', (data, callback) => {
  setTimeout(() => {
    console.log('Plugin 1');
    callback();
  }, 100);
});

hook.tapAsync('Plugin2', (data, callback) => {
  setTimeout(() => {
    console.log('Plugin 2');
    callback();
  }, 50);
});

// Both run in parallel
hook.callAsync('data', () => {
  console.log('All done');
});
Used in webpack:
// lib/Compiler.js:201
make: new AsyncParallelHook(['compilation'])

AsyncParallelBailHook

Like AsyncParallelHook, but bails when any tap returns a result.
const { AsyncParallelBailHook } = require('tapable');

const hook = new AsyncParallelBailHook(['data']);

hook.tapAsync('Plugin1', (data, callback) => {
  setTimeout(() => {
    callback(null, 'result from Plugin1');
  }, 100);
});

hook.tapAsync('Plugin2', (data, callback) => {
  setTimeout(() => {
    callback(); // No result
  }, 50);
});

hook.callAsync('data', (err, result) => {
  console.log(result); // 'result from Plugin1'
});

Hook Maps

HookMap allows you to create multiple hooks with different keys.
const { HookMap, SyncHook } = require('tapable');

const hookMap = new HookMap(() => new SyncHook(['arg']));

// Access hook for specific key
hookMap.for('javascript/auto').tap('MyPlugin', (arg) => {
  console.log('JavaScript auto:', arg);
});

hookMap.for('css').tap('MyPlugin', (arg) => {
  console.log('CSS:', arg);
});

// Call specific hook
hookMap.for('javascript/auto').call('data');
Used in webpack:
// lib/NormalModuleFactory.js - parser hooks for different module types
parser: new HookMap(() => new SyncHook(['parser', 'parserOptions']))

Tap Methods

.tap() - Synchronous

hook.tap('MyPlugin', (arg) => {
  // Synchronous code
});

// With options
hook.tap({
  name: 'MyPlugin',
  stage: 100 // Control execution order
}, (arg) => {
  // Code
});

.tapAsync() - Callback-based

hook.tapAsync('MyPlugin', (arg, callback) => {
  setTimeout(() => {
    callback(); // Must call callback
  }, 100);
});

.tapPromise() - Promise-based

hook.tapPromise('MyPlugin', async (arg) => {
  await someAsyncOperation();
  return result; // Can return value
});

Tap Options

name

Required. Identifies your plugin.
hook.tap({ name: 'MyPlugin' }, () => {});

stage

Controls execution order. Lower numbers run first.
hook.tap({ name: 'Plugin1', stage: 100 }, () => {});
hook.tap({ name: 'Plugin2', stage: -100 }, () => {}); // Runs first
Example from webpack:
// lib/BannerPlugin.js:100
compilation.hooks.processAssets.tap(
  {
    name: 'BannerPlugin',
    stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS
  },
  () => {
    // Add banner to assets
  }
);

before

Run before specific plugins.
hook.tap({
  name: 'MyPlugin',
  before: ['OtherPlugin']
}, () => {});

after

Run after specific plugins.
hook.tap({
  name: 'MyPlugin',
  after: ['OtherPlugin']
}, () => {});

Interceptors

Interceptors allow you to monitor and control hook behavior.
hook.intercept({
  // Called when a new tap is registered
  register: (tap) => {
    console.log('New tap:', tap.name);
    return tap; // Can modify tap
  },
  
  // Called before the hook is called
  call: (...args) => {
    console.log('Hook called with:', args);
  },
  
  // Called before each tap
  tap: (tap) => {
    console.log('Running tap:', tap.name);
  },
  
  // Called when loop restarts (SyncLoopHook)
  loop: (...args) => {
    console.log('Loop restarted');
  }
});

Context

Share data between taps using context.
hook.intercept({
  context: true, // Enable context
  tap: (context, tap) => {
    // Access context
  }
});

hook.tap({
  name: 'Plugin1',
  context: true
}, (context, arg) => {
  context.shared = 'data';
});

hook.tap({
  name: 'Plugin2',
  context: true
}, (context, arg) => {
  console.log(context.shared); // 'data'
});

Complete Example: Custom Hook System

const { SyncHook, AsyncSeriesHook } = require('tapable');

class BuildSystem {
  constructor() {
    this.hooks = {
      beforeBuild: new AsyncSeriesHook(['options']),
      build: new SyncHook(['modules']),
      afterBuild: new AsyncSeriesHook(['result'])
    };
  }

  async run(options) {
    // Before build
    await this.hooks.beforeBuild.promise(options);
    
    // Build
    const modules = ['module1', 'module2'];
    this.hooks.build.call(modules);
    
    // After build
    await this.hooks.afterBuild.promise({ success: true });
  }
}

// Use the system
const builder = new BuildSystem();

// Register plugins
builder.hooks.beforeBuild.tapAsync('PreparePlugin', (options, callback) => {
  console.log('Preparing build');
  setTimeout(callback, 100);
});

builder.hooks.build.tap('BuildPlugin', (modules) => {
  console.log('Building modules:', modules);
});

builder.hooks.afterBuild.tapPromise('CleanupPlugin', async (result) => {
  console.log('Cleaning up');
});

// Run
builder.run({ mode: 'production' });

Best Practices

1. Always Name Your Taps

// Good
hook.tap('MyPlugin', () => {});

// Bad
hook.tap(() => {}); // Error: name is required

2. Use Appropriate Hook Type

// For I/O operations
hook.tapAsync('MyPlugin', (data, callback) => {
  fs.readFile(path, callback);
});

// For promises
hook.tapPromise('MyPlugin', async (data) => {
  return await fetch(url);
});

3. Handle Errors in Async Hooks

hook.tapAsync('MyPlugin', (data, callback) => {
  doSomething((err, result) => {
    if (err) return callback(err); // Pass error
    callback(); // Success
  });
});

4. Use Stage for Ordering

// Run first
hook.tap({ name: 'Setup', stage: -100 }, () => {});

// Run last  
hook.tap({ name: 'Cleanup', stage: 100 }, () => {});

5. Return Boolean from Bail Hooks

hook.tap('MyPlugin', (value) => {
  if (shouldStop(value)) {
    return true; // Bail
  }
  // Continue by returning undefined
});

See Also