Webpack code splitting, specifically with dynamic imports and chunks, is a way to lazily load parts of your JavaScript bundle only when they’re needed, drastically improving initial page load times.

Let’s see it in action. Imagine a simple React app with two components: App and HeavyComponent. HeavyComponent is only rendered when a button is clicked.

// App.js
import React, { useState } from 'react';
import './App.css';

function App() {
  const [showHeavy, setShowHeavy] = useState(false);

  const handleClick = () => {
    setShowHeavy(true);
  };

  return (
    <div className="App">
      <h1>Webpack Code Splitting Demo</h1>
      <button onClick={handleClick}>Load Heavy Component</button>
      {showHeavy && (
        <HeavyComponent />
      )}
    </div>
  );
}

export default App;

The magic happens in how HeavyComponent is imported. Instead of a static import HeavyComponent from './HeavyComponent';, we use a dynamic import():

// App.js (modified)
import React, { useState, Suspense } from 'react';
import './App.css';

// Use React.lazy for dynamic imports with components
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  const [showHeavy, setShowHeavy] = useState(false);

  const handleClick = () => {
    setShowHeavy(true);
  };

  return (
    <div className="App">
      <h1>Webpack Code Splitting Demo</h1>
      <button onClick={handleClick}>Load Heavy Component</button>
      {/* Suspense is required to handle the loading state */}
      <Suspense fallback={<div>Loading...</div>}>
        {showHeavy && (
          <HeavyComponent />
        )}
      </Suspense>
    </div>
  );
}

export default App;

When Webpack encounters import('./HeavyComponent'), it doesn’t bundle HeavyComponent into the main app.js file. Instead, it creates a separate JavaScript file, often called a "chunk," for HeavyComponent and its dependencies. This chunk will be named something like 123.chunk.js (the number is a hash for caching).

When the handleClick function is called and showHeavy becomes true, React’s Suspense boundary notices that HeavyComponent is not yet loaded. It then triggers a request to fetch 123.chunk.js from the server. Until the chunk is downloaded and executed, the fallback UI (<div>Loading...</div>) is displayed. Once loaded, HeavyComponent is rendered.

The problem this solves is the "monolithic JavaScript bundle" anti-pattern. In traditional setups, all your application’s JavaScript code is bundled into one massive file. Even if a user only needs to interact with a small part of the application on their initial visit, they still have to download the entire bundle. This leads to slow initial load times, especially on slower networks or less powerful devices. Code splitting breaks down this monolith into smaller, manageable chunks that are loaded on demand.

Webpack’s configuration for code splitting is largely automatic with dynamic import(), but you can influence it. The optimization.splitChunks configuration in webpack.config.js is key.

// webpack.config.js
module.exports = {
  // ... other configurations
  optimization: {
    splitChunks: {
      chunks: 'all', // 'all' is common for splitting all types of chunks (initial, async, and vendor)
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/, // Regex to match modules in node_modules
          name: 'vendors', // Name of the chunk
          chunks: 'all', // Process all types of chunks
        },
      },
    },
  },
};

The chunks: 'all' setting tells Webpack to consider all types of chunks (initial and asynchronous) for splitting. The cacheGroups allow you to define custom rules. Here, we’re creating a vendor chunk that groups all modules from node_modules into a separate file named vendors.js. This is extremely useful because vendor dependencies (like React, Lodash, etc.) often change less frequently than your application code. By caching them separately, browsers can reuse the vendors.js file across multiple page loads or even different applications served from the same domain, significantly speeding up subsequent visits.

The name property in cacheGroups can be a string or a function. Using a function allows for more dynamic naming based on the modules included in the chunk. For instance, name: (module, chunks, cacheGroupKey) => \chunk-${cacheGroupKey}-${module.context.match(/\/$/)[1]}`would create names likechunk-vendors-react-dom`.

The most surprising thing is that import() is not a Webpack-specific syntax; it’s a standard JavaScript proposal that Webpack has adopted and uses as its primary mechanism for enabling code splitting. This means that as JavaScript evolves, your code splitting strategies can become more portable.

When you run Webpack, you’ll see output indicating the creation of multiple JavaScript files, including your main app.js and the dynamically loaded chunks (e.g., 123.chunk.js, vendors.js). The index.html file will be updated to include script tags for all these generated files.

The next concept you’ll likely encounter is managing the loading state and error handling for these dynamically imported chunks, especially when dealing with multiple dynamic imports or complex application logic.

Want structured learning?

Take the full Webpack course →