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 Compiler and Compilation
  • 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.js leaked 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:

  1. Initialization: Read configuration, set up plugins and loaders
  2. Compilation: Build the dependency graph, process modules, generate assets
  3. 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:

  1. Read index.js
  2. See that it imports ./utils.js → add that to the graph
  3. See that it imports ./styles.css → add that to the graph
  4. Process utils.js → find its imports
  5. Process styles.css → run it through loaders
  6. 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:

  1. css-loader reads the CSS file and resolves @import and url() statements
  2. style-loader takes the processed CSS and injects it into the DOM via <style> tags

For TypeScript:

  1. ts-loader (or babel-loader with TypeScript preset) transpiles .ts to .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 import statements (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:

  1. Splits that module into a separate chunk
  2. Generates a small JSONP loader that fetches the chunk on demand
  3. 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-loader to parallelize loading
  • Use babel-loader cache
  • Exclude node_modules from unnecessary loaders
  • Use development mode 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:

  1. Start at the entry point and recursively discover all dependencies
  2. Process each module through loaders to convert it to JavaScript
  3. Parse to AST to find more dependencies
  4. Wrap every module in a function with a custom require
  5. Bundle everything into one or more files
  6. 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.