What I’ve learned about minifying Elm code

Summary

The Elm Guide has a page on minification, but it’s not the end of the story.

  • The UglifyJS command in the Elm Guide can be tweaked to produce slightly smaller code a tiny bit faster.
  • But you can get even smaller by first running just parts of UglifyJS and then esbuild – and much faster!
  • Gzip is really unpredictable. Slightly increasing the minified size can sometimes decrease the gzipped size.
  • If you’re OK with ~2% more of the original JS, you can run only esbuild in less than a second (~40 times faster).
  • There’s a trick to getting esbuild to remove even more JS.
  • Terser produced ever so slightly more code than UglifyJS last time I checked (and has dependencies, while UglifyJS has none).
  • I didn’t bother with Google Closure Compiler.
  • swc is porting Terser to Rust – I’m excited to see if that beats esbuild in the future!

Make sure to check out the “Time/Size comparison table” at the end of this post!

A note on how UglifyJS works

To understand why I’ve configured UglifyJS like I have, one needs to know a little bit about UglifyJS.

UglifyJS has two central things called mangle and compress, which can be enabled and configured individually.

mangle:off, compress:off

If both are off, UglifyJS only removes whitespace and comments, which is the simplest form of minification.

var cool = true; // hi!

:arrow_down:

var cool=true;

mangle:on, compress:off

mangle renames variables to be shorter.

var cool = true; // hi!

:arrow_down:

var r=true;

mangle:off, compress:on

compress has a bunch of sub-options that do everything from shortening booleans to dead code elimination.

var cool = true; // hi!

:arrow_down:

var cool=!0;

mangle:on, compress:on

Usually one enables both:

var cool = true; // hi!

:arrow_down:

var r=!0;

Sometimes mangle and compress can interact. I’ll get to that in the next section.

(Note that the above examples are a little bit simplified. If you feed them directly to UglifyJS you might not get the exact same results without more flags (such as --toplevel), or more complicated code examples.)

The Elm Guide, tweaked

The Elm Guide page about minification says:

uglifyjs elm.js --compress 'pure_funcs=[F2,F3,✂,A8,A9],✂' | uglifyjs --mangle --output elm.min.js

uglifyjs is called twice there. First to --compress and second to --mangle. This is necessary! Otherwise uglifyjs will ignore our pure_funcs flag.

That’s correct! --mangle renames stuff like F2 to t, and at the same time we’re saying that functions called F2 should be treated as pure. UglifyJS must be doing mangling first or something, because they say this in their docs:

Make sure symbols under pure_funcs are also under mangle.reserved to avoid mangling.

So while it works running UglifyJS like the Elm Guide says, repeating the list of pure functions in mangle.reserved is better:

  • It results in a little bit smaller output.
  • It’s ever so slightly faster. I had expected much faster, but it turns out parsing is fast, so doing it twice doesn’t matter much.

Here’s the updated version of the CLI command from the Elm guide:

uglifyjs elm.js --compress 'pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,unsafe_comps,unsafe' --mangle 'reserved=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9]' --output elm.min.js

Or using the JS API:

const fs = require("fs");
const UglifyJS = require("uglify-js");

const pureFuncs = [ "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9"];

const elmCode = fs.readFileSync("elm.js", "utf8");

const result = UglifyJS.minify(elmCode, {
  compress: {
    pure_funcs: pureFuncs,
    pure_getters: true,
    unsafe_comps: true,
    unsafe: true,
  },
  mangle: {
    reserved: pureFuncs,
  },
});

(I also removed keep_fargs=false since that’s the default.)

UglifyJS passes option

passes is an option for compress that allows running compress several times. passes: 2 eeks out a little bit more code, but is really expensive: The runtime is up to 50% longer. With passes: 3 we’re really talking diminishing returns.

The default is passes: 1 – go with that unless you really need those extra kilobyte savings. Too slow build times can be a pain!

UglifyJS tradeoff

The UglifyJS docs suggest only enabling mangle for faster minification, since whitespace+comments removal and variable renaming are faster than other optimizations and give the biggest wins.

I also researched only enabling some compress options. This is pretty tricky because many times the options work together. The way to do it is to start out with some UglifyJS command to use as a baseline, and then disable just one option at a time using a script. If disabling an option resulted in the same size, that option did nothing. If disabling an option increased the size, it actually helps when enabled!

I tested this with 3.2MiB of JS from a pretty large Elm app at work, and used the tweaked Elm Guide command as a baseline. Turns out only about half of the options affected the size!

