Lazy-loading modules in EmberJS

The article is dedicated to the topic of lazy-loading of dependencies in an EmberJS ecosystem. You will be able to read about our motives, we will guide you through an example of how we managed to delay loading a phone number validation library, all enclosed by summing up our achievements.


Motives

Every (not only single-page-app) developer must have stumbled upon a topic on optimising app boot speed. Approaches as code minification, assets optimisation, caching, CDN integration, offline accessibility, server-side rendering come in very handy.

We were no exception to that. An importance of an optimised build process, as well as a vividly-responsive platform has been on our mind since the very beginning (500% build process speed-up, SSR in Zonky, DB optimisation, note: all are written in Czech).

Yet after we looked at Lighthouse / Page Speed Insights analysis* recently, we realised there was again some room for an improvement.

Overall Lighthouse analysis before applying lazy-loading concepts
Lighthouse analysis output regarding JS execution time before applying lazy-loading concepts

Even if there were a few other recommendations we couldn't do much about (an integrated web chat embedding an iframe with a plenty of redundant and non-minified CSS), we spotted that our bundle size grew unexpectedly.

For those who are not familiar with EmberJS bundle structure: zonky-app.js is a file containing a business-specific logic whereas vendor.js bundle contains a framework-related code (framework itself, optional addons, non-framework specific 3rd party libraries, etc.).

Well, Page Speed Insights quickly helped us to narrow down what to focus on next: the vendor code. At this point we could raise 3 issues to take care of:

  • How to easily determine size impact of addons bundled intovendor.js?
  • Once detected, what can we do to avoid it?
  • How to make sure adding a new addon in the future won't cause a huge increase of a bundle size again?

Analysis

For the purpose of bundle analysis we reached out for the addon called ember-cli-bundle-analyzer. It visualises an entire app bundle in a hierarchical way (top-to-bottom) including its file sizes.

An output of ember-cli-bundle-analyzer showing what our bundle looked like.

Aside from module sizes which we were aware of, one could spot a rather large chunk representing liphonenumber library (in the middle of the attached screenshot). Shortly put, the liphonenumber provides a more sophisticated way for phone numbers validation. This dependency was added not so long ago by a developer who did not notice the dependency's size when adding a new form enabling account activation via SMS. The form was used on 1 page only, yet it held ca. 80 % of the vendor modules embedded to the app bundle.

Instead of immediately giving up on a client-side validation, we decided to take an advantage of an alternative approach we had already listed in our wanna-try techniques for some time.


Implementation

Lazy-loading is easily accomplishable by applying dynamic import. Dynamic import is currently a Stage 3 ECMA feature. It works similarly to static import, but makes the following possible[1]:

  • import a module on-demand (or conditionally)
  • compute the module specifier at runtime
  • import a module from within a regular script (as opposed to a module).

Ember community doesn’t sleep though — the feature’s adoption is already available thanks to a brilliant addon called ember-auto-import (EAI). The addon uses webpack behind the scenes to keep dynamically loaded node modules off the app bundle and to mount them first when needed.

Target

Our target, in EmberJS terms, is loading a module at places dedicated primarily to handling asynchronicity — the model hooks — followed by saving the loaded functions or properties for a latter use (a simplified example below).

// app/index/route.js
beforeModel() {
...
return import('libphonenumber').then((module) => {
// do sth with the loaded module
});
},

Configuration

Well, let's provide an example for what such a configuration for libphonenumber may look like.

Modify ember-cli-build.js configuration to let the EAI addon know which module to skip when bundling the vendor files.

autoImport: {
publicAssetURL: '/assets',
alias: {
libphonenumber: 'google-libphonenumber/dist/libphonenumber',
},
// avoids multiple import
exclude: ['qunit', 'moment', 'autosize', 'ldclient-js', 'rsvp'],
},

The publicAssetsURL tells the EAI where to output the module within the dist folder. Every such dependency will be (by default) fingerprinted, given a name prefixed with chunk (for example dist/assets/chunk.ed16d872df711b50f989.js).

The alias key stands for a key we want to use in our beforeModel() hook to target the module. The value holds a location of the module within the node_modules folder. If the package name is the same as the key you want to use, there is no need to configure an alias.

