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 stringCommon Loaders Deep Dive
JavaScript/TypeScript Transpilation
| Loader | Speed | Type Checking | Config |
|---|---|---|---|
babel-loader | Slow | No (use fork-ts-checker) | .babelrc / babel.config.js |
ts-loader | Slow | Yes (optional transpileOnly) | tsconfig.json |
esbuild-loader | Very fast | No | Inline options |
swc-loader | Very fast | No | .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 Loader | Asset Module Type | Behavior |
|---|---|---|
file-loader | asset/resource | Emit file, return URL |
url-loader | asset/inline | Inline as data URI |
url-loader + limit | asset | Auto-choose based on size |
raw-loader | asset/source | Import 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.doneTapable Hook Types
Webpack uses Tapable for its hook system. Understanding hook types matters when writing plugins:
| Hook Type | Behavior |
|---|---|
SyncHook | Runs handlers synchronously, ignores return values |
SyncBailHook | Stops if any handler returns non-undefined |
SyncWaterfallHook | Passes return value of each handler to the next |
AsyncSeriesHook | Runs async handlers in series |
AsyncParallelHook | Runs async handlers in parallel |
AsyncSeriesBailHook | Async 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 instanceprocessAssetsis 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 completeModule 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
mainandmodulefields. - "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.