compress option character increase when disabled
negate_iife 4
assignments 7
unsafe_comps 24
properties 30
typeofs 108
hoist_props 116
unsafe 121
comparisons 147
loops 377
reduce_funcs 469
side_effects 497
evaluate 694
strings 1074
booleans 1134
functions 1337
sequences 1933
merge_vars 3818
switches 6261
dead_code 6529
pure_funcs 7572
if_return 7996
inline 14381
pure_getters 15110
join_vars 17762
reduce_vars 19239
conditionals 20698
collapse_vars 20763
unused 53527

What I did then was to pick and choose just a few of the above and see how they behaved: How much size do they eat, with what time cost? Is enabling those two expensive but effective options better than enabling those four cheaper ones? How does this rule improve if that is also enabled?

From trial and error I found that the following combo gave a lot of bang for the buck, so to speak:

const fs = require("fs");
const UglifyJS = require("uglify-js");

const pureFuncs = [ "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9"];

const elmCode = fs.readFileSync("elm.js", "utf8");

const result = UglifyJS.minify(elmCode, {
  compress: {
    // Turn off all options.
    ...Object.fromEntries(
      Object.entries(UglifyJS.default_options().compress).map(
        ([key, value]) => [key, value === true ? false : value]
      )
    ),
    // The options with a lot of bang for the buck:
    pure_funcs: pureFuncs,
    pure_getters: true,
    join_vars: true,
    conditionals: true,
    unused: true,
  },
  mangle: {
    reserved: pureFuncs,
  },
});

It’s easy to lose yourself in this, trying to find the perfect tradeoff. It’s also very hard to decide how much time to spend and how much space to “waste!” But after I realized that esbuild is stable enough to use, it doesn’t matter anymore. It outperforms any UglifyJS option combo you can think of, both in terms of size and time. (Unless you look at the gzipped size…)

esbuild

esbuild is easy to use:

esbuild elm.js --minify --target=es5 --outfile=elm.min.js

The elm make output is ES5. --target=es5 makes sure that esbuild doesn’t “upgrade” function to =>.

Using esbuild’s JS API (also with pure functions specified – I’m not sure if that actually makes any difference):

const fs = require("fs");
const esbuild = require("esbuild");

const pureFuncs = [ "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9"];

const elmCode = fs.readFileSync("elm.js", "utf8");

const result = esbuild.transformSync(elmCode, {
  minify: true,
  pure: pureFuncs,
  target: "es5",
});

esbuild trick

esbuild only removes unused functions at the top level. elm make wraps everything in an “IIFE” (immediately invoked function expression):

(function (scope) {
  // actual code
})(this);

By removing the IIFE esbuild can remove more code:

var scope = window;
// actual code

(this refers to window in this case, but unless I explicitly say window I couldn’t get esbuild to output what I want).

This is easier to do with JavaScript than command line tools:

const fs = require("fs");
const esbuild = require("esbuild");

const pureFuncs = [ "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9"];

const elmCode = fs.readFileSync("elm.js", "utf8");

// Remove IIFE.
const newCode =
  "var scope = window;" +
  elmCode.slice(elmCode.indexOf("{") + 1, elmCode.lastIndexOf("}"));

const result = esbuild.transformSync(elmCode, {
  minify: true,
  pure: pureFuncs,
  target: "es5",
  // This enables top level minification, and re-adds an IIFE.
  format: "iife",
});

(I’ve run the final output in the browser. It works just fine.)

UglifyJS plus esbuild

UglifyJS produces smaller code than esbuild simply because it has more optimization tricks. By only enabling the UglifyJS stuff that esbuild doesn’t do and then running esbuild, we can get the same code size – actually even better! – but in less time.

There’s no need for the IIFE trick when running UglifyJS first.

const fs = require("fs");
const UglifyJS = require("uglify-js");
const esbuild = require("esbuild");

const pureFuncs = [ "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9"];

const elmCode = fs.readFileSync("elm.js", "utf8");

const result = UglifyJS.minify(elmCode, {
  compress: {
    // Disable all options.
    ...Object.fromEntries(
      Object.entries(UglifyJS.default_options().compress).map(
        ([key, value]) => [key, value === true ? false : value]
      )
    ),
    // These are the options that actually resulted in smaller output.
    pure_funcs: pureFuncs,
    pure_getters: true,
    strings: true,
    sequences: true,
    merge_vars: true,
    switches: true,
    dead_code: true,
    if_return: true,
    inline: true,
    join_vars: true,
    reduce_vars: true,
    conditionals: true,
    collapse_vars: true,
    unused: true,
  },
  mangle: false,
});

if (result.error !== undefined) {
  throw result.error;
}

const result2 = esbuild.transformSync(result.code, {
  minify: true,
  target: "es5",
});

Here’s a command line approximation:

