JavaScript DOM access methods speed benchmark

As a web-developer I had to work with with DOM tree uncountable number of times. Well, without all those shiny frameworks like Angular, React or Vue.js it’s actually impossible to do anything with a webpage without something like this:

document.querySelector(’#my-selector’).innerText = ‘Welcome to a new benchmark’;

In my experience I have met several most common approaches to accessing DOM elements:

  1. Using document.querySelector method
  2. Accessing elements by id (with getElementById method)
  3. Accessing elements by class names (e.g. document.getElementsByClassName(‘my_class’)[4])

Also, some front-end developers store received elements as variables, and some not.

After having quite intensive discussion about which method should be used and if there`s any sense in storing elements in your app as variables/constants I have finally decided to investigate this question a little and write this review.

So, join me and let’s found who’s the fastest.

First of all we should understand that a single selection operation is ultimately fast and there`s probably no sense in measuring single operation time. To notice any significant performance difference we will use the following function skeleton:

const ITERATION_NUM = 500000; // this number allows us to have execution time of 1-2 seconds  
  
const measureSomeMethod = () => {  
    for (let i = 0; i < ITERATION_NUM; i++) {  
        document.querySelector('#my-target').innerText = 'Benchmark test';  
    }  
};

And to measure performance we introduce a simple benchmark method:

const benchmark = (func, params) => {  
    const start = Date.now();  
    const result = func(...params);  
    const end = Date.now();  
    const executionTime = end - start  
    console.log(`Function took ${executionTime} ms to finish`);  
    **return** [result, executionTime]  
};

Now, we can move to the testing itself. For this example we will use such a simple elements tree:

<div class="parent">  
    <div class="child-1"></div>  
    <div class="child-1"></div>  
    <div class="child-1"></div>  
    <div class="child-1">  
        <div class="child-2"></div>  
        <div class="child-2"></div>  
        <div class="child-2"></div>  
        <div class="child-2"></div>  
        <div class="child-2"></div>  
        <div class="child-2"></div>  
        <div class="child-2"></div>  
        <div class="child-2">  
            <div class="child-3"></div>  
            <div class="child-3"></div>  
            <div class="child-3"></div>  
            <div class="child-3"></div>  
            <div class="child-3"></div>  
            <div class="child-3">  
                <div class="child-4" id="target"></div>  
            </div>  
        </div>  
    </div>  
</div>

Firstly, we are going to evaluate the most straight forward approach — query selector by classpath without any caching. Method should just render 500 00 updates (set innerText property of our element with some random text). It will look as following:


const ITERATION_NUM = 500000;

const accessByQuerySelector = (selector, text) => {  
    for (let i = 0; i < ITERATION_NUM; i++){  
        document.querySelector(selector).innerText = text;  
    }  
};

benchmark(accessByQuerySelector, ['.parent .child-1 .child-2 .child-3 .child-4', 'z tv cjhhb, Ybyf']);

This method tooks 1780 ms to complete on my MacBook Pro 2015. As for me, that’s a bit long. So, let’s try to improve this result.

The first idea I had was to use querySelector, but instead class path search element by id. So, my new benchmark was:

const ITERATION_NUM = 500000;

const accessByQuerySelector = (selector, text) => {  
    for (let i = 0; i < ITERATION_NUM; i++){  
        document.querySelector(selector).innerText = text;  
    }  
};

benchmark(accessByQuerySelector, ['#target', 'z tv cjhhb, Ybyf']);

In this version, it gives relatively better result — it takes only 1498 ms to render our updates, which is ~15% better than initial results. However, I believe we could do it better. Probably, much better.

What if we will look by class name using getElementsByClassName instead of querySelector?

Here it is:

const accessByClassName = (text) => {  
   for (let i = 0; i < ITERATION_NUM; i++){  
       const parent = document.getElementsByClassName('parent')[2];  
       const child1 = parent.getElementsByClassName('child-1')[3];  
       const child2 = child1.getElementsByClassName('child-2')[7];  
       const child3 = child2.getElementsByClassName('child-3')[5];  
       const child4 = child3.getElementsByClassName('child-4')[0];  
       child4.innerHTML = text;  
   }  
};

benchmark(accessByClassName, ['z tv cjhhb, Ybyf']);

This version seems to be faster — 1292 ms. Probably, that’s because in case of querySelector our browser had to do syntax parsing of the query itself, while here we did it for him by using document.getElementsByClassName method.

It was logically to think, that we can do the same trick with document.getElementById and that’s right.

const accessById = (id, text) => {  
    for (let i = 0; i < ITERATION_NUM; i++){  
        document.getElementById(id).innerText = text;  
    }  
};

benchmarkTime(accessById, ['target', 'z tv cjhhb, Ybyf']);

Due to DOM ids’ nature — hashmap it gives another performance improvement, now it takes only 1148 ms to render element changes.

Not bad, ya? This is approximately ~35.5% faster than the first approach (document.querySelector and class path). But can we do it better (hmm, probably that’s my new favorite question)? YES. We don’t cache our elements? Let’s see if it will help us.

We will test only two approaches with caching: naive first one (class path and document.querySelector) and the fastest (accessing by ids).

const ITERATION_NUM = 500000;  
  
const accessByIdAndCache = (id, text) => {  
    const element = document.getElementById(id);  
    for (let i = 0; i < ITERATION_NUM; i++){  
        element.innerText = text;  
    }  
};

const accessByQuerySelectorAndCache = (selector, text) => {  
    const element = document.querySelector(selector);  
    for (let i = 0; i < ITERATION_NUM; i++){  
        element.innerText = text;  
    }  
};

benchmark(accessByIdAndCache, ['target', 'z tv cjhhb, Ybyf']);  
benchmark(accessByQuerySelectorAndCache, ['.parent .child-1 .child-2 .child-3 .child-4', 'z tv cjhhb, Ybyf']);

And that gave us 784 ms and 870 ms… Query selector approach still loses, but that’s due to long time it needs to parse query (which is then cached by broswser). And this is ~55% and ~51% faster than my initial approach.

So, if you want to do DOM tree modifications fast — it might be usable to go as simple as caching and using old simple methods as accessign elements by id or class name.

To conclude and recap, let’s just look at the following comparison diagram of the methods listed above:

P.S. It’s important to understand, that this experiment has its limitations. For example, you can’t assign elements to variables if you need to rebuild DOM tree or set innerHTML to their parents somewhere else in the code. However, even regarding that, it’s quite important to keep in mind that way you access DOM elements might be another method to increase performance of your application.

Thank you for your time and have fun with coding.