Serving cached assets without serving stale assets

The fastest request is the one the browser never makes. After you've trimmed your bundle size, long-term caching is the next biggest lever for load time: tell the browser to keep files around for a long time, then make sure it fetches a fresh copy exactly when those files change.

Set a far-future Cache-Control header and change the filename whenever the content changes:

# Server header
Cache-Control: max-age=31536000

With webpack, skip manual version numbers and let the build stamp each file with its content hash. Use [chunkhash] in the output filename:

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
  }
};

To tell the client which hashed file to request, you have two options. The HtmlWebpackPlugin is the simpler path: it generates an HTML file during compilation that references all built resources. The WebpackManifestPlugin is more flexible for complex server setups — it emits a JSON mapping between un-hashed and hashed filenames that your server can consult at request time.

Stop re-downloading dependencies on every deploy

App code changes frequently; dependencies change rarely. If both live in one bundle, any edit to your source forces the browser to re-download the entire file — including all the vendor code that didn't change. Splitting dependencies into their own chunk lets the browser cache them across deploys.

Three steps get you there:

  1. Add [name] to the output filename so each chunk is identifiable by something other than its hash:
// webpack.config.js
module.exports = {
  output: {
    // Before
    filename: 'bundle.[chunkhash].js',
    // After
    filename: '[name].[chunkhash].js'
  }
};
  1. Turn the entry field into an object, where the key is the chunk name referenced by [name]:
// webpack.config.js
module.exports = {
  // Before
  entry: './index.js',
  // After
  entry: {
    main: './index.js'
  }
};
  1. Let webpack extract vendor code. In webpack 4, enable optimization.splitChunks.chunks: 'all':
// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

This option triggers smart code splitting: webpack pulls vendor code into a separate chunk when it exceeds 30 kB before minification and gzip, and it also extracts code shared across multiple bundles. In webpack 3, use the CommonsChunkPlugin to move all modules from node_modules into their own file:

// webpack.config.js (for webpack 3)
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
    // A name of the chunk that will include the dependencies.
    // This name is substituted in place of [name] from step 1
    name: 'vendor',

    // A function that determines which modules to include into this chunk
    minChunks: module => module.context && module.context.includes('node_modules'),
    })
  ]
};

Splitting the runtime into its own file is just as important. The webpack runtime — the small piece of code managing module execution — contains a mapping between chunk IDs and filenames. That mapping changes whenever any chunk changes, and webpack hides the runtime in whatever chunk it generates last, which is usually the vendor chunk. Result: a one-line change to your app busts the vendor cache.

In webpack 4, enable optimization.runtimeChunk. In webpack 3, create an empty chunk with the CommonsChunkPlugin. Afterward, each build produces three files:

$ webpack
Hash: ac01483e8fec1fa70676
Version: webpack 3.8.1
Time: 3816ms
                            Asset     Size  Chunks             Chunk Names
   ./main.00bab6fd3100008a42b0.js    82 kB       0  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       1  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

Include them in index.html in reverse order:

<!-- index.html -->
<script src="./runtime.79f17c27b335abc7aaf4.js"></script>
<script src="./vendor.26886caf15818fa82dfa.js"></script>
<script src="./main.00bab6fd3100008a42b0.js"></script>

Inline the runtime to skip one request

The runtime is small, but it's still a request. Inlining it into the HTML response saves that round trip — useful under HTTP/1, less impactful but still nonzero under HTTP/2.

If you generate HTML with HtmlWebpackPlugin, the InlineSourcePlugin handles it directly:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      inlineSource: 'runtime~.+\\.js',
    }),
    new InlineSourcePlugin()
  ]
};

With custom server logic in webpack 4, first use the WebpackManifestPlugin to learn the generated runtime filename, then read that file and inject its contents into the HTML response:

// manifest.json
{
  "runtime~main.js": "runtime~main.8e0d62a03.js"
}

In webpack 3, give the runtime a static name so you can reference it directly:

module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
      minChunks: Infinity,
      filename: 'runtime.js'
    })
  ]
};

Lazy-load what the user doesn't need first

Not all code on a page matters equally at initial load. On a video page, the player matters more than the comments; on a news article, the text matters more than ads. Download the critical parts first and defer the rest with dynamic import():

// videoPlayer.js
export function renderVideoPlayer() { … }

// comments.js
export function renderComments() { … }

// index.js
import {renderVideoPlayer} from './videoPlayer';
renderVideoPlayer();

// …Custom event listener
onShowCommentsClick(() => {
  import('./comments').then((comments) => {
    comments.renderComments();
  });
});

Webpack responds to import() by moving the requested module into its own chunk, fetched only when execution reaches that call. This shrinks the main bundle and improves caching — edits to the main chunk leave the deferred chunk untouched.

Route-based splitting for faster loads

When an app ships a single JS bundle for all of its routes or pages, every visitor pays for code they may never execute. A home page visitor, for instance, still downloads the JavaScript that renders an article page they haven't opened. And if you later edit that article code, webpack invalidates the entire bundle, forcing the home page visitor to re-download the whole application even though the home page itself didn't change.

Splitting by route or page solves both problems. Users download only what they need, and a change to one page invalidates only that page's chunk.