uglifyjs elm.js --compress 'pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters' | esbuild --minify --target=es5 > elm.min.js

Time/Size comparison table

Here’s a comparison of commands, ran on a pretty large Elm app at work. The times fluctuate with a couple of tenths of a second every time I run them, and sometimes more if I let a day pass between.

Use the below table as a rough size-time tradeoff guide, but measure yourself too.

  • Computer: 2019 MacBook Pro (2.6 GHz 6-core Intel Core i7, 16 GB RAM)
  • UglifyJS version: 3.14.1
  • esbuild version: 0.12.18
  • Node.js version: 16.1.0
Command Time Size Gzip
elm make --optimize (for reference) 0.6s 3.23MiB 490KiB
UglifyJS, only whitespace and comments removal 1.1s 2.61MiB 409KiB
Above, plus shortened variable names (mangle) 2.2s 897KiB 260KiB
Elm Guide command (run UglifyJS twice: compress then mangle) 12.5s 753KiB 236KiB
Tweaked Elm Guide command (run UglifyJS just once with mangle.reserved) 12.0s 748KiB 235KiB
Above, plus passes: 2 19.4s 739KiB 234KiB
Above, plus passes: 3 25.4s 738KiB 233KiB
UglifyJS with Elm Guide compress (but no mangle) plus esbuild 11.3s 730KiB 238KiB
Parts of UglifyJS plus esbuild 8.8s 732KiB 236KiB
Above, but with the slow option reduce_vars disabled 6.8s 739KiB 238KiB
Just esbuild 0.3s 813KiB 257KiB
Just esbuild with the IIFE trick 0.3s 798KiB 250KiB
UglifyJS tradeoff 4.3s 806KiB 244KiB
UglifyJS with all compress options off (measure compress overhead) 3.2s 895KiB 260KiB

The last line shows how just enabling the compress AST pass takes a second. (UglifyJS seems to remove braces after if, for and while (where possible) whenever compress is enabled – that’s why the mangle-only run is ~2KiB larger.)

It’s interesting how in 2.2s UglifyJS can remove 2.3MiB. Almost double that time (to 4.3s) and you only remove another 91KiB. Quadruple that (and then some, to 19.4s) to remove a final 70KiB. And at the same time esbuild comes really close in just 0.3s. So:

  • If you are plagued by long build times, there’s just one option: esbuild. And it still does a great job! Especially with the IIFE trick.
  • If you need the absolute smallest code size, you have to use UglifyJS. Best in combination with esbuild (faster and (possibly) slightly smaller).

GitHub Gist of this post, with more tables and scripts: Minify Elm code · GitHub

47 Likes

Wow, thank you for posting this! I’m working on something where asset size matters a lot and you just saved me a ton of research :smiley:

2 Likes

Thanks for taking the time to do those concrete experimentations! I think the “esbuild with the IIFE trick” option might be a sweet spot in terms of size and compression time, especially for larger projects. We should apply your research to improve create-elm-app. Their build script also uses UglifyJS and largely follows the Elm guide practice. Since esbuild is still a new tool, I think create-elm-app can introduce an experimental option to optimize with esbuild. Once we solve the issues that arises, we can change the default from UglifyJS to esbuild. The same can be said of Babel. Though I’m a little confused about the functionality of esbuild. Can it replace Babel?

1 Like

There’s a webpack loader that allows running esbuild inside webpack, and it’s possible to only run it as a minifier: GitHub - privatenumber/esbuild-loader: ⚡️ Speed up your Webpack build with esbuild. Not sure if it’s possible to combine that with UglifyJS (or Terser – uglifyjs-webpack-plugin (which create-elm-app seems to use) is deprecated in favor of terser-webpack-plugin, which does seem to allow you to plug the actual minifier, though: GitHub - webpack-contrib/terser-webpack-plugin: Terser Plugin)

esbuild can:

  • Remove TypeScript syntax (but not typecheck). This partly replaces tsc.
  • Turn modern JS into older JS, like => to function. This replaces Babel to a large extent.
  • Follow imports and turn all your JS files into one. This replaces the bundling parts of webpack, Parcel and Rollup.
  • Minify JS. This replaces UglifyJS and Terser to a large extent.
  • And more!

webpack and Babel are more like “You can do anything!” while esbuild is more like “All standard things are supported – fast”.

2 Likes

@supermario asked about elm-optimize-level-2. Here’s the above table again, but with more columns. The ones with a 2 are run on the output of elm-optimize-level-2 on the same Elm app (the other columns are the same as before).

