Transpiled for-of Loops are Bad for the Client

Or... How we increased browser compatibility and reduced our JavaScript bundle 10% by using a different kind of for-loop.

October 26, 2017

This is the story of picking the right JavaScript for-loop. On a client project Julian Jones and I casually wrote heaps of NodeList.prototype.forEach() for the DOM manipulation in our Pattern Library.

const inputs = document.querySelectorAll('input, select, textarea');

inputs.forEach(function(input){
    console.log(input);
});

forEach() on a NodeList is very new. It didn’t even work in Edge until this summer so I knew it would fail in older browsers. Our strategy was to use Babel to fix this for us. Unfortunately, we learned that Babel doesn’t transpile forEach() statements. Hm. Twitter apparently thinks you’re an idiot if you didn’t know this about Babel, so we set off to look for a workaround.

for...of Loops to the Rescue!

After some initial exploration, we decided to switch to for...of-loops instead.

const inputs = document.querySelectorAll('input, select, textarea');

for(let input of inputs) {
    console.log(input);
}

The syntactic sugar of the auto-assigned variables gave this the edge over writing traditional for-loops. Running it through the Babel REPL tool gave us some confidence that it would transpile giving us the backwards-compat we needed.

'use strict';

var inputs = document.querySelectorAll('input, select, textarea');

var _iteratorNormalCompletion = true;
var _didIteratorError = false;
var _iteratorError = undefined;

try {
    for (var _iterator = inputs[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) {
        var input = _step.value;

        console.log(input);
    }
} catch (err) {
    _didIteratorError = true;
    _iteratorError = err;
} finally {
    try {
        if (!_iteratorNormalCompletion && _iterator.return) {
            _iterator.return();
        }
    } finally {
        if (_didIteratorError) {
            throw _iteratorError;
        }
    }
}

The transformed output of for...of-loops is an ugly try {} catch {} finally { try {} finally {} } statement, but whatever, it solves the problem and computers be computers, right?!

However. This doesn’t work in IE11. Surprise! 'Symbol' is undefined.

Symbol.iterator is a relatively new ES6 language feature that doesn’t exist in IE11. This error was a bit unexpected in that even with an IE > 8 flag, Babel only takes care of the syntax not the feature support. To put it another way, I was surprised the backwards-compat machine didn’t take care of the backwards-compat.

Ironically, browser support for for...of is greater than Symbol.iterator support.

Chalk it up again to my ignorance of how Babel works, the feedback from Twitter was that I needed to polyfill Symbol. First stop, I looked at babel-polyfill.js but was surprised to see it is 259kb unminified/uncompressed. I’m sure it does a lot, but the entire JavaScript bundle for the whole site is only 93kb unminified/uncompressed. It’s hard for me to recommed a +300% increase.

Then it was suggested to use Babel with preset-env and babel-polyfill which if configured right (heh) injects an import statement for es6.symbol.js which Webpack then takes and bundles together for the client. Once that’s all configured and npm install‘d, we finally have a for-loop!

I started getting uncomfortable with my options.

Re-looking at the transpiled output, we wondered if traditional for-loops were a better way to avoid the try-catch mess. Fortunately, we decided to retrace our steps and re-evaluate NodeList.prototype.forEach()

NodeList.prototype.forEach() for the Win!

forEach() was our original choice and has the syntactic sugar we craved. We were surprised to discover the NodeList.prototype.forEach() polyfill is only ~200b. New browsers leverage the new language features as well. This lightweight solution seemed too good to be true so we started by rolling back a few for-loops to see the impact. After each save, we noticed the bundle size dropping by ~700b for each for-loop we reverted.

All said and done, we converted 15 for-loops to NodeList.prototype.forEach() and shaved off 9kb from our unminified/uncompressed bundle (10% reduction) and increased our browser compatibility back to IE9.

Conclusion: Transpiled for...of Loops are Bad for the Client

This leads me to my link-bait title conclusion; I think for...of loops being run through Babel and served to the client is an anti-pattern. Transforming adds 658b of try-catch code per for-loop. With something as foundational as a for-loop, this adds up to a not insignificant amount of non-DRY code delivered to the client. The Babel fallback uses Symbol.iterator which doesn’t increase compatibility in older browsers and leads to more polyfilling. Newer browsers don’t leverage new language features, they get the transpiled try-catch statements.

If you live in a ES6+-only Browsertopia, then for...of loops are probably great but I’d question the need to transpile that language feature. There are advantages of ES6 for-of loops, but that seems limited to when you want to break; and exit a loop early instead of looping through every item. For client-side DOM manipulation, I typically find myself looping through every element I have querySelected.

I don’t enjoy living in absolutes, but I think it’s important to find lightest possible solution that meets your needs. In this instance NodeList.prototype.forEach() allowed us to write cleaner code and output less code and I believe that it is a better approach on the client.

Bonus conclusion: What the hell are we doing here?

This story is just a personal reminder for me to repeatedly question what our tools spit out. I don’t want to be the neophobe in the room but I sometimes wonder if we’re living in a collective delusion that the current toolchain is great when it’s really just morbidly complex. More JavaScript to fix JavaScript concerns the hell out of me. Especially given that JS on the average mobile client is an expensive resource.

I’m not sure this is an issue with Babel per-se, but maybe a more meta topic of the cost of polyfilling. In fact, a quick look at my codebase tells me that picturefill makes up ~55% (47kb unminified/uncompressed) of the total JavaScript bundle – and we only use it for the logo!

All of these “problems” minify and gzip away. Sure, sure. Except that minified picturefill is still ~33% of the total bundle. So it’s still an issue. But the issue isn’t size alone, it’s that a majority of my codebase only does one tiny task and existed unquestioned1.

I am responsible for the code that goes into the machine, I do not want to shirk the responsibility of what comes out. Blind faith in tools to fix our problems is a risky choice. Maybe “risky” is the wrong word, but it certainly seems that we move the cost of our compromises to the client and we, speaking from personal experience, rarely inspect the results.

  1. Annnnd you know that ya boy is working on a ~3kb sizes-less version of a <picture> polyfill that minifies down to ~1kb. Currently works in IE9-IE11.