Webpack Module Bundling Explained: How It Works Under the Hood
If you've ever built a frontend project in the last decade, you've probably used Webpack. It's the tool that turns your import statements and separate files into a single (or multiple) optimized bundle that the browser can run.
But have you ever wondered what actually happens when you run webpack? How does it go from dozens of scattered .js files to one bundle.js? And why does it sometimes feel like magic?
In this article, we'll peel back the curtain and explore how Webpack works internally. By the end, you'll understand:
- What module bundling actually means
- How Webpack builds its dependency graph
- The difference between
CompilerandCompilation - How modules are wrapped and executed in the browser
- Why code splitting works the way it does
Let's dive in.
The Problem: Browser-Native Modules Are Not Enough
Before module bundlers like Webpack, we had a fundamental problem: browsers don't natively support import/export the way Node.js does (well, they do now with ES modules, but there are still caveats).
In the early days, we'd load multiple <script> tags:
<script src="utils.js"></script>
<script src="app.js"></script>
This worked, but it had serious issues:
-
Global scope pollution: Everything in
utils.jsleaked into the global namespace - Order matters: You had to load dependencies before dependents
- No dependency management: No automatic way to know what depends on what
Then came ES modules (import/export), which solved the syntax problem. But we still need bundling for other reasons:
- Tree shaking (removing unused code)
- Code splitting (loading only what's needed)
- Asset processing (TypeScript, SCSS, images)
- Performance (fewer requests, smaller files)
This is where Webpack comes in.
Core Concepts You Need to Know
Before we walk through the build process, let's define the key players:
Entry
The entry point is where Webpack starts building its dependency graph. It's the "root" module that everything else hangs off of.
// webpack.config.js
module.exports = {
entry: './src/index.js'
};
Webpack starts here and follows every import statement to discover all the files your app needs.
Output
The output tells Webpack where to put the bundled files and what to name them.
module.exports = {
output: {
path: '/dist',
filename: 'bundle.js'
}
};
Loaders
Loaders teach Webpack how to process non-JavaScript files. By default, Webpack only understands JavaScript. But what about CSS? TypeScript? Images?
That's what loaders are for. They transform files before they're added to the bundle:
module.exports = {
module: {
rules: [
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ test: /\.ts$/, use: 'ts-loader' }
]
}
};
Plugins
Plugins are more powerful than loaders. They can hook into the entire build lifecycle and do things like:
- Extract CSS into separate files
- Optimize assets
- Generate HTML files
- Define environment variables
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })]
};
The Build Process: Three Stages
Now that we know the core concepts, let's walk through what happens when you run webpack.
The build happens in three main stages:
- Initialization: Read configuration, set up plugins and loaders
- Compilation: Build the dependency graph, process modules, generate assets
- Emission: Write the final files to disk
Let's look at each stage in detail.
Stage 1: Initialization
When you run webpack, the first thing it does is create a Compiler instance.
The Compiler is the overall manager. It:
- Reads your configuration file
- Registers all plugins
- Sets up the file system
- Runs the initial build
const webpack = require('webpack');
const compiler = webpack(config);
// compiler.run() starts the build
The Compiler lives for the entire lifetime of your Webpack process. If you're using webpack-dev-server, the Compiler is created once and reused across rebuilds.
Stage 2: Compilation
This is where the real work happens. Each time Webpack builds (or rebuilds), it creates a new Compilation object.
The Compilation represents one single build. It tracks:
- All the modules being processed
- All the generated assets
- The dependency graph
Here's what happens during compilation:
Step 2.1: Build the Dependency Graph
Webpack starts at the entry point and recursively finds every imported module.
For example, if your entry file looks like this:
// index.js
import { greet } from './utils.js';
import './styles.css';
greet('World');
Webpack will:
- Read
index.js - See that it imports
./utils.js→ add that to the graph - See that it imports
./styles.css→ add that to the graph - Process
utils.js→ find its imports - Process
styles.css→ run it through loaders - Repeat until everything is discovered
This creates a dependency graph: a tree (or more accurately, a DAG) of all the modules your app needs.
Step 2.2: Resolve Modules
When Webpack sees import { greet } from './utils.js', it needs to figure out what file ./utils.js actually refers to.
This is called resolution. Webpack's resolver:
- Checks if it's a relative path (starts with
./or../) - Tries adding extensions (
.js,.jsx,.ts, etc.) - Checks if it's a directory (then looks for
index.js) - Resolves aliases from your config
Step 2.3: Process Modules with Loaders
Once Webpack has found a file, it runs it through any matching loaders.
For example, for a .css file:
-
css-loaderreads the CSS file and resolves@importandurl()statements -
style-loadertakes the processed CSS and injects it into the DOM via<style>tags
For TypeScript:
-
ts-loader(orbabel-loaderwith TypeScript preset) transpiles.tsto.js
The output of loader processing is always JavaScript. That's the contract: loaders take any file type, output JavaScript.
Step 2.4: Parse to AST
After loaders process a module, Webpack parses the resulting JavaScript into an Abstract Syntax Tree (AST).
The AST is a structured representation of your code. Webpack uses it to:
- Find all
importstatements (to discover more dependencies) - Apply transformations (like removing unused exports for tree shaking)
This is how Webpack knows what other modules a file depends on.
Stage 3: Emission
Once all modules are processed and the dependency graph is complete, Webpack generates the final bundle.
But wait — how does it turn a graph of modules into a single runnable file?
How Modules Actually Run in the Browser
This is the part most people don't think about. Let's look at what's actually inside a Webpack bundle.
Remember: browsers don't natively have a require function (not the old way, anyway). So Webpack has to simulate one.
The Module Wrapper
Every module in your bundle gets wrapped in a function like this:
(function(module, exports, require) {
// Your actual module code goes here
const greet = (name) => `Hello, ${name}!`;
module.exports = { greet };
});
Webpack stores all these wrapped modules in an object:
const modules = {
'./src/index.js': (function(module, exports, require) { /* ... */ }),
'./src/utils.js': (function(module, exports, require) { /* ... */ }),
'./src/styles.css': (function(module, exports, require) { /* ... */ })
};
The require Function
Then Webpack defines its own require function:
function require(moduleId) {
// Check if we've already loaded this module
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module object
const module = installedModules[moduleId] = {
exports: {},
loaded: false
};
// Run the module function
modules[moduleId].call(
module.exports,
module,
module.exports,
require
);
// Mark it as loaded
module.loaded = true;
return module.exports;
}
Putting It All Together
The bundle ends up looking like this:
(function(modules) {
// The require function we just defined
function require(moduleId) { /* ... */ }
// Start with the entry point
return require('./src/index.js');
})({
// All our modules
'./src/index.js': (function(module, exports, require) {
const { greet } = require('./src/utils.js');
greet('World');
}),
'./src/utils.js': (function(module, exports, require) {
const greet = (name) => `Hello, ${name}!`;
module.exports = { greet };
})
});
That's it! That's how Webpack bundles work. It's just a big IIFE (Immediately Invoked Function Expression) that defines a custom require and starts executing from your entry point.
Code Splitting: Why and How
One of Webpack's most powerful features is code splitting — splitting your code into multiple bundles that load on demand.
Why Code Split?
Imagine your app has 10 different pages. If you bundle everything into one file, users have to download all 10 pages' code just to see the homepage. That's wasteful.
Code splitting lets you:
- Load the homepage code first
- Load other pages only when the user navigates to them
How It Works: Dynamic Imports
Webpack supports dynamic imports using the import() syntax:
// Instead of:
import { aboutPage } from './about.js';
// Do:
button.addEventListener('click', () => {
import('./about.js').then(module => {
module.aboutPage();
});
});
When Webpack sees import(), it automatically:
- Splits that module into a separate chunk
- Generates a small JSONP loader that fetches the chunk on demand
- Returns a Promise that resolves when the chunk is loaded
The Runtime Chunk
Code splitting introduces another concept: the runtime chunk.
The runtime is a small piece of Webpack boilerplate that:
- Manages module loading
- Tracks which chunks have been loaded
- Resolves dependencies between chunks
It's usually just a few KB, but it's essential for code splitting to work.
Production Optimization
In production mode, Webpack applies a bunch of optimizations automatically:
Tree Shaking
Tree shaking removes unused exports from your bundle.
If you have a file like this:
// utils.js
export const usedFunction = () => 'I am used';
export const unusedFunction = () => 'I am not used';
And you only import usedFunction, Webpack will completely remove unusedFunction from the final bundle.
This works because ES modules have a static structure — you can't dynamically add exports at runtime. CommonJS (require()) doesn't support tree shaking as well.
Minification
Webpack uses Terser (or another minifier) to:
- Remove whitespace and comments
- Shorten variable names
- Remove dead code
This can reduce bundle size by 50% or more.
Module Concatenation
By default, each module is wrapped in its own function scope. Module concatenation (also called "scope hoisting") merges all modules into a single scope, which:
- Reduces function wrapper overhead
- Makes the bundle smaller and faster
Common Pitfalls and How to Avoid Them
1. Oversized Bundles
If your bundle is hundreds of KB, you're probably not code splitting enough.
Solution: Use route-based code splitting — split by page/route.
2. Slow Build Times
As your project grows, Webpack builds can get slow.
Solutions:
- Use
thread-loaderto parallelize loading - Use
babel-loadercache - Exclude
node_modulesfrom unnecessary loaders - Use
developmentmode for local development (faster builds)
3. Duplicated Dependencies
If you have multiple chunks that all import Lodash, Lodash gets bundled into each chunk.
Solution: Use the SplitChunksPlugin to extract shared dependencies into a common chunk.
module.exports = {
optimization: {
splitChunks: {
chunks: 'all'
}
}
};
Conclusion
Webpack can feel like magic, but it's actually a pretty straightforward pipeline:
- Start at the entry point and recursively discover all dependencies
- Process each module through loaders to convert it to JavaScript
- Parse to AST to find more dependencies
-
Wrap every module in a function with a custom
require - Bundle everything into one or more files
- Optimize for production with tree shaking and minification
Once you understand this pipeline, debugging Webpack issues becomes much easier. You'll know exactly where to look when something goes wrong.
The frontend ecosystem has been moving away from Webpack toward faster tools like Vite and esbuild. But understanding how Webpack works still gives you a solid foundation for understanding all module bundlers — the core concepts are the same.
This article was originally published on Wu Huajin's blog, where I write about frontend engineering, performance optimization, and developer tools.
Originally published on wuhuajin.com.
Top comments (0)