Monday, May 19, 2014

innerHTML vs appendNode vs DocumentFragment - Optimizing bulk DOM operations for mobile

There are cases when you want to actually create and show a large chunk of new HTML from javascript. The simplest one is when you are loading a wholly new view in a single-page app. Should you worry about how to implement that?

TL;DR 1) on early-2014 mobile browsers the raw method used to create and show a couple of thousand nodes doesn't matter. 2) with more than a couple of thousand takes too long anyway. 3) the game has changed significantly in the past couple of years. 4) you must measure the right thing (create and show).

innerHTML, appendNode or DocumentFragment?

It's not easy to get good answers by Googling:

The 1st Google result (by Andrew Hedges) for 'innerhtml speed' heavily recommends using innerHTML (instead of DOM operations) on iPhone. To be fair, it's talking about iOS 2.2. It also does specifically note, that you should measure not only the javascript execution time but the time it takes to show the results.

The 1st Google result (by John Resig) for 'documentfragment' tells us that DocumentFragments give us a 2-3x performance improvement over direct appendNode(). This too is old, from 2008, and talks about desktop browsers.

For 'innerhtml vs dom speed' the 1st and 2nd results are Andrew Hedges's pages. The 3rd result is a stackoverflow discussion, where the highest-ranking answer does say that it shouldn't matter, but does also say that for large numbers of elements you should use DocumentFragments and links to John Resig's page.

If you measure just javascript, DOM methods are faster

(Link to test code, I'll explain the test setup in detail shortly.)

But when you factor in rendering time, the difference mostly disappears

Getting your HTML on front of the user requires not only the new DOM, but calculating styles, calculating layout and painting the results on-screen. (If this is unfamiliar ground, Tali Garsiel and Paul Irish wrote an excellent explanation called 'How Browsers Work'). The recalculations are not done synchronously as you manipulate the DOM, but in bulk, later, after your javascript is done.

With total rendering time, all variants come in within 10-15%. The results look similar, but aren't quite the same. On Android the absolute difference between DOM manipulation and innerHTML actually grows from 50ms to 80ms (I don't know why, maybe some calculations can be shared when reusing DOM nodes via cloneNode?), but on iPhone the absolute difference goes down from 70ms to 30ms. The Webkit on the iPhone is actually doing the style recalculation (and render tree creation) synchronously when using innerHTML (Blink recentishly changed innerHTML to use the same mechanism as appendNode). Timings below, the top result is with innerHTML and shows no Style calculation time, whereas the bottom result is with appendNode and shows 50ms spent in style calculation:

Commercial break: The Mobile HTML5 Rendering Profiler

The measurements in this blog post have been created with (and exported into a spreadsheet from) the Mobile HTML5 Rendering Profiler. The Profiler lets you test the speed of your whole DOM manipulation chain: javascript, style calculation, layout and paint.

You can measure some of the rendering time by delaying your 'finished' time to a setTimeout() as PPK pointed out in 2009.. That's definitely better than just measuring javascript execution time. The Profiler can also measure painting times, split recalculation to styles and layout; and it runs your tests several times to measure whether changes are significant or not (how much variance there is from run to run).

The Profiler will set you back 55 EUR (+ VAT), but do first download the 7-day trial and see what makes your app go fast (or slow...).

With enough nodes, there are differences

Firstly: you shouldn't be doing this. The times are in the seconds: your users aren't going to hang around for seconds. However, these results tell us some interesting things about the browser internals :-)

So with 10000 sibling nodes, innerHTML is suddenly faster on the iPhone. What gives? It's the interaction of two different properties of the WebKit used in the browser at the moment (iOS 7.1): 1) innerHTML calculates styles synchronously, appendNode lazily and 2) the lazy calculation is O(n^2) in the number of siblings. This is motivation behind the 'appendNode, nested' variant: that one cuts down on the number of sibling nodes by inserting some intermediate nodes in the tree (NB: this does not result in the same DOM tree, but may well work for you, depending on your styling and code).

