TL;DR

We upgraded webpack from 3.8.1 to 4.16.5. We find out the biggest change is how we can split common chunks between entries, which also leads to the biggest improvement we gained from this migration.

Background

Webpack 4 was released half a year ago and then released 16 minor version in the past 5 month. The webpack team brings

However, none of those is going to benefit us, until we finish migrating to webpack@4.

The Goal

  • We should upgrade webpack version 4, and make sure everything (business features/code structure) remains unaffected as much as possible.
  • Do as much performance improvement as we can, though not the focus for this migration

Change Overview

Webpack made following breaking changes that would affect our build config,

  • process.env.NODE_ENV would be injected by default.
  • ModuleConcatenationPlugin is removed and the option would be on by default in production mode.
  • NamedModulesPlugin is on by default in development mode.
  • CommonsChunkPlugin is removed and replaced by config.optimization.splitChunks option. split chunk would be on by default in production mode.
  • config.optimization.splitChunks is a different concept derived from CommonsChunkPlugin, from Imperative to Declarative, and with auto chunk split points.
  • json-loader is no longer needed, JSON is accepted by webpack as first-class citizen (like JS files).

Other than the breaking changes listed above, we still have many 3rd-party loaders and plugins to deal with. Most of them only need a simple version bump, but there are two packages that we need to take special care of:

  • extract-text-webpack-plugin do not support webpack@4 very well (migration code is still in beta version, and still not working in our config).
    The maintainer recommend developer to use another plugin: mini-css-extract-plugin
  • inline-resource-plugin no longer supports webpack@4, but its replacement inline-source-webpack-plugin behaves differently.
    The inlined script with src attribute does not go through webpack build pipeline anymore (hence no babel support and no variables defined and replaced).
    But the provided inline-bundle magic attribute does work after a lot of experiment. (solution: expose two variables ENV and LOCALE used by our inline scripts).

Fun Fact

preload-webpack-plugin does not work after our first upgrade attempt. But one morning we found out they secretly released a new version which solved the problem. And that release is not in their release history. 😈

Code splitting

As mentioned above, code splitting has changed quite much in the new version. Before this upgrade, we define two entries in webpack config: bundle and vendor. And we hand pick some parts of the project as standalone chunk entries.
This approach is still doable in the new splitChunks concept if we define cacheGroups option with vendors and default options off.
But since webpack gives us the ability to only declare configurations and let it figure it out by itself, there is no reason we shouldn't try it out.

Most straightforward approach

The default splitChunks config for webpack is as following:


{
  splitChunks: {
    chunks: 'async',
    minSize: 30000,
    maxSize: 0,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    automaticNameDelimiter: '~',
    name: true,
    cacheGroups: {
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        priority: -10
      },
      default: {
        minChunks: 2,
        priority: -20,
        reuseExistingChunk: true
      }
    }
  }
}

All we need to do is to declare:

{
  splitChunks: {
    chunks: 'all',
  }
}

Before migration

Bundle FoamTree

(Thanks to the great webpack-bundle-analyzer)
before bundle result

Total Bundle Size: 13.08MB

total-size-1

Performance Waterfall Graph (Fast 3G, no CPU throttle) Download
waterfall-graph-1

Default splitChunk config

Bundle Foamtree
foamtree-graph-2
Total Bundle Size: 8.56MB

total-size-2

Performance Waterfall Graph (Fast 3G, no CPU throttle) Download
waterfall-2

Chunk Split Configuration.

Most of the config in splitChunks field don't need much tuning. The fields we need to tune carefully are maxInitialRequests, maxAsyncRequests and maxSize. We need to find balance between them and each choice will have its own tradeoff.

splitChunks.minSize

The maxSize config limits the minimum size for a common chunk to be generated. Default 30KB (before minify).
It's the decision we have to make on having fewer requests of JS files or less chunk overlap & better caching.

splitChunks.maxAsyncRequests

Just like its name, it limits the chunk count a bundle entry can load at one time. The default value is 5. This would affect bundle result by merging smaller chunks that crosse the entry back to the bundle entry.

splitChunks.maxInitialRequests

Pretty much alike the limit on maxAsyncRequests, but this one is more sensitive because it will affect the initial rendering and subsequent image and data fetching.

The Tuning Process

When web developers see small chunks in the foamtree graph, they would feel panic and try to eliminate those small boxes. But let's read some facts about the foamtree graph appeared previously.

  • The number of chunks below 10KB is the same between two builds.
  • Though there are 14 new chunks that have size between 10KB-30KB. They are actually all JSON files (which don't appear in the old analyze graph because of the JSON loading mechanism).
  • CSS file chunks are included after the chunk split: (NEW | OLD)
    css-file-in-graph

Attempt 1
We want chunks to be bigger than 80KB (before minified), and load 10 chunks maximum (to take advantage of HTTP/2 multiplexing)

Sure, why not. Here is the result:
First of all, some of the common chunks got merged back to entry, which causes it to be packed twice and the total output size got bigger (9.2 MB).
Before chunk merge:
chunk-merge-after
After chunk merge:
chunk-merge-before
We can tell from foamtree that the total size of this entry has decreased, this means more common parts has been extracted.
Attempt 2
We want to take better advantage of HTTP/2, and we don't mind small chunks

We can set maxInitialRequests and maxAsyncRequests to 20 (recommended by webpack/example)
. And set maxSize to its default. And here is what happened:
Profile record
small-chunks
Tip: ~ in the filenames means this chunk is shared among these bundle entries.

What does it look like if we split to the extreme?
If we set maxInitialRequests and maxAsyncRequests to 99, and maxSize to 10kb, here is the build result:
total-size-4
FoamTree:
foam-tree-4
Profile record
WebpageTest
extream-waterfall
Performance on browsers that do not supports HTTP/2
We tried the attempt 2 config on the still-alive browser that doesn't supports HTTP/2 (IE10).
default config (3 initial requests, 5 async requests):
water fall on IE10
attempt 2 (20 initial requests, 20 async requests):
water fall on IE10 2
We can see script requests did stall our image requests and data requests, but not that much if we set request counts carefully. But as long as that count is bigger than 4, it would eat up all TCP connection resource in IE10.
Conclusion
Considering IE10 users only takes up to 0.5% of our traffic, and the result is not that bad. We should go with more common chunk splitting and take more advantage of HTTP/2 multiplexing.

Summary

  • We can remain the same webpack bundle behavior by upgrading all plugins we are using to the newest version. The only breaking change is common chunk split behavior.
  • If we adopt the new optimization.splitChunks concept, we can take more advantage of HTTP/2 multiplexing.
  • By upgrading to webpack@4, we improved overall web page performance by these changes:
    • Less chunk overlap, better caching.
    • Load multiple chunks in parallel, reduce total load time.
    • Total output size is reduced from 13.08MB to 8.14MB.
    • CSS code splitting along with JS chunks.

What's next

We are working on the dynamic entry assets loading based on the route/url.
Stay tuned to this blog for more!