Rendering Large Lists in Vanilla JS:
List Virtualization

Rendering Large Lists in Vanilla JS: List Virtualization

ยท

5 min read

My friend appeared in a tech interview for a Frontend Developer role a few days ago.

Interviewer: How can you render large lists efficiently in JavaScript?
Friend: Rendering large lists meaning a large no. of DOM nodes present at once which will be heavier for the CPU. We can have a fixed no. of DOM nodes available for renders and when the user scrolls we can remove the DOM nodes that are not visible and append DOM nodes for new content.
Interviewer: Wrong!
Friend: ๐Ÿ˜ฒ๐Ÿค”

The problem with this approach

While he was right that rendering all the items at once will be heavy on the client CPU, he missed another crucial aspect of DOM nodes.

DOM nodes are heavy objects. Each node has numerous properties and methods.

A simple h1 node has so many methods and properties. Each of these methods has its own set of functionalities.

Naturally, manipulating DOM nodes frequently is a costly task. That's why React batches DOM updates and focuses on optimizing them. Removing and appending DOM nodes as the user scrolls would not be as heavy as rendering everything at once but it will still be heavy on the browser because of how fast the nodes would be created and destroyed. User scroll behavior is erratic. Sometimes they would scroll slowly and sometimes very fast, even dragging the scrollbar up or down in an instant. That's when the user would notice the gigantic lag. The main thread would be blocked trying to update all those nodes in an instant.

How can we optimize this further?

Instead of manipulating DOM nodes, we manipulate what's inside them. We replace their content (text, style, image src, etc.) as the user scrolls. Let's continue with the idea my friend had and improve on that.

We keep a fixed no. of DOM nodes visible always and as the user scrolls, we update the content of the nodes in the direction of the user scrolling.

Below I have implemented a demo of a working virtualized list in vanilla js.

Let's dissect the code

In the above pen, I am displaying a list of 10,000 comments with each user's username and avatar. You can imagine rendering 10,000 comments would decimate a not-so-powerful CPU. It wouldn't fare better for destroying and creating nodes either. The only reason your browser is not dead yet when you are interacting with the list is because of the virtualization.

1. Node pool

First, I am creating a pool of 30 nodes that would be used and reused to render a portion of the 10,000-long comments list. Here is the piece of code:

const nodePool = Array.from({ length: 30 }, (_, i) => {
    const div = document.createElement('div');
    div.className = 'list-item';
    div.innerHTML = '<img alt="Avatar"/><span class="username"></span><span class="comment"></span>';
    div.style.position = 'absolute';
    div.style.top = `${i * 40}px`; // Position the node based on its index
    list.appendChild(div);
    return div;
});

2. Register scroll event handler

The start and end variables are used to track the indices of the list items that are to be visible to the user. A scroll event handler is attached to the container to shift the visible items.

let start = 0;
let end = 30;

container.addEventListener('scroll', () => {
    const scrollTop = container.scrollTop;
    start = Math.floor(scrollTop / 40); // each item has a height of 40px
    end = start + 30;
    render();
});

3. The render function

Now we use the index markers (start and end) to get that slice of data and use the node pool we created earlier to update their content.

function render() {
    const visibleData = data.slice(start, Math.min(end, data.length));
    for (let i = 0; i < nodePool.length; i++) {
        const div = nodePool[i];
        if (i < visibleData.length) {
            const item = visibleData[i];
            div.querySelector('.username').textContent = item.username;
            div.querySelector('img').src = item.avatar;
            div.querySelector('.comment').textContent = item.comment;
            div.style.top = `${(start + i) * 40}px`; // Update the position of the node based on its index in the data array
        } else {
            div.style.display = 'none'; // Hide the node if it's not in the visible range
        }
    }
}

Note: we are also adjusting the top style to keep the list items visible that are supposed to be visible when the user scrolls and it is dependent on the start index.

4. The "placeholder" element

Since we only have 30 DOM nodes rendered, the user will quickly run out of scroll space. They wouldn't get the opportunity to scroll further because they would've reached the end of the scrollbar. The "placeholder" element resolves that issue.

The placeholder or 'dummy' element takes up the same amount of space as the data would if it were fully rendered. It ensures that the container's scrollable area reflects the total size of the data, even though not all of the data is rendered at once.

// Create a placeholder element with the total height of the data
const placeholder = document.createElement('div');
placeholder.style.height = `${data.length * 40}px`; // Each item has a height of 40px
list.appendChild(placeholder);

Afterthoughts

This particular problem is an excellent interview question. There is a high chance that you will be asked this question when you are interviewing for a front-end role. List virtualization is an important technique used in many products that you see in the wild (eg., YouTube live chat, Discord group chat, etc.). Make sure to practice this technique when preparing for your interviews.

ย