DNAðŸ“Ķ Webpack & Build ToolsLoaders, Plugins & Module Resolution
ðŸĶ–DinosaurWebpackLoadersPlugins

Loaders, Plugins & Module Resolution

Loaders transform files one at a time; plugins reshape the entire compilation. Understanding their internals — pitch phase, Tapable hooks, enhanced-resolve — separates webpack users from webpack engineers.

Loaders, Plugins & Module Resolution

If webpack core concepts are the skeleton, loaders and plugins are the muscles. Loaders teach webpack how to read files it doesn't natively understand. Plugins hook into the build lifecycle to transform, optimize, and emit assets. Module resolution is the nervous system that finds the right file when you write import.

Loader Architecture

A loader is a function that receives source content and returns transformed content. That's it. The simplicity is the power.

// A minimal synchronous loader
module.exports = function (source) {
  return source.replace(/console\.log\(.*?\);?/g, '');
};

Normal Phase vs. Pitch Phase

Loaders have two execution phases:

                     Pitch Phase (left → right)
                ─────────────────────────────────â–ķ
Loader A                Loader B                Loader C
                ◀─────────────────────────────────
                     Normal Phase (right → left)

Normal phase: Loaders execute right to left. Each loader receives the previous loader's output.

Pitch phase: Before any normal execution, webpack calls each loader's pitch method left to right. If a pitch returns a value, the chain reverses immediately — remaining pitches and all normal executions to the right are skipped.

// style-loader uses pitch to intercept the chain
module.exports.pitch = function (remainingRequest) {
  // Instead of waiting for css-loader to process the CSS,
  // style-loader.pitch generates runtime code that will
  // require the CSS through the remaining loaders at runtime
  return `
    var content = require(${JSON.stringify('-!' + remainingRequest)});
    module.exports = content;
  `;
};

This is why style-loader must be first (leftmost) in the use array — it needs its pitch to fire before css-loader.

Writing Custom Loaders

Synchronous Loader

module.exports = function (source) {
  const options = this.getOptions(); // access loader options
  this.cacheable(true); // mark as cacheable (default)
 
  if (options.strip === 'debug') {
    source = source.replace(/\/\/ DEBUG:.*$/gm, '');
  }
 
  return source;
};

Asynchronous Loader

module.exports = function (source) {
  const callback = this.async(); // signals async operation
 
  fetchTypeDefinitions(source)
    .then(types => {
      const result = injectTypes(source, types);
      callback(null, result); // (error, content, sourceMap, meta)
    })
    .catch(err => callback(err));
};

Raw Loader (Binary)

module.exports = function (buffer) {
  const optimized = optimizeImage(buffer);
  return optimized;
};
module.exports.raw = true; // receive Buffer instead of string

Common Loaders Deep Dive

JavaScript/TypeScript Transpilation

LoaderSpeedType CheckingConfig
babel-loaderSlowNo (use fork-ts-checker).babelrc / babel.config.js
ts-loaderSlowYes (optional transpileOnly)tsconfig.json
esbuild-loaderVery fastNoInline options
swc-loaderVery fastNo.swcrc
// esbuild-loader: 10-50x faster than babel-loader
{
  test: /\.[jt]sx?$/,
  use: {
    loader: 'esbuild-loader',
    options: {
      target: 'es2020',
      jsx: 'automatic',
    },
  },
}

Interview insight: ts-loader with transpileOnly: true skips type checking and becomes much faster — pair it with fork-ts-checker-webpack-plugin to type-check in a separate process. Or switch to esbuild-loader / swc-loader for order-of-magnitude speed gains at the cost of some Babel plugin compatibility.

CSS Processing

// Development: inject styles into DOM
{
  test: /\.css$/,
  use: ['style-loader', 'css-loader', 'postcss-loader'],
}
 
// Production: extract to files
{
  test: /\.css$/,
  use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
}

style-loader injects CSS via <style> tags at runtime (great for HMR). MiniCssExtractPlugin.loader extracts CSS into separate .css files (required for production — parallel loading, caching).

Asset Modules (Webpack 5)

Webpack 5 replaced file-loader, url-loader, and raw-loader with built-in asset module types:

Old LoaderAsset Module TypeBehavior
file-loaderasset/resourceEmit file, return URL
url-loaderasset/inlineInline as data URI
url-loader + limitassetAuto-choose based on size
raw-loaderasset/sourceImport as string
{
  test: /\.(png|jpg|gif|webp)$/,
  type: 'asset',
  parser: {
    dataUrlCondition: {
      maxSize: 8 * 1024, // 8KB — inline below, emit above
    },
  },
  generator: {
    filename: 'images/[name].[contenthash:8][ext]',
  },
}

Plugin Architecture

Compiler vs. Compilation

The two core objects you must understand:

Compiler: The top-level object. Created once per webpack run. Holds configuration, file system access, and the full set of lifecycle hooks. Think of it as the "build orchestrator."

Compilation: Created for each build (including rebuilds in watch mode). Contains the dependency graph, modules, chunks, and assets. Think of it as a "snapshot of this specific build."

Compiler (singleton)
├── hooks.beforeRun
├── hooks.run
├── hooks.compile
│   └── Compilation (per build)
│       ├── hooks.buildModule
│       ├── hooks.succeedModule
│       ├── hooks.seal
│       ├── hooks.optimize
│       ├── hooks.optimizeChunks
│       └── hooks.processAssets
├── hooks.emit
├── hooks.afterEmit
└── hooks.done

