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 wont 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 wont 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.