6 tips to optimize bundle size
Mathias Bernardeau
Posted on May 13, 2020
Disclaimer:
This article includes optimizations that are only valid for an app bundled with webpack, or only for a React app. Some optimizations also assume you have control over the webpack configuration of your app.
Measure and visualize bundle size
Valid for: any app bundled with Webpack
The tool webpack-bundle-analyzer can produce an easily understandable view of the composition of a JS bundle.
The easiest way to use it is to generate a stats file with webpack and to launch the tool with npx
.
webpack --profile --json > stats.json
# Assuming generated bundled files are in the dist folder
npx webpack-bundle-analyzer stats.json dist/
To understand what the different sizes mean:
-
Stat size
is the size of the input, after webpack bundling but before optimizations like minification -
Parsed size
is the size of the file on disk (after minification). It is the effective size of the JavaScript code parsed by the client browser -
gzip size
is the size of the file after gzip (most likely the effective content size transmitted over the network)
1. Avoid libraries global imports
Valid for: any bundled app or lib
Cost: Low
Impact: High
With some large libraries, it is possible to import only the parts we use instead of the entire library. If done right, this can save a lot of unused bundle size.
Examples of splittable libs: lodash, date-fns, react-bootstrap...
The downside of this is that only one global import in the app or in dependencies that transitively depend on these libs is necessary to make the bundler include the whole dependency.
In this capture you can see that necessary functions are bundled up to 3 times (once in lodash.js, once in lodash.min.js, and once in one-by-one imports). This is the worse case scenario.
There are two ways to enforce one-by-one imports. Note that both of these methods don't apply to dependencies, only to your own code.
Via a babel plugin
The plugin babel-plugin-transform-imports has the ability to replace global destructured imports by one-by-one imports.
Configured like this:
# .babelrc
"plugins": [
["transform-imports", {
"lodash": {
"transform": "lodash/${member}",
"preventFullImport": true
}
}]
]
It will have the following effect:
import { map, some } from 'lodash'
// will be replaced by
import map from 'lodash/map'
import some from 'lodash/some'
Note that the option preventFullImport
will tell the plugin to throw an error if it encounters an import which would include the entire library.
Via an ESLint rule
The downside of the first method is that two methods of imports of the same function are valid, meaning it becomes harder to enforce a single import style in a single project.
Fortunately it is possible to configure the no-restricted-imports rule to throw an error if a global import is encountered.
// .eslintrc
"no-restricted-imports": [
"error",
{
"paths": [
"lodash"
]
}
]
It will produce an error for the following import:
import { map } from 'lodash'
But not for a specific module import like this.
import map from 'lodash/map'
You can of course combine these two methods to enforce a specific style.
2. Use code-splitting
Valid for: Web apps bundled with webpack
Cost: Low
Impact: Variable
Using dynamic imports and Suspense, it is possible to split application code in async chunks that can be loaded on-demand. This allows to reduce the size of the bundle downloaded initially. It does not reduce overall bundle size (it even slightly increases it).
Configuration:
# webpack.config.js
optimization: {
splitChunks: {
// include all types of chunks
chunks: 'all',
}
}
By default a “vendors” chunk is created, separating application code from dependencies. This can have a positive impact when updating the application. If only the application code changes (assuming resources are correctly cached), the client can save the cost of downloading vendors files. This behaviour can be disabled by setting:
optimization: {
splitChunks: {
// include all types of chunks
chunks: 'all',
cacheGroups: {
vendors: false,
},
},
}
Be careful not to be overzealous with code splitting as this can slow down some user actions as we have to download, parse and execute more code. Depending on the structure of the application, it possible that adding a chunk implies downloading several files (with HTTP 1, there is a limit on parallel connections to the same domain).
The recommended way is to create a chunk per route. This is not an absolute rule.
How to export a lazy loaded component:
// myComponent.lazy.jsx
import React, { Suspense } from 'react'
import LoadingIndicator from '..'
// Create a lazy component using React.lazy
export const MyComponentLazy = React.lazy(() =>
import(/* webpackChunkName: "my-component" */ './myComponent'),
)
const MyComponent = props => (
<Suspense fallback={<Loading Indicator />}>
<MyComponentLazy {...props} />
</Suspense>
)
export default MyComponent
Here we use the dynamic import syntax to tell Webpack to bundle a separate chunk for MyComponent (and all its dependencies).
Setting the webpackChunkName
is optional, this allows to control the name of the generated file (with the corresponding webpack configuration). If two lazily imported components have the same name, they will be concatenated in a single chunk.
React.lazy
is used to allow the lazily imported component to be rendered like a regular component. Suspense
allows to provide a fallback (component that will be rendered while the import is not resolved).
Note that Suspense
can be further up the component tree, depending on what the users should see during load.
See React documentation for a more complete explanation of lazy
and Suspense
.
3. Do not include source maps
Valid for: Web apps and libs bundled with Webpack
Cost: Low
Impact: Variable
Source maps are a link between source code and generated bundled files. While it can be really useful to use browser debuggers, it shouldn't be included in the production bundle.
For JS source-map, the option devtool controls how source-maps are generated.
For development, 'eval-source-map'
is a good choice (we see the original source and rebuilds are fast).
For production, setting false
will completely disable source-map generation. As it can be useful to debug generated bundled app, the best way to keep them in production is to set devtool: 'source-map'
. It will generate a separate file (downloaded only if browser devtools are open) linked by a comment added in the original bundle that looks like this: //# sourceMappingURL=app.daa9676c2167d965d0ae.js.map
.
For CSS, Less or Sass source-maps, the configuration depends on the loader used. Using css-loader, sass-loader and less-loader, I would recommend setting options: { sourceMap: true }
in development inside loader configuration, and options: { sourceMap: false }
in production (as this is the default, you can safely omit the property in production).
4. Remove replaceable libs
Valid for: any bundled app or lib
Cost: Variable
Impact: Variable
It can be very tempting to add a library which does meet the user requirement but also do much more. Possible reasons include not knowing future needs of users or simply to deliver faster.
Adding unneeded complexity can have a huge impact on the bundle size.
In my project, we found out that we used libphonenumber-js for only two use cases:
- Format a french phone number
- Validate an input field which only allows french phone numbers
Having to deal only with french phone number greatly reduce the complexity needed for these kind of features. libphonenumber-js is a great library -- just not fitted to our needs.
Rewriting these feature using only vanilla JS took only a few hours and saved us ~150 KiB of JS bundle size.
For each dependency, you should wonder:
- Do we use only a small part of the dependency ?
- Do we have the capacity to rewrite it in a reasonable time ?
If the answer to both questions is yes, it seems that rewriting code which meet the needs of the project (and only them) is a good idea.
5. Remove prop-types
Valid for: React apps
Cost: low
Impact: High
With React, defining prop-types enables validation of props passed to a component. Although it is really useful in development, prop-types are disabled in production (mostly for performance reasons).
But their definition is still included in the produced bundle.
The Babel plugin transform-react-remove-prop-types completely deletes prop-types definitions from the generated bundle. However, prop-types included by dependencies are not removed.
// .babelrc
{
"env": {
"production": {
"plugins": [
[
"transform-react-remove-prop-types",
{
"removeImport": true
}
]
]
}
}
}
Warning: only activate this plugin in the production environment.
6. Target recent browsers
Valid for: any web app
Cost: low
Impact: medium
To include polyfills, you probably already use core-js and regenerator-runtime.
By default, all polyfills are included and core-js weights approximately 154KiB while regenerator-runtime is only 6.3KiB.
By targeting only recent browsers, it is possible to reduce the size of included polyfills.
Babel-preset-env has the ability to replace global imports of core-js by specific imports dependings on the targeted browsers.
To configure the preset:
// .babelrc
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry",
"corejs": "3.6"
}
],
],
The version of core-js has to be provided.
With "useBuiltIns": "entry"
, you only have to import these two dependencies once:
import 'regenerator-runtime/runtime'
import 'core-js/stable'
These two imports will be replaced by specific imports depending on the targeted browsers.
To declare targeted browsers, the preset uses the browserslist syntax.
"browserslist": "last 2 Chrome versions, last 2 Firefox versions, last 2 safari versions",
Conclusion
Hope this helps !
Let me know if this is useful to you and how much bandwidth and client memory you saved
Posted on May 13, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.