On Android we finally see DocumentFragment making headway. I don't really know why.

Some conclusions

Browsers are trying to become better and better in dynamic manipulation of the content. The bugs and merges linked to above show how the internal mechanisms for the different APIs (innerHTML, appendNode, DocumentFragment) are converging, so it won't matter what you use. They also show that at any single point in time there might be issues with any of them...

I'm somewhat surprised that Chrome on the Samsung S5 is consistently about 100% slower than Mobile Safari on the iPhone 5 (not iPhone5S).

There are a lot of things these tests don't tell you anything about: the effect of more or less complex styles, how does modification work compared to creation of new nodes etc.

Test setup details

The devices used are an iPhone 5 running iOS 7.1 and a Samsung Galaxy S5 running Androd 4.4.2 and Chrome 34.

You can just go and read the code if you want to.

The test code creates a given number of items like:

<div ng-repeat="item in items" class="item row">
  <div class="name col-xs-6" ng-bind="item.name">item {{i}}</div>
  <div class="description col-xs-6" ng-bind="item.description"
    >A description of item {{i}} - Lorem ipsum dolor sit amet,
     consectetur adipisicing elit, sed do eiusmod tempor
     incididunt ut labore et dolore magna aliqua</div>
</div>

It's not using AngularJS, the attributes are there to make sure some other test comparisons are apples-to-apples. The {{i}} are replaced with actual item indices. Here are the code variants (edited for line length and brevity):

/* cloneNode */
function setHtmlCloneNodeDom() {
  var i = 1;
  var div = document.createElement("div");
  div.setAttribute("ng-repeat", "item in items");
  div.setAttribute("class", "item row");
  div.innerHTML = '' 
    + '  <div class="name col-xs-6" ng-bind="item.name">
    +     '</div>'
    + '  <div class="description col-xs-6" ' + 
    +     'ng-bind="item.description">A description of item '
    +     i + ' - ' + longtext + '</div>'
  ;
  var item = div.getElementsByTagName("div")[0];
  var description = div.getElementsByTagName("div")[1];
  for (i = 0; i < COUNT; i++) { 
    item.textContent = "item " + i;
    description.textContent = "A description of item " + i +
        " - " + longtext;
    container.appendChild(div.cloneNode(true));
  } 
}

/* innerHTML */
function setHtmlInnerHtml() {
  var html = [];
  for (var i = 0; i < COUNT; i++) {
    html.push('<div ng-repeat="item in items" class="item row">');
    html.push('  <div class="name col-xs-6" ng-bind="item.name">');
    html.push('item ');
    html.push(i.toString()); html.push('</div>');
    html.push('  <div class="description col-xs-6" ');
    html.push('ng-bind="item.description">A description of item ');
    html.push(i.toString()); html.push(' - ');
    html.push(longtext); html.push('</div>');
    html.push('</div>');
  }
  container.innerHTML = html.join("");
}

/* DocumentFragment */
function setHtmlCloneNodeFragment() {
  var fragment = document.createDocumentFragment();
  /* like setHtmlCloneNodeDom */
  for (i = 0; i < COUNT; i++) {
    /* like setHtmlCloneNodeDom */
    fragment.appendChild(div.cloneNode(true));
  }
  container.appendChild(fragment);
}

/* appendNode, nested */
function setHtmlCloneNodeTree() {
  /* like setHtmlCloneNodeDom */
  var each = COUNT / 50;
  var tree_templ = document.createElement("div");
  for (i = 0; i < COUNT; i++) {
    if (i % each === 0) {
      var tree = tree_templ.cloneNode();
      container.appendChild(tree);
    }
    /* like setHtmlCloneNodeDom */
    tree.appendChild(div.cloneNode(true));
  }
}

Tests were run with the Mobile HTML5 Profiler. Full data and graphs available as a Google Spreadsheet (exported via 'Copy TSV' from the Profiler).

2 comments:

Ian L. said...

Super helpful. Thank you!

kuus said...

Very interesting, thanks a lot