Command Time Time 2 Size Size 2 Gzip Gzip 2
elm make --optimize/elm-optimize-level-2 (for reference) 0.6s 5.3s 3.23MiB 3.91MiB 490KiB 500KiB
UglifyJS, only whitespace and comments removal 1.1s 1.1s 2.61MiB 2.89MiB 409KiB 432KiB
Above, plus shortened variable names (mangle) 2.2s 2.3s 897KiB 903KiB 260KiB 271KiB
Elm Guide command (run UglifyJS twice: compress then mangle) 12.5s 13.9s 753KiB 734KiB 236KiB 235KiB
Tweaked Elm Guide command (run UglifyJS just once with mangle.reserved) 12.0s 13.1s 748KiB 723KiB 235KiB 234KiB
Above, plus passes: 2 19.4s 20.8s 739KiB 704KiB 234KiB 233KiB
Above, plus passes: 3 25.4s 28.3s 738KiB 701KiB 233KiB 232KiB
UglifyJS with Elm Guide compress (but no mangle) plus esbuild 11.3s 12.1s 730KiB 721KiB 238KiB 238KiB
Parts of UglifyJS plus esbuild 8.8s 9.8s 732KiB 724KiB 236KiB 236KiB
Above, but with the slow option reduce_vars disabled 6.8s 6.8s 739KiB 726KiB 238KiB 238KiB
Just esbuild 0.3s 0.3s 813KiB 823KiB 257KiB 267KiB
Just esbuild with the IIFE trick 0.3s 0.3s 798KiB 799KiB 250KiB 259KiB
UglifyJS tradeoff 4.3s 4.5s 806KiB 784KiB 244KiB 245KiB
UglifyJS with all compress options off (measure compress overhead) 3.2s 3.4s 895KiB 901KiB 260KiB 271KiB

Some notes:

  • elm-optimize-level-2 produces larger output than elm make --optimize: 0.68MiB. But if I run prettier on both of them, the difference shrinks to 0.33MiB. This is because elm-optimize-level-2 uses 4 space indentation instead of 2 and other stylistic differences.
  • UglifyJS alone is able to minify the output of elm-optimize-level-2 better than the output of elm make --optimize, even though the input is larger! The new size record is 701KiB – 29KiB smaller than the previous record of 730KiB by UglifyJS plus esbuild. However, you need passes: 2 or passes: 3 for this, which is really time consuming – especially given that you need to spend 5 seconds on elm-optimize-level-2 as well.
  • As said, UglifyJS plus esbuild is not the winner size wise this time. But the combo still wins over UglifyJS with passes: 1.
  • Even though UglifyJS set a new size record, gzip wise there’s little to no difference.
  • I haven’t verified that the bundles actually execute in the browser this time.
7 Likes

Exciting news about SWC and their minifier:

4 Likes

Hello @lydell, thank you for this great writeup.

Similar to the IIFE trick, I would like to explore transpiling the code to an ESM and then test different minify settings.

Would you be open to run a few scripts on your machine if I supplied them?
Then the duration would be comparable in the test tables.

Sure! That would be interesting.

Thanks @lydell - this is super helpful!

Currently using Parcel which I already thought was “quick”, but this really helps contextualize things!

2 Likes

I tested a few more tools, and also investigated ESModules, but there is nothing really new compared to the initial post.
I compiled two separate Elm apps into one bundle and then compared the results, as I suspected that might make a difference.

This is my summary:

  • For the smallest file size using a single tool, you can either pick UglifyJS or Google’s closure compiler. Both take about the same time (9.5-10s in my case).
  • But you can achieve the same size result by running just parts of UglifyJS and then esbuild in ~40% of the time (~3.5s) as @lydell described.
  • Esbuild alone produces ~10% more code than UglifyJS, but it needs only ~2% of the time (~0.1s) when directly writing to a file.
  • SWC is comparable to Esbuild in compilation time, but not yet in compression ratio (16% bigger). They are working on reaching parity to terser.
  • Terser produces ~4% more code than UglifyJS but only needs 60% of the time.

About using an ESModule
I think it makes most sense to compress the IIFE that the Elm compiler generates and then turn it into an esm instead of first transforming it and then minifying it.
For this one can simply replace (function(r){"use strict"; at the beginning with const r={};, and then replace })(this); at the end with export const Elm = r.Elm;.
This means that we replace 35 chars with 36, so the size will be pretty much the same.
Note: Sometimes, the IIFE is negated. but replacing everything until the first “;” works well.


To compare the different tools on your own codebase you can check out this repo and then run the compression comparison on arbitrary Elm Browser apps like ❯ npx ts-eager compare-minify-elm-esm src/*.elm.


tools used version
@swc/core 1.2.84
esbuild 0.12.22
google-closure-compiler 20210808.0.0
terser 5.7.2
uglify-js 3.14.1

I also stored my results on this page