Tapable Hook Types

Webpack uses Tapable for its hook system. Understanding hook types matters when writing plugins:

Hook TypeBehavior
SyncHookRuns handlers synchronously, ignores return values
SyncBailHookStops if any handler returns non-undefined
SyncWaterfallHookPasses return value of each handler to the next
AsyncSeriesHookRuns async handlers in series
AsyncParallelHookRuns async handlers in parallel
AsyncSeriesBailHookAsync series, stops on first non-undefined result

Writing a Custom Plugin

class BuildManifestPlugin {
  constructor(options = {}) {
    this.filename = options.filename || 'build-manifest.json';
  }
 
  apply(compiler) {
    compiler.hooks.thisCompilation.tap('BuildManifestPlugin', (compilation) => {
      compilation.hooks.processAssets.tapPromise(
        {
          name: 'BuildManifestPlugin',
          stage: compilation.constructor.PROCESS_ASSETS_STAGE_SUMMARIZE,
        },
        async (assets) => {
          const manifest = {};
 
          for (const chunk of compilation.chunks) {
            for (const file of chunk.files) {
              const entryName = chunk.name || chunk.id;
              manifest[entryName] = manifest[entryName] || [];
              manifest[entryName].push(file);
            }
          }
 
          const json = JSON.stringify(manifest, null, 2);
          compilation.emitAsset(
            this.filename,
            new compiler.webpack.sources.RawSource(json)
          );
        }
      );
    });
  }
}

Key details:

  • apply(compiler) is the entry point — webpack calls this with the compiler instance
  • processAssets is the right hook for generating/modifying output files
  • Asset stages control ordering: ADDITIONAL → PRE_PROCESS → ... → SUMMARIZE → REPORT
  • compilation.emitAsset() adds a file to the output

Plugin Lifecycle Cheat Sheet

compiler.hooks.environment          ← configure compiler
compiler.hooks.afterEnvironment
compiler.hooks.entryOption          ← process entry config
compiler.hooks.beforeRun
compiler.hooks.run                  ← start compilation
compiler.hooks.compile
  compilation.hooks.buildModule     ← per module
  compilation.hooks.succeedModule
  compilation.hooks.seal            ← optimization begins
  compilation.hooks.optimizeModules
  compilation.hooks.optimizeChunks  ← SplitChunksPlugin runs here
  compilation.hooks.processAssets   ← final asset processing
compiler.hooks.emit                 ← write to disk
compiler.hooks.done                 ← build complete

Module Resolution Deep Dive

Webpack uses enhanced-resolve, a more capable version of Node's resolution algorithm.

Configuration Reference

module.exports = {
  resolve: {
    extensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
      'lodash-es': 'lodash', // redirect to a different package
    },
    fallback: {
      crypto: require.resolve('crypto-browserify'),
      stream: require.resolve('stream-browserify'),
      path: false, // explicitly disable polyfill
    },
    modules: ['src', 'node_modules'],
    mainFields: ['browser', 'module', 'main'],
    conditionNames: ['import', 'require', 'browser', 'default'],
    symlinks: false, // performance boost if no symlinks
  },
};

resolve.fallback (Webpack 5)

Webpack 5 removed automatic Node.js polyfills. If your code (or a dependency) uses Node built-ins, you must explicitly configure fallbacks:

resolve: {
  fallback: {
    buffer: require.resolve('buffer/'),
    process: require.resolve('process/browser'),
    fs: false,      // module not needed in browser
    path: false,
    crypto: false,
  },
}

resolve.conditionNames and Package Exports

Modern packages use the "exports" field in package.json:

{
  "name": "my-lib",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "browser": "./dist/browser/index.js",
      "default": "./dist/esm/index.js"
    },
    "./utils": {
      "import": "./dist/esm/utils.js"
    }
  }
}

conditionNames tells webpack which conditions to match:

resolve: {
  conditionNames: ['import', 'browser', 'default'],
}

Externals

Externals tell webpack to exclude certain imports from the bundle — they'll be available at runtime (CDN, server-side, etc.):

module.exports = {
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
  // With import() → `import react from 'react'` becomes `const React = window.React`
};
 
// Function form for more control
module.exports = {
  externals: ({ request }, callback) => {
    if (/^@company\/shared-/.test(request)) {
      return callback(null, `commonjs ${request}`);
    }
    callback();
  },
};

Interview insight: Externals are critical for micro-frontend architectures — shared dependencies like React are loaded once as externals, while each micro-frontend bundles only its own code. This is also how Module Federation's shared config works under the hood.

Interview Power Moves

  • "Loaders have two phases — pitch runs left-to-right before normal right-to-left execution. That's how style-loader works." Most interviewers haven't heard of pitch phase.
  • "Compiler is the build orchestrator; Compilation is a single build snapshot. Plugins tap into hooks on both." Shows you understand the object model.
  • "Webpack 5 replaced file-loader and url-loader with built-in asset modules." Signals that you're current with webpack 5.
  • "enhanced-resolve supports package.json exports via conditionNames." Shows you understand modern resolution beyond just main and module fields.
  • "I'd use esbuild-loader for transpilation speed and fork-ts-checker for parallel type checking." A practical architecture decision that demonstrates real-world experience.