Since webpack and ember-cli might bundle other node modules redundantly, you might need to explicitly exclude them via the exclude config option.

Building

During the build process (say you run $ ember build -prod ) the EAI produces a chunk per lazy-loaded module and places it into the configured path (in our case it was the dist/assets folder):

The dist folder layout after the build process

Some information related to the bundling process is revealed in the console:

Asset                            Size       Chunks    Chunk Names
chunk.536b638f912d1a992d67.js 2.76 KiB 0 app
chunk.5d120ecc1871f382581f.js 25.7 KiB 2 vendors~app
chunk.8a81c3889ccbe62637dd.js 6.64 KiB 3 vendors~tests
chunk.a19df52fb071e2603fe8.js 1.91 KiB 1 tests
chunk.ed16d872df711b50f989.js 444 KiB 4 [big]
Entrypoint app = chunk.5d120ecc1871f382581f.js chunk.536b638f912d1a992d67.js
Entrypoint tests = chunk.8a81c3889ccbe62637dd.js chunk.a19df52fb071e2603fe8.js
...
[29] ./node_modules/google-libphonenumber/dist/libphonenumber.js 540 KiB {4} [built]
...
Built project successfully. Stored in "dist/".
File sizes:
- dist/assets/chunk.ed16d872df711b50f989.js: 444.26 KB (92.08 KB gzipped)
...

There is an extra argument you can use to get a deeper view on what webpack is doing:

$ DEBUG="ember-auto-import:*" ember b -prod

Clarification

Now, if you have already tweaked your model hook to import() a module on the fly, you should be able to confirm the correct behaviour in a network tab of your browser:

The module script is being fetched once the execution hits the beforeModel() hook

After this point it is up to you what you do with the fetched script. You can use it directly in the route hook, or you can store the loaded module in a property and pass to a component or save it in a service.

The screenshot demonstrates availability of the imported module in the app.

Prevention

The last question to be answered is how to prevent developers from adding a large dependency and realize it after shipping into production.

Instead of writing a custom script, we reached out for an addon called ember-cli-bundlesize. With a super simple configuration as:

'use strict';
module.exports = {
'js-app': {
pattern: 'assets/zonky-app-*.js',
limit: '200KB',
compression: 'gzip',
},
'js-vendor': {
pattern: 'assets/vendor-*.js',
limit: '500KB',
compression: 'gzip',
},
css: {
pattern: 'assets/styles/*.css',
limit: '50KB',
compression: 'gzip',
},
};

It proved to be exactly what we needed due to the fact that:

In case of a failure the command will exit with a non-zero exit code. So you can integrate this command into your CI workflow, and make your builds fail when the bundle size test does not pass.

Thus our CI pipeline ends with an error if a developer attempts to add a module whose size adds to the size of an app exceeding the configured limit.

In a case that size stays within limits, running ember bundlesize:test praises you for a good job:


Summary

The performance of our web app improved by several points**. All we changed was an approach to including a 3rd party dependency that was used on a specific route only. The initial bundle size dropped by almost 0.5 MB;

Overall Lighthouse analysis after applying lazy-loading concepts

This EAI-related code was added, however, it won’t grow much with next modules treated lazily in the future. Hence we have put more lazy-loadable dependencies on our ToDo list and are convinced this will prove even better with new requirements; For instance, another locale to support (currently 12+ KB). There are already PRs coming taking advantage of this load-on-demand approach since they add functionality to the app only in case of mobile devices or other specific cases the common code base does not necessarily need.

Lighthouse analysis output regarding JS execution time after applying lazy-loading concepts

* the demonstrated analysis was conducted by Lighthouse on Chrome running the app locally with the following settings:

  • * The Best practices evaluation sank down because of an increased amount of insecure (HTTP) requests — all related to tracking tools. We believe that this is due to the fact that more requests were executed during the analysis of the app with lazy-loading (lazy-loading made our app more performant which made app fire more requests, 2 of them over HTTP, hence the Best practices rank dropped).

Sources:

[1] Dynamic import, Mathias Bynens https://developers.google.com/web/updates/2017/11/dynamic-import