The most surprising thing about JavaScript module systems is that they’re not really about JavaScript at all, but about how the JavaScript runtime environment (the browser or Node.js) can efficiently load and manage code.

Let’s see how this plays out with a simple example. Imagine you have two files: math.js and app.js.

math.js:

// This is a CommonJS module
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

module.exports = {
  add,
  subtract,
};

app.js:

// This is an ESM module
import { add } from './math.js';

console.log(`5 + 3 = ${add(5, 3)}`);

If you tried to run this directly in a modern browser without any build tools, you’d likely get an error because the browser doesn’t natively understand module.exports or import. This is where Webpack, a module bundler, steps in. Webpack’s job is to take your code, understand these different module formats, and transform them into a format that the browser can understand, often a single, optimized JavaScript file.

Webpack doesn’t just bundle; it builds a mental model of your entire application’s dependency graph. It starts from your entry point (e.g., app.js) and recursively follows every require() (CommonJS) or import (ESM) statement to map out how all your code pieces connect.

Here’s a breakdown of the common module types Webpack handles:

CommonJS (CJS)

This is the module system historically used by Node.js. It’s synchronous, meaning when you require() a module, the execution of your current script pauses until that module is loaded and evaluated.

How it works:

  • require('module-name'): Imports a module.
  • module.exports = { ... } or exports.name = ...: Exports values from a module.

Example in Webpack: If Webpack encounters a require() statement, it knows to look for a module that exports something using module.exports or exports. It resolves these dependencies at runtime (in Node.js) or bundles them together for browser execution.

ECMAScript Modules (ESM)

This is the official standard module system for JavaScript, introduced in ES6 (ECMAScript 2015). ESM is asynchronous and statically analyzable, meaning the dependencies can be determined before the code even runs. This allows for optimizations like tree-shaking (removing unused code).

How it works:

  • import { name } from './module.js': Imports specific exports.
  • import defaultExport from './module.js': Imports a default export.
  • export const name = ... or export default ...: Exports values from a module.

Example in Webpack: Webpack parses import and export statements to build its dependency graph. It’s smart enough to handle both static imports (like import { foo } from './bar') and dynamic imports (like import('./baz')), which can be used for code-splitting.

Asynchronous Module Definition (AMD)

AMD was an earlier attempt to standardize asynchronous module loading, primarily for browsers, before ESM became widespread. It’s less common in modern Webpack projects but Webpack still supports it.

How it works:

  • define(['dependency1', 'dependency2'], function(dep1, dep2) { ... }): Defines a module with its dependencies.
  • Dependencies are loaded asynchronously.

Example in Webpack: Webpack can process define() calls, treating them as module definitions and resolving their dependencies. It ensures that even if you have a mix of AMD and other module types, they are all correctly bundled.

Webpack’s power comes from its ability to understand all these different syntaxes and contexts. It transforms them into a unified output that can be executed efficiently in the target environment. It can even convert between these formats during the bundling process. For instance, if you import a CommonJS module in your ESM code, Webpack will handle the necessary transformations.

The core concept Webpack revolves around is the dependency graph. It’s not just about linking files; it’s about understanding the relationship between every piece of code. This graph is what allows Webpack to perform advanced optimizations like code splitting (breaking your bundle into smaller chunks that can be loaded on demand) and tree shaking (eliminating dead code that’s never imported or used). The graph is built by traversing all require and import statements starting from your entry points.

A subtle but crucial aspect of Webpack’s ESM handling is its interpretation of import() as a way to trigger code splitting. When Webpack sees import('./lazy-module'), it doesn’t just include lazy-module.js in the main bundle. Instead, it creates a separate JavaScript file for lazy-module.js and ensures it’s only fetched and executed when that import() call is actually reached in the browser. This is a dynamic, asynchronous loading strategy that significantly improves initial page load times for large applications.

The next concept you’ll likely encounter is how Webpack handles different asset types beyond JavaScript, such as CSS, images, and fonts.

Want structured learning?

Take the full Webpack course →