Single-page apps

Use dynamic import() to load route components on demand. Most frontend frameworks provide a built-in mechanism:

Multi-page apps

For traditional multi-page setups, define a separate webpack entry point per page:

// webpack.config.js
module.exports = {
  entry: {
    home: './src/Home/index.js',
    article: './src/Article/index.js',
    profile: './src/Profile/index.js'
  }
};

Webpack then builds an independent dependency tree for each entry:

$ webpack
Hash: 318d7b8490a7382bf23b
Version: webpack 3.8.1
Time: 4273ms
                            Asset     Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./home.91b9ed27366fe7e33d6a.js    18 kB       1  [emitted]  home
./article.87a128755b16ac3294fd.js    32 kB       2  [emitted]  article
./profile.de945dc02685f6166781.js    24 kB       3  [emitted]  profile
 ./vendor.4f14b6326a80f4752a98.js    46 kB       4  [emitted]  vendor
./runtime.318d7b8490a7382bf23b.js  1.45 kB       5  [emitted]  runtime

If only the article page imports Lodash, the home and profile bundles will not contain it.

Separate entry trees have a catch: shared dependencies get duplicated into every bundle that uses them. To extract that common code once:

  • In webpack 4, set optimization.splitChunks.chunks: 'all' in the config:
// webpack.config.js (for webpack 4)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

This enables automatic code splitting, moving shared modules into their own files.

  • In webpack 3, use the CommonsChunkPlugin to move common dependencies into a single specified file:
module.exports = {
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: 2    // 2 is the default value
    })
  ]
};

Adjust minChunks to fit your project. A low value works for a few chunks, but as the number of chunks grows, raise it to avoid inflating the shared file. With 3 chunks, minChunks: 2 may be right; with 30, a value like 8 keeps the common bundle from ballooning.

Stabilizing module IDs

During builds, webpack assigns each module a numeric ID, used internally by require() calls. Output often shows these IDs before module paths:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.8ecaf182f5c85b7a8199.js  22.5 kB       0  [emitted]
   ./main.4e50a16675574df6a9e9.js    60 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime

↓ Here:

[0] ./index.js 29 kB {1} [built]
[2] (webpack)/buildin/global.js 488 bytes {2} [built]
[3] (webpack)/buildin/module.js 495 bytes {2} [built]
[4] ./comments.js 58 kB {0} [built]
[5] ./ads.js 74 kB {1} [built]
+ 1 hidden module

By default, webpack uses an incrementing counter. That becomes a problem the moment you add a module that lands in the middle of the list—every subsequent module shifts to a new ID:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.5c82c0f337fcb22672b5.js    22 kB       0  [emitted]
   ./main.0c8b617dfc40c2827ae3.js    82 kB       1  [emitted]  main
 ./vendor.26886caf15818fa82dfa.js    46 kB       2  [emitted]  vendor
./runtime.79f17c27b335abc7aaf4.js  1.45 kB       3  [emitted]  runtime
   [0] ./index.js 29 kB {1} [built]
   [2] (webpack)/buildin/global.js 488 bytes {2} [built]
   [3] (webpack)/buildin/module.js 495 bytes {2} [built]

↓ A new module was added…

[4] ./webPlayer.js 24 kB {1} [built]

↓ And now comments.js has ID 5 instead of 4:

[5] ./comments.js 58 kB {0} [built]

↓ And ads.js has ID 6 instead of 5:

[6] ./ads.js 74 kB {1} [built]
       + 1 hidden module

The result: all chunks referencing those modules are invalidated, even when their code is unchanged. In this example, the chunk with comments.js and the main app chunk get invalidated, when only the main chunk should have been.

The HashedModuleIdsPlugin replaces counter-based IDs with hashes of the module's path:

$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
                           Asset      Size  Chunks             Chunk Names
      ./0.6168aaac8461862eab7a.js  22.5 kB       0  [emitted]
   ./main.a2e49a279552980e3b91.js    60 kB       1  [emitted]  main
 ./vendor.ff9f7ea865884e6a84c8.js    46 kB       2  [emitted]  vendor
./runtime.25f5d0204e4f77fa57a1.js  1.45 kB       3  [emitted]  runtime

↓ Here:

[3IRH] ./index.js 29 kB {1} [built]
[DuR2] (webpack)/buildin/global.js 488 bytes {2} [built]
[JkW7] (webpack)/buildin/module.js 495 bytes {2} [built]
[LbCc] ./webPlayer.js 24 kB {1} [built]
[lebJ] ./comments.js 58 kB {0} [built]
[02Tr] ./ads.js 74 kB {1} [built]
    + 1 hidden module

With hashed IDs, a module keeps its ID until you rename or move it. Adding new modules no longer shifts existing ones. Enable the plugin in the plugins section of the webpack config:

// webpack.config.js
module.exports = {
  plugins: [
    new webpack.HashedModuleIdsPlugin()
  ]
};

Key takeaways

  • Name bundles with content hashes so browsers can cache versions independently
  • Separate app code, vendor libraries, and the webpack runtime into distinct chunks
  • Inline the runtime to avoid an extra HTTP request
  • Lazy-load code that isn't needed at initial render
  • Split by route or page so users only fetch the code they actually use