Reduce webapp build size
Posted on September 11, 2018 in Dev • 14 min read
I recently started working on Cyclassist which aims to be a webapp to ease tracking and reporting issues with bike infrastructures while riding your bike (any danger on the way such as holes in the ground, cars parked like shit, road work, etc). You can think of it as Waze for bikes :)
This webapp is meant to be used while riding your bike, on the go. Then, I wanted it to be as small as possible, to ensure the first render on a mobile device will be quick and the whole app will be downloaded as fast as possible. I came across this Smashing Magazine article which gives some rough ideas and was really inspiring.
Documentation on reducing the build size is really scattered around the web and I did not find any comprehensive guide of the best steps to take to reduce a webapp built files size. Here is a tour of the steps I took, using Cycl’Assist to provide examples.
I am starting from a webapp with two chunks: a “vendor” chunk (everything from
node_modules
) which is around 190 kB (after gzip) and an app chunk which is
around 25 kB (after gzip). This corresponds to this
commit.
Note that at this time I was using the Vuetify webpack
template so part of the optimizations
listed below were already included. Production build time (for reference) is
70 s.
Note: This is a Vue + Vuetify webapp so some comments and code might be tailored for this setup. However, the ideas behind are general and could be adapted to other codebases :) Webpack 4 is used.
Bundle analyzer
First, the best way to check what is included in your bundles and the weight of each included lib is to use Webpack-bundle-analyzer.
To use it, simply put something like this in your production Webpack config
const webpackConfig = { … };
if (process.env.ANALYZE) {
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
}
module.exports = webpackConfig;
You can now run your production build command with ANALYZE=true
prepended to
open a browser window at the end of the build process pointing to the bundle
analyzer result.
Extract CSS to a dedicated file
Then, you can extract the CSS into a dedicated CSS file rather than having it together with your JS code. This can be done using a config similar to
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
…,
module: {
rules: [
{
test: /\.css$/,
use: [
// In development, use regular vue-style-loader. In
// production, use the MiniCssExtract loader to extract CSS to
// a dedicated file.
process.env.NODE_ENV !== 'production'
? 'vue-style-loader'
: MiniCssExtractPlugin.loader,
// Process with other loaders, namely css-loader and
// postcss-loader.
{
loader: 'css-loader',
// PostCSS is run before, see
// https://github.com/webpack-contrib/css-loader#importloaders
options: { importLoaders: 1 },
},
{
loader: 'postcss-loader',
options: {
plugins: () => [require("postcss-preset-env")()],
},
},
],
},
{
test: /\.styl(us)?/,
use: [
// Same loader hierarchy for stylus files (used by Vuetify).
process.env.NODE_ENV !== 'production'
? 'vue-style-loader'
: MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: { importLoaders: 1 },
},
{
loader: 'postcss-loader',
options: {
// Assumes you have a
// [browserslist](https://github.com/browserslist/browserslist#readme)
// in a config file or as a package.json entry
plugins: () => [require("postcss-preset-env")()],
},
},
'stylus-loader',
],
},
],
plugins: [
new MiniCssExtractPlugin({
filename: utils.assetsPath('css/[name].[contenthash:4].css'),
chunkFilename: utils.assetsPath('css/[name].[contenthash:4].css'),
}),
]
};
Note: You should define your browserslist
entry according to your own
specs and typical client browsers. You can use
browserl.ist to check which browsers are included.
Note that we use hash in the generated CSS file for easy management of cache
bustingi. Whenever the
content of the file changes, the contenthash
will change and this ensures
the updated file will be indeed requested at next visit as the URL will be different.
Minify everything
UglifyJS and OptimizeCSSAssets
Then, we want to minify JS and CSS as much as possible. This can be done using
UglifyJS
and
OptimizeCSSAssets
plugins. For example,
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
module.exports = {
…,
optimization: {
minimizer: [
new UglifyJsPlugin({
// Enable file caching
cache: true,
// Use multiprocess to improve build speed
parallel: true
}),
new OptimizeCSSAssetsPlugin({})
],
},
}
Images
When publishing images online, you can optimize them (using optipng
and such
tools), without loss of quality (deleting metadata etc).
Ideally, you should run such programs on all your image files, but that is
painful to do. You can use the
image-webpack-loader
instead to automatically run optipng
(PNG), pngquant
(PNG), mozjpeg
(JPEG), svgo
(SVG) and gifsicle
(GIF) on your image files, during build.
Here is a sample configuration for loading image files with this loader:
module.exports = {
…,
module: {
rules: [
{
// Regular images
test: /\.(jpe?g|png|gif)$/,
use: [
{
// Once processed by image-webpack-loader, the images
// will be loaded with `url-loader` and eventually
// inlined.
loader: 'url-loader',
options: {
name: utils.assetsPath('images/[name].[hash:4].[ext]'),
// Images larger than 10 KB won’t be inlined
limit: 10 * 1024,
}
},
{
loader: 'image-webpack-loader',
options: {
// Only use image-webpack-loader in production, no
// need to slow down development builds.
disable: process.env.NODE_ENV !== 'production',
},
},
],
},
{
// SVG images, same logic here
test: /\.svg$/,
use: [
{
loader: "svg-url-loader",
options: {
name: utils.assetsPath('images/[name].[hash:4].[ext]'),
// Images larger than 10 KB won’t be inlined
limit: 10 * 1024,
noquotes: true,
},
},
{
loader: 'image-webpack-loader',
options: {
disable: process.env.NODE_ENV !== 'production',
},
},
],
},
],
},
};
HTML built with HtmlWebpackPlugin
You probably already use
HtmlWebpackPlugin
to
build your index.html
file and inject scripts automatically. You can pass it
minification options as well:
module.exports = {
…,
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true, // Inject scripts
minify: (
// Only minify in production
process.env.NODE_ENV === 'production'
? {
// Options are coming from
// https://github.com/kangax/html-minifier#options-quick-reference.
collapseBooleanAttributes: true,
collapseWhitespace: true,
html5: true,
removeAttributeQuotes: true,
removeComments: true,
}
: {}
),
}),
],
};
Extract vendor libs in a separate chunk
Vendor libs (all the code under node_modules
) don’t change often, comparing
to your base code. Then, you can extract all the vendors libs code into a
dedicated chunk (another JS file). Whenever you update and rebuild your code,
if you did not update any of the JS dependencies of your app, the vendor code
will not change. Then, users will likely be able to use a cached version of
this chunk and only download the chunk containing your base code, which was
updated, resulting in smaller transfers.
This is easily doable in webpack using
module.exports = {
…,
optimization: {
…,
splitChunks: {
// Required for webpack to respect the vendor chunk. See
// https://medium.com/dailyjs/webpack-4-splitchunks-plugin-d9fbbe091fd0
// for more details.
chunks: 'initial',
cacheGroups: {
vendors: {
// Put everything from node_modules in this chunk
test: /[\\/]node_modules[\\/]/,
},
},
},
},
};
Split app in chunks, lazy loading routes
Next step was to reduce as much as possible the size of the initial script, that is the one required before any rendering could occur. Everything else would be lazy loaded after the initial render. This way, the webapp appears to be loading very fast, way before the user quits, tired of waiting ( half of your users will leave if the loading time is more than 3 seconds).
Typically, for Cycl’Assist, the onboarding view is systematically displayed at startup. This is actually a requirement as I am using NoSleep to prevent the device from going to sleep in the map view (by playing a fake media file and media files cannot be played without a prior user interaction in modern browsers). Then, the map view is loaded when the user clicks the button to access it.
The map view requires heavy external libraries (Leaflet or OpenLayers to display the map for instance) and weights 115 kB after Gzip. If it goes into a dedicated chunk, this means the initial loading of the webapp will be much lighter (about twice lighter actually)!
Lazy loading Vue routes can be easily achieved with Webpack dynamic import.
Basic solution: just lazy load the components when required
The most basic solution is simply to lazy load the chunk with the extra components when entering the route. In your router definition, just use something like this
import Vue from 'vue';
import Router from 'vue-router';
Vue.use(Router);
const LazyMap = () => import('Map.vue'); // Dynamic import
export default new Router({
routes: [
{
path: '/map',
name: 'Map',
component: LazyMap,
},
],
});
This is fine, but the chunk will only be downloaded when the user enters the route. This means that there won’t be any feedback to the user when they enter the route and while the chunk is downloaded, which might be misleading.
More elaborate solution: lazy load the components when required but display feedback while loading
A slightly more elaborate solution is to use Vue’s ability to handle lazy loading to display feedback to the user (such as a progress bar).
<template>
<Map></Map>
</template>
<script>
// Your Loading component, typically a spinner
import Loading from 'Loading.vue';
export default {
components: {
// Define a lazy-loaded component
Map: () => ({
// This is actually Vue magic. `component` is the dynamically
// imported component, `loading` is a component to display during
// loading.
component: import('Map.vue'),
loading: Loading,
}),
},
};
</script>
Going further with chunk names: grouping dynamic imports
Now we have a route which is lazy loaded and the size of the initial script has reduced drastically, that’s fine. Still, there is an issue left: how to group together imports of different components in a single chunk?
Typically, in Cycl’Assist, the map view is lazy loaded and all the files to render this view are in a dedicated chunk. However, I had to define related functions in other components. This is typically the case for the “Export GPX” (export the current GPS trace), which has to be defined in the menu but does not make sense to display before the user has seen the map and started tracking their position. There is no need to load this part in the initial chunk, it should go together with the map view in its dedicated chunk.
If we use the previous strategy to dynamically load the “Export GPX” code, Webpack would put it in a third chunk, which does not make any sense. I’d like this code to go together with the map view chunk. How to handle it?
The easy solution is to use named chunk. Actually, Webpack lets you specify a
name for the chunk created by a dynamic import, through a JS comment. To use
it, simply replace the previous import('Map.vue)
by
import('Map.vue' /* webpackChunkName: "MapView" */)
. This will name the
chunk “MapView” (you can put whatever you want, this is simply a name).
Then, when lazy loading the methods to export to GPX, you can specify the same chunk name to put the code in the same chunk as the map view. For instance,
export default {
methods: {
exportGPX() {
import('exportGPX' /* webpackChunkName: "MapView" */).then((module) => {
module.default(this.data);
});
},
},
};
With this setup, the lazy loaded chunk are loaded whenever they are required (typically when you browse to the corresponding view for instance). You can also prefetch them, to start loading them as soon as the initial render is done. More instructions on this are available at https://mamot.fr/@glacasa/100708173580273735 but I could not yet look into it, so this is untested.
Only load the necessary bits from the libs you use
Use Babel 7, which comes with smarter polyfilling
Babel 7 was recently released and comes with a new (still experimental though) feature to help you reduce the size of your polyfills. You should really try using it!
With a basic configuration of prior versions of Babel, you are likely to have
a .babelrc
file containing something similar to
{
"presets": [
["env", { "modules": false }],
"stage-2"
]
}
This means you are using the “env”
preset, which will enable syntax
transforms and polyfills based on your
browserlist definitions. We
do not transform ES6 modules ("modules": false
) because we are using Webpack
as the build system. We also use the
“stage-2” transforms.
When you use Babel like this, you have to include a import "@babel/polyfill"
directive somewhere in your code (only once) to include the matching polyfills
for the features you enabled.
In Babel versions prior to 7, you could use "useBuiltIns": "entry"
as an
option to the env
preset to replace the global import of @babel/polyfill
(pulling the whole lib into your compiled files) by individual calls to the
specific polyfilled features in your preset (pulling only the polyfills
required for your browser targets). This was coming with a huge reduction of
the compiled files size, depending on your browsers target.
Still, this meant you would pull polyfills for all the features which were not
supported by your browsers target, even if you were not using them. Babel 7
introduced a new value for this option, "useBuiltIns": "usage"
, which will
replace the global import of @babel/polyfill
by individual imports for each
polyfilled feature you are using in your code, still based on your
browser targets. This feature is still experimental, but so far it works well
for my use case. There might be some false positives, meaning you are pulling
polyfills in your code base which you are not using, but this is not a big deal.
With two chunks involving loading parts of @babel/polyfill
, the size went
down from 21.94 + 5.66 kB to 5.87 + 8.42 kB. That is a total reduction of
about 13 kB!
Note that the .babelrc
and presets have slightly changed in Babel 7, but
there is an upgrade
tool
which makes a great job. A similar configuration as the one at the beginning
of this section for Babel 7 is:
{
"presets": [
[
"@babel/preset-env",
{
"modules": false,
"useBuiltIns": "usage"
}
]
],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-syntax-import-meta",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-json-strings",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-proposal-function-sent",
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-numeric-separator",
"@babel/plugin-proposal-throw-expressions"
]
}
Vuetify components
My webapp is built around Vue and Vuetify. Vuetify is very nice to quickly prototype an app as it includes tons of Material design components which play nice together. It is really easy to use to quickly draw a basic interface out of them. However, there is an obvious downside: Vuetify includes tons of components (just have a look at the list of components), which makes it heavy (about 25 kB of gzipped CSS and 117 kB of gzipped JS).
Luckily, Vuetify developpers thought about this and each component is defined in its own file. Then, it is possible to only import the components you need, then reducing drastically the size of the built files.
To only import the components you need from Vuetify, simply import it like this
import Vue from 'vue';
// Import the core Vuetify code
import Vuetify from 'vuetify/es5/components/Vuetify';
// First, import the base styles
import 'vuetify/src/stylus/app.styl';
// Import required Vuetify components
import VApp from 'vuetify/es5/components/VApp'; // VApp is a core component, mandatory
import VMenu from 'vuetify/es5/components/VMenu'; // To import the v-menu component, for example
Vue.use(Vuetify, {
components: {
VApp,
VMenu,
},
});
There is an helper tool in Vuetify doc to generate the imports you need based on the components you want to import.
If you are converting an already existing code base, you first have to list the Vuetify components you are using. I used this bash magic one liner (which might have issues, use at your own risks) which you can run in the folder containing all your source JS and Vue files:
ack '<v-' . | sed -e 's/^src\\/.*:[[:space:]]*<//' | grep '^v-' | sed -e 's/\\(v-[^[:space:]>]*\\).*/\\1/' | sort | uniq
Importing only the components I use in Vuetify, Vuetify only contributes for 39 kB of JS after gzip to my vendors chunk.
Moment locales
If you are using Moment.JS to handle your dates in JS, first you should be aware that the default build requires all the locales from Moment, even if you don’t use them. You should only load the locales you need, which can be easily done in Webpack using the following plugin
module.exports = {
...,
plugins: [
// Don't include any Moment locale
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
};
and in your own code you can then manually require explicitly the locales you need
import moment from 'moment';
import 'moment/locale/en';
import 'moment/locale/fr';
moment.locale('fr'); // Set default Moment locale
Dropping the unused locales, you spare a few kB per locale (and there are tons of them!) :)
Drop useless libs or find lighter alternatives
Replace Moment by date-fns or custom script
Moment is not very modular and the whole library will be added, no matter whether you use all the features or only a handful of them. With French and English locales only, this results in 17 kB after gzip for Cycl’Assist.
Another option is to move to date-fns
which is a collection of useful
functions to work with dates (think lodash), rather than the global object
approach from Moment. This is much more modular and you can only import the
specific features you need. For Cycl’Assist, this is basically relative
times,
formatting and
parsing ISO 8601 strings. For
Cycl’Assist, this means going down from 17 kB after gzip (using Moment) to a
handful of kB (using date-fns
), which is a huge improve.
However, these libraries might be super useful when you prototype something,
as they will provide you with tons of functions at hand, but once your webapp
is stabilized, you can check and you probably use only a very small portion of
the possibilities of such libraries. Sadly, JavaScript does not have advanced
functions built-in for formatting and computing relative dates, but there is a
Date.parse()
function to parse ISO 8601 strings (beware that it returns a milliseconds
timestamp, not a Date
object!) and to do all-in-one
formatting
according to locale.
Doing basic formatting of dates in JS and computing relative times can be done in less than 100 lines and this, together with the built-ins functions, should be enough for most projects (at least it is sufficient for Cycl’Assist). The resulting file size after minification and gzip is 600 bytes. That’s 28 times lighter than optimized Moment!
gps-to-gpx
and xmlbuilder
To build the GPX file from the GPS points, I am relying on a nice library. Sadly, the library was written with Node in mind, and not the browser. As Node does not have any built-in to generate and manipulate XML trees, it was relying on xmlbuilder.
I’m using this library in a browser context and browsers have DOM parsing and serialization APIs which can be used to manipulate and generate XML.
Rewriting the XML generation part of the gps-to-gpx
library to use the
browser APIs means that we can get rid of xmlbuilder
in the compiled files.
This is 7.8kB less ! Note that the same code could potentially be used with a
Node backend, using xmldom to polyfill the
required APIs.
Howler to play sounds
In Cycl’Assist, I wanted to play a sound whenever the user approaches a known report. For quick and easy prototyping, I was using Howler which has the great benefit of taking care of everything for you and ensuring compatibility with as many browsers as possible. Of course, it comes with the downside that it makes for 7 kB of gzipped JS.
For my particular use case, the Audio API was more than enough. I did not need support for playlists nor advanced tricks for autoplay on mobile devices. Audio element is well supported and so is MP3 format.
Other ideas
Here are some other ideas, which are not really useful for Cycl’Assist at the moment but might be for you:
- If you have a lot of translations and they represent enough kB to worry about, you could lazy load them.
- There are some nice
ideas
to load polyfills in a dedicated chunk only on browsers which need them
(and without relying on polyfills.io).
Sadly, this is not
implemented
or easily doable with Babel
useBuiltIns: usage
feature :(
Conclusion
Initially, I had two chunks: a “vendor” chunk (everything from node_modules
)
which was around 190 kB (after gzip) and an app chunk which was around 25 kB
(after gzip). Both had to be loaded before the app could start to render,
meaning an initial payload of about 215 kB (after gzip). The production build
time was 70 s (but this already included image-webpack-loader
processing for instance).
After going through the steps described in this guide, corresponding to this commit, I now have three chunks: * A “vendor” chunk, which is about 90 kB (after gzip). * An initial “app” chunk, which is about 27 kB (after gzip). * A lazy-loaded chunk for the map view, which is about 90 kB (after gzip).
The total size of the JS files did not change much. It actually slightly increased, but this is not a fair comparison as I added new features and moved from Leaflet to OpenLayers, which is about three times heavier. The important part is that my initial payload (required for the first render) was 215 kB whereas now I have an initial payload of 90 + 27 = 117 kB. This is 100 kB less or equivalently 2 seconds less when browsing using a mobile phone relying on a perfect EDGE / typical urban 3G connection (link in French).
The final production build time is about 50s.
Note that I am still working on optimizing this webapp for mobile connections. I will keep this article updated as I find new ways to reduce the compiled webapp size.