These days, frameworks are all the rage. But sometimes we still turn to DOM manipulation with vanilla JavaScript. The typical pattern for DOM manipulation using vanilla JavaScript is something like this: first, you need to get a reference to the DOM element you want to work with. This is often done with functions like getElementById
or querySelector
.
Once you have a reference to an element, you might want to do something like:
In this article, we’ll explore the efficiency of various DOM manipulation methods and compare their performance. While some approaches may be faster than others, this might not matter all that much for your application. For example, if one approach can perform one million operations per second but another can only perform 700,000 operations per second, this is essentially going to provide the same user experience in a typical HTML document or app.
If you’re interested in more information regarding DOM manipulation, check out our other article, Exploring essential DOM methods for frontend development, which covers the basics of selecting elements, event handling, and more.
That being said, let’s dive in and start looking at some DOM manipulation patterns. This article has a companion GitHub repository that demonstrates some of the performance differences we’ll discuss.
A common way to get references to DOM elements in modern JavaScript is to use the querySelector
or querySelectorAll
functions. These take a CSS selector string and will return the first element (in the case of querySelector
), or all elements (in the case of querySelectorAll
), that match the selector you provide.
Before diving into the performance differences between selectors, it’s important to understand how the browser matches elements to selectors.
The browser matches elements by processing a selector from right to left. This means that the less specific the rightmost parts of the selector are, the more DOM elements have to be evaluated, and the longer the query will take.
Consider this selector, which matches all elements that are descendants of a list item:
li *
The asterisk selector means it has to collect all elements in the document, and then find any that have a li
as an ancestor. This has a performance cost, as all elements must be examined. Instead, suppose you know that the child elements of the li
elements you’re interested in are links. That way, you can make your selector more specific:
li a
Now, the browser will only consider all the a
elements in the document, instead of all elements. This selector will likely be more performant, especially in larger documents.
Generally speaking, you can make your selector more performant by making the rightmost parts of the selector as specific as possible.
querySelector
vs. querySelectorAll
When choosing between querySelector
and querySelectorAll
, it’s important to consider their behavior. querySelector
will stop once it finds a matching element, whereas querySelectorAll
will perform an exhaustive search of the entire document.
If you are looking for a specific element that isn’t the first matched element, you might use querySelectorAll
and access the index of the element you want.
This works, but it would be more efficient to refine your search and use a more specific selector to get the exact element you want by using querySelector
instead. This could be done by adding an ID or class to the desired element, or by using a pseudo-class like :nth-child
to select the element you want.
For example, these two queries both select the second item in a list, but the second query will be more performant because it only gets one element:
document.querySelectorAll('.list li')[1]
document.querySelector('.list li:nth-child(2)')
You can always call document.querySelector
to search the document, but you can also call querySelector
on any DOM element once you have a reference to it. Instead of searching the entire document, this will search just that element’s subtree. This can cut down on the scope of your search and may yield a performance benefit.
If you will be performing operations repeatedly on an element throughout the lifetime of your app, you might want to cache the element returned by querySelector
. This is as simple as saving it to a variable you can access later. This way, you only need to query the document once for this element.
A word of caution: if you have cached a reference to an element and you later remove the element from the DOM, make sure you clear the cached value. Otherwise, the element will be out of the DOM but there will still be a reference to it, and it will become a detached element.
Detached elements are not garbage collected, and event listeners for these elements are not removed. Detached elements are a common source of memory leaks in JavaScript applications.
The following are some patterns you can follow to make your event handling more efficient.
Event delegation is a pattern that can reduce the number of active event listeners you have in your document, which can be more efficient. Suppose you have several buttons under a common parent element. Instead of adding a click listener to each individual button, you can add a single click listener to the parent element.
This is possible because events propagate up the DOM hierarchy. If you click on one of the buttons, the click event happens on the button, and then it propagates, or bubbles, up to the parent element. Here, our click listener will fire and you can determine what action to take based on which button was clicked.
Consider the following HTML for a list containing buttons, one for each user. Each button has a data-userid
attribute to indicate which user this is a button for:
<ul class="user-list"> <li><button data-userid="user1">User 1</button></li> <li><button data-userid="user2">User 2</button></li> </ul>
You can apply event delegation as follows:
const list = document.querySelector('#user-list'); list.addEventListener('click', event => { if (event.target.dataset.userid) { console.log(`Clicked user: ${event.target.dataset.userid}`); } });
There’s only one event listener, added to the <ul>
element. Whenever anything inside this list is clicked, the listener will fire. You can determine if a user button was clicked by looking for the userid
data attribute.
Some events can be fired many times in a short period. Because of this, depending on what you are doing in the event handler, you could run into performance issues.
You can throttle a function by making sure it only runs once within a given period. For example, if you want to perform some calculations on the scroll
event, you might throttle your event handler so it only runs once every 500 milliseconds.
You can also debounce an event handler by making sure it only fires after a certain period of time where the event didn’t fire. Consider a text field that sends an API request to search as you type. If you did this on every keydown
event, you could hit the server with many requests in rapid succession. By debouncing this event handler, you can be sure you don’t send the request until the user has stopped typing for a given amount of time, say, 500 milliseconds.
Read more about when to debounce or throttle in this React-specific guide.
One of the most common types of DOM manipulation is getting or setting the text displayed inside an element. Like most DOM operations, there are multiple ways to do this, each with its own performance considerations.
innerText
DOM elements have an innerText
property. You can read or write the text of an element by modifying this property. This works as expected, but has a slight performance penalty. The innerText
property deals with text as it is rendered on screen. This means it has to take styles into account before returning the text, as CSS styles may hide some of the text content.
This is more of an issue if you use innerText
on a parent element with multiple child elements that contain text. The styles of each child element need to be taken into account before returning the final text value.
textContent
You can also manipulate text with an element’s textContent
property. This property works differently than innerText
in that it returns all text in the markup, regardless of its visibility status. This means that, unlike innerText
, it does not take styles into account.
This difference is good to know, but in practice, you probably won’t notice a performance difference in a typical page or app unless there are a large amount of elements or styles on the page.
Another common DOM operation is to add child content to an element. Like most other things in the DOM, there are also multiple ways to do this.
innerHTML
To add child content to an element, you can set its innerHTML
property, providing an HTML string. The browser will parse this string into HTML elements, set their content, and set them as the child element(s) of the parent element.
Here’s an example of using innerHTML
to add a new list item to an unordered list:
const list = document.querySelector('ul.myList'); list.innerHTML += '<li>New List Item</li>';
While this makes it trivial to set child content, using the innerHTML
property has several disadvantages:
innerHTML
property, manipulate the returned string, and then set it back as the new innerHTML
innerHTML
can open you up to attacks such as cross-site scripting (XSS)The other way to add child content is to create elements by calling document.createElement
, then adding them to an element’s child content using methods like appendChild
or insertBefore
.
Here’s an example of creating and appending a new list item to an unordered list:
const list = document.querySelector('ul.myList'); const item = document.createElement('li'); item.textContent = 'New List Item'; list.appendChild(item);
Certain operations can trigger a reflow in a document. A reflow is when the browser engine recalculates style and layout data for all or part of the document. This can be an expensive operation and may hurt your page’s performance, depending on how often it’s done.
While it’s obvious that making changes to an element’s layout or style can trigger reflows, you might not know that even read-only operations can trigger reflows. For example, you might call getBoundingClientRect
on an element to get its dimensions and position. This, however, should be used with caution because it will trigger a reflow as the browser makes sure that all style changes are applied before returning the bounding rectangle.
Even something as simple as reading an element’s offsetWidth
property causes a reflow.
Performing many DOM operations that trigger reflows in a short period can lead to layout thrashing and recalculating styles and layouts multiple times, all of which can hurt your page’s performance. This can happen, for example, if you’re reading and writing DOM properties in a loop.
Here’s a bit of a contrived example that illustrates the problem:
const elements = document.querySelectorAll('.item'); for (let i = 0; i < elements.length; i++) { elements[i].style.width = `${elements[i].offsetWidth + 10}px`; }
This code is looping over a collection of matched elements. For each element, it’s setting an inline CSS style. Doing this in rapid succession, like is being done here, can lead to layout thrashing. The browser is continually reading offsetWidth
, which makes the browser recalculate the layout. Then it is immediately writing to the style
property.
To avoid this problem, you can perform all of your read operations first, followed by all of your write operations. This prevents the back-and-forth read/write operations that lead to layout thrashing.
With the above example, you could first read all of the widths, then use those widths to update all of the styles:
const widths = []; // Do all the reads together, first. for (let i = 0; i < elements.length; i++) { widths[i] = elements[i].offsetWidth; } // Then do all the writes, using the previously calculated values. for (let i = 0; i < elements.length; i++) { elements[i].style.width = `${widths[i]}px`; }
In this article, we talked about some ways you can be more efficient with DOM manipulation, both from a memory and CPU standpoint.
While one practice may be more efficient, in terms of performance metrics, this may not matter all that much. If one operation takes two milliseconds and another takes 20 milliseconds, both will feel instantaneous to the user.
So while the patterns in this article are good to follow, don’t sacrifice functionality or code simplicity if you don’t actually have a performance problem.
That being said, there are certainly cases where every last bit of performance does matter — such as when performing animations. If performance issues are encountered while you’re running an animation, the user will likely see some jankiness in the animation instead of a smooth, 60 frames per second, animation.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowEfficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
Design React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
Fix sticky positioning issues in CSS, from missing offsets to overflow conflicts in flex, grid, and container height constraints.