Code splitting is how we stop users from downloading your entire JavaScript bundle upfront, even if they only need a tiny fraction of it initially.

Imagine a massive application like an e-commerce site. You’ve got code for the product listing page, the checkout flow, the user profile, and maybe even a hidden admin panel. Traditionally, all of that JavaScript gets bundled and sent to the browser the moment a user lands on the homepage. This is like asking someone to carry a library into their house just to find one book. Code splitting breaks that monolithic bundle into smaller, manageable chunks.

Here’s what that looks like in practice. Let’s say you’re using Webpack. You’d typically have an entry point like this:

// webpack.config.js
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};

This tells Webpack to start at src/index.js and bundle everything it can reach into a single bundle.js. Now, let’s introduce code splitting. We can tell Webpack to dynamically import modules. This is often done using import() syntax, which is a promise-based way to load modules.

Consider a route-based code splitting strategy. When a user navigates to /products, we only want to load the JavaScript for the product page.

// src/index.js
import('./src/routes/products').then(({ default: ProductsPage }) => {
  ProductsPage.render();
});

// src/routes/products.js
export default class ProductsPage {
  static render() {
    console.log('Rendering products page!');
    // ... actual page rendering logic
  }
}

When Webpack sees the dynamic import('./src/routes/products'), it knows this is a separate chunk of code that shouldn’t be in the initial bundle. It will create a new file, typically named something like 123.js (where 123 is a hash based on the content), and place it in your dist folder. This chunk will only be downloaded by the browser when the import() statement is executed.

The magic behind this is Webpack’s ability to analyze your code and identify these dynamic imports. It creates a dependency graph, and any module imported via import() becomes a separate entry in that graph, leading to a separate output file. This is often referred to as "dynamic import" or "lazy loading."

The actual mechanism involves the browser requesting these additional JavaScript files on demand. When the import() promise resolves, the code within that chunk is executed. This significantly reduces the initial download size, leading to faster initial page loads and a better user experience, especially on slower networks or less powerful devices.

You can also configure Webpack to split code based on other factors, like vendor libraries. If you have a lot of third-party dependencies (React, Lodash, etc.), you can tell Webpack to put them in a separate bundle.

// webpack.config.js
module.exports = {
  entry: {
    app: './src/index.js',
    vendor: ['react', 'react-dom'], // Example vendor chunk
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'all',
        },
      },
    },
  },
};

In this setup, Webpack creates app.bundle.js and vendor.bundle.js. The vendor.bundle.js file contains all the code from your node_modules directory that’s imported by app.bundle.js. This is beneficial because vendor libraries tend to change less frequently than your application code. By separating them, the app.bundle.js will be smaller and change more often, leading to better browser caching. The browser can cache the vendor.bundle.js for a long time, and only re-download app.bundle.js when your application code updates.

The most surprising thing about code splitting is that it doesn’t require a fundamentally different programming model; it’s largely an enhancement to how your existing module system works, and frameworks abstract much of the complexity away. You write code as if it’s all available, and the bundler figures out how to parcel it out.

Here’s a simplified view of the network tab in a browser’s developer tools after a code-split application loads. The initial HTML file is downloaded, followed by the main JavaScript bundle (main.js in this example). Then, as the user interacts with the page and navigates, additional, smaller JavaScript files (like 123.js and 456.js) are requested and downloaded only when their code is actually needed.

Network Tab Example:
- index.html (20 KB)
- main.js (150 KB) - Initial application code
- 123.js (30 KB) - Loaded when user visits /products
- 456.js (50 KB) - Loaded when user initiates checkout

You control how granular these chunks become through your dynamic import statements and Webpack’s optimization.splitChunks configuration. A common strategy is to split by route, but you can also split by feature, user role, or even by component if you have very large, isolated components. The goal is always to minimize the initial payload and deliver code just in time.

One subtle but powerful aspect of code splitting is how it interacts with tree shaking. When you use dynamic imports and your bundler performs tree shaking, it can be even more efficient. If a module is dynamically imported but never actually called in any executed code path, tree shaking can eliminate it entirely, meaning it won’t even be included in the output chunk. This ensures that you’re not shipping any dead code, even in your lazily loaded modules.

The next challenge after mastering code splitting is often managing the loading states and potential errors associated with these dynamically loaded chunks.

Want structured learning?

Take the full Performance course →