Tracking client JavaScript bundle size during development
Problem: During development, it is so easy (and tempting) to add an additional dependency. This can cause your client bundle to bloat, negatively impacting users with constrained network connections (download size) or constrained devices (JavaScript parse times). Dead-code elimination is a good practice but, even with ClojureScript and Google’s Closure compiler, is not enough to eliminate all unnecessary dependencies.
Solution: One defence against client bundle bloating is to continuously monitor the size of the production bundle during development and take action when it grows substantially. Tightening feedback loops allows action to be taken when it is cheaper to do so (source: Lean/Agile!). This post describes an approach to monitor bundle size using the open-source Jenkins continuous integration server.
The basic idea is pretty simple:
- Frequently push code changes to version control.
- Have a continuous integration server produce a production (optimised) build in the background frequently.
- As part of the production build, generate some metrics on bundle size.
- Visualise the metrics (or alert, or whatever).
Local Jenkins build automation server
I have a local installation of Jenkins running on my development laptop, although that’s not particularly important for this article. (See my tips for installing on MacOS at the end of the post.)
Local git repositories: working and master
Visualising build size with Jenkins does not require local git repositories, but it works well for me when I don’t wish to use a remote service like Github. The nice thing about having a local file-system master repository is that it is very cheap to do a frequent (every minute) poll of the repository and also works when I’m working offline.
For my applications, I have two local git repositories that I manage. One is my working copy but the other is actually in a cloud-synchronised directory (mainly for off-site backup) and that is the master to which I push my commits. (Jenkins is actually going to create another working copy for the build.)
This is easy to setup as follows, assuming that you already have a local working repository:
# working directory in ~/work/myproject
# master repo to be in ~/cloud-secured/myproject.gitgit init --bare ~/cloud-secured/myproject.git# In ~/work/myproject
git remote add origin ~/cloud-secured/myproject.git
git push -u origin master
Now, you can possibly get away with pointing Jenkins at your .git
directory inside your working directory but I haven’t tried that to know if there are any pitfalls. You could also probably use a post-commit hook but I haven’t had to as the setup above works well for me.
When setting up the build in Jenkins, I simply use a file://
URL, e.g.
file:///Users/<username>/cloud-secured/myproject.git
and set the poll SCM
option to be every minute using * * * * *
for the cron-like schedule specification.
Simple script to calculate build sizes
In my project, I have a script ./bin/jss-size.sh
containing the following:
#!/usr/bin/env bash
export FILE_NAME="./resources/public/js/compiled/app.js"export UNCOMPRESSED_FILE_SIZE=`wc -c < $FILE_NAME`
export GZIPPED_FILE_SIZE=`gzip -c < $FILE_NAME | wc -c`echo $UNCOMPRESSED_FILE_SIZE,$GZIPPED_FILE_SIZE
In this case, the JavaScript bundle is in a file called app.js
. We use wc
to calculate the number of bytes (the -c
option) of a file (piped from STDIN). Using -c
for wget will pipe the GZipped output to STDOUT rather than gzipping the file in place. These two metrics are then printed out to STDOUT.
It is probably a good idea to initialise an output CSV file as follows. I have mine simply in the project root directory (laziness!) called js-size.csv
.
"JS Size (Uncompressed)", "JS Size (GZipped)"
Jenkins build step to generate metrics
After the main production (optimised!) build step (using webpack
, lein
, etc.) I have another “execute shell” build step with the following action to simply concatenate the current build’s metrics to a CSV file.
bin/js-size.sh >> js-size.csv
Configuring Jenkins to plot the results
First, install the plot plug-in for Jenkins using the “Manage Plugins” option within the “Manage Jenkins” UI. (I’m using v2.1.0.) Obviously, this only needs to be done once for a Jenkins instance.
In the build configuration, add a “plot build data” post-build action. Specify a plot group (“Statistics”) and title (“Client bundle size”). My y-axis label is “Size (bytes)” and I use a line plot-style with “build descriptions as labels” turned on. I also keep records for deleted builds.
Once you click the button to “Add CSV series,” you can specify the data series file (e.g. js-size.csv
). I also select the option to “display original CSV above plot.”
Production client bundle metrics, finally!
Assuming everything is configured correctly, the next time you run a build, you will get a “Plots” entry for the Jenkins build that might eventually look something like the following:
In the early builds, I stripped down the project to bare minimum, then re-added my dependencies. In the case of this project, I’m using ClojureScript so Google’s Closure compiler is used with advanced optimisations. Simply introducing a library such as Rum (which transitively introduces React) bumps the bundle size up to around 60KB (min+gzip) (see build #18). Around build #29, I introduced clojure.spec and by build #31 I was experimenting with instrumentation. In build #36 I managed to move the instrumentation to only exist in my development builds, so we can see the dependency is removed from production.
Dead-code elimination (e.g. Google Closure compiler’s optimisations) cannot eliminate code that isn’t provably unused. Sometimes simply including a dependency will introduce data or code that the compiler cannot prove isn’t used, so it cannot eliminate it.
Conclusion
Monitoring bundle size in near-real-time during development allows me to be more critical about dependencies I’m introducing. The script can be used outside of the continuous integration server, of course, so it is interesting to just evaluate what weight a library introduces (just by requiring it, without even using it!).
Tips for installing Jenkins on MacOS
Jenkins is easily installed via brew install jenkins
. I did want to change the HTTP port to something other than 8080 and this was a little problematic so I just hacked the jenkins
script located in /usr/local/Cellar/jenkins/<version>/bin
to include --httpPort=8889
at the end (just before “$@”). I simply run jenkins
at the beginning of my development session.
To avoid issues with the ClojureScript compiler, I also had to make sure that Jenkins knew about my local JDKs so used the Jenkins UI via: Manage Jenkins, then Global Tool Configuration, then JDK.