DEV Community

Cover image for Extremely large lists or maximum list performance in Angular
Evgenii Grebennikov
Evgenii Grebennikov

Posted on

Extremely large lists or maximum list performance in Angular

Of course, we will talk about lists with virtualization.

A bit of theory

In the classic implementation, lists include all elements from the collection. In other words, those elements that are not visible to the user (are outside the viewport) are still present in the DOM tree. Now imagine if the list consists of, say, 1,000,000 elements, how will this affect performance and resource consumption? The answer is obvious: resource consumption will increase proportionally to the collection size and overall performance will decrease.

But fortunately for us, there are methods and algorithms that allow us to significantly speed up work with such huge lists.

Image demonstrating the concept of virtualized lists.
Image demonstrating the concept of virtualized lists.

The concept of list virtualization is very simple. The number of elements that fit within the viewport boundaries plus the buffer zone is calculated, the corresponding number of list element components is created, and then all manipulations will occur with the already created components. When a scroll event is received, methods for recalculating the elements included in the visualization list are called. The component that goes beyond the buffer area will be used again at the moment of list shifting. It will be assigned new coordinates and new data from the corresponding collection element will be transferred.

The process of assigning new coordinates and data of a collection element to a “free” component is called tracking. Tracking of list elements must be performed by a unique identifier, since a mapping of the collection element’s correspondence by a given identifier and a component of the screen area is created.

Tracking

Demonstration of component behavior in the DOM tree.

As you can see from the image above, the DOM tree does not have more elements than the calculated and specified buffer limit (i.e. the number of components is +- constant).

If we talk about virtualized lists with fixed element size, then this is a very simple problem with linear calculations.

Virtualized lists with content-adapted elements are not such a simple task. To calculate positions correctly, it is necessary to introduce the concept of a cache of “marked” and “unmarked” areas, then calculate metrics and correctly visualize components using a recursive “chain” update. But that’s not all. When scrolling, “problems” with positions will arise, to solve them, it will be necessary to introduce a delta offset, which is calculated based on the difference between the “marked” and “unmarked” areas.

For those of you who want to practice writing a solution like this, this is a great challenge!

And then we will talk about the tool ready for use.

NgVirtualList

ng-virtual-list logo

Link to npm package: ng-virtual-list

Next we will consider an example of creating a virtualized list with incoming and outgoing messages, grouped by, for example, date. The size of the collection will be 100,000 messages.

We will need Angular version from 14.x to 20.x. At the time of writing, the latest release version of the package is 20.0.12, which corresponds to Angular version 20.x.

Installing angular cli:

npm i -g @angular/cli@20
Enter fullscreen mode Exit fullscreen mode

Creating a new Angular project:

ng new [Your project name]
Enter fullscreen mode Exit fullscreen mode

Go to the directory of the created project:

cd [Your project name]
Enter fullscreen mode Exit fullscreen mode

Installing the ng-virtual-list tool:

npm i ng-virtual-list@20
Enter fullscreen mode Exit fullscreen mode

In src/app/app.component.ts we write the following code:

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { NgVirtualListComponent, IVirtualListCollection, IVirtualListStickyMap } from 'ng-virtual-list';

///////////// Generate a collection of messages with arbitrary text /////////////
const MAX_ITEMS = 100000;

const CHARS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];

const generateLetter = () => {
  return CHARS[Math.round(Math.random() * CHARS.length)];
}

const generateWord = () => {
  const length = 5 + Math.floor(Math.random() * 50), result = [];
  while (result.length < length) {
    result.push(generateLetter());
  }
  return `${result.join('')}`;
};

const generateText = () => {
  const length = 2 + Math.floor(Math.random() * 10), result = [];
  while (result.length < length) {
    result.push(generateWord());
  }
  let firstWord = '';
  for (let i = 0, l = result[0].length; i < l; i++) {
    const letter = result[0].charAt(i);
    firstWord += i === 0 ? letter.toUpperCase() : letter;
  }
  result[0] = firstWord;
  return `${result.join(' ')}.`;
};

const GROUP_DYNAMIC_ITEMS: IVirtualListCollection = [],
  GROUP_DYNAMIC_ITEMS_STICKY_MAP: IVirtualListStickyMap = {};

let groupDynamicIndex = 0;
for (let i = 0, l = MAX_ITEMS; i < l; i++) {
  const id = i + 1, type = i === 0 || Math.random() > .895 ? 'group-header' : 'item', incomType = Math.random() > .5 ? 'in' : 'out';
  if (type === 'group-header') {
    groupDynamicIndex++;
  }
  GROUP_DYNAMIC_ITEMS.push({ id, type, name: type === 'group-header' ? `Group ${groupDynamicIndex}` : `${id}. ${generateText()}`, incomType });
  GROUP_DYNAMIC_ITEMS_STICKY_MAP[id] = type === 'group-header' ? 1 : 0;
}
////////////////////////////////////////////////////////////////////////////////

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, NgVirtualListComponent],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss'
})
export class AppComponent {
  groupDynamicItems = GROUP_DYNAMIC_ITEMS;
  groupDynamicItemsStickyMap = GROUP_DYNAMIC_ITEMS_STICKY_MAP;
}
Enter fullscreen mode Exit fullscreen mode

Setting up the template src/app/App.component.html:

<div class="wrapper">
  <div class="vl-section block cap">
    <h1 class="center">ng-virtual-list demo</h1>
  </div>

  <div class="container">
    <ng-virtual-list #dynamicList class="list" [items]="groupDynamicItems" [itemRenderer]="groupItemRenderer"
      [itemsOffset]="10" [stickyMap]="groupDynamicItemsStickyMap" [dynamicSize]="true" [snap]="true"></ng-virtual-list>
  </div>

  <ng-template #groupItemRenderer let-data="data" let-config="config">
    @if (data) {
      @switch (data.type) {
        @case ("group-header") {
          <div class="list__group-container" [ngClass]="{'snapped': config.snapped, 'snapped-out': config.snappedOut}">
            <span>{{data.name}}</span>
          </div>
        }
        @default {
            @let isIn = data.incomType === 'in';
            @let isOut = data.incomType === 'out';
            @let class = {'in': isIn, 'out': isOut};
            <div class="list__container" [ngClass]="class" [ngStyle]="{}">
              <div class="message" [ngClass]="class">
                <span>{{data.name}}</span>
              </div>
            </div>
          }
        }
    }
  </ng-template>
</div>
Enter fullscreen mode Exit fullscreen mode

Edit src/app/app.component.scss:

// reset ng-virtual-list-item styles
.list::part(item) {
    background-color: unset;
}

.wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
    display: grid;
    grid-template-rows: auto 1fr;
    overflow: hidden;
    justify-content: center;
    width: 100%;
    height: 100%;
}

.vl-section {
    padding: 0 20px;
    margin-bottom: 24px;
    width: calc(100% - 40px);
    max-width: 640px;

    &.cap {
        margin-bottom: 24px;
    }

    &>h1 {
        background: -webkit-linear-gradient(0deg, #6f00e2 0%0%, #6f00e2 30%, #f90058 100%);
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
        font-size: 19px;
        font-weight: 800;
        text-transform: uppercase;
        margin: 0;
        padding: 0;
        letter-spacing: 0.5px;
        margin-bottom: 9px;

        &.center {
            width: 100%;
            text-align: center;
        }
    }

    &.block {
        padding: 20px 20px 4px;
    }
}

.container {
    display: grid;
    height: calc(100%);
    width: auto;
    min-width: 480px;
    overflow: hidden;
}

.list {
    background: linear-gradient(180deg, rgb(80, 42, 155) 0%, rgb(53, 147, 184) 100%);
    border-radius: 4px;

    &::part(scroller) {
        scroll-behavior: auto;
    }

    &__container {
        height: 100%;
        width: 100%;
        padding: 6px 20px;
        background-color: transparent;
        box-sizing: border-box;
        display: flex;
        align-items: center;

        &>* {
            height: auto;
        }

        &.in {
            justify-content: start;
        }

        &.out {
            justify-content: end;
        }

        .message {
            display: flex;
            max-width: 55%;
            background-color: rgb(210, 220, 221);
            border-radius: 12px;
            box-shadow: 1px 2px 8px 4px rgba(0, 0, 0, 0.075);
            border: 1px solid rgba(0, 0, 0, 0.1);
            padding: 10px 14px;
            word-break: break-all;

            &.in {
                border-bottom-left-radius: 0px;
            }

            &.out {
                border-bottom-right-radius: 0px;
            }
        }
    }

    &__group-container {

        &.snapped,
        &.snapped-out {
            background-color: rgb(80, 45, 156);
        }

        height: 100%;
        width: 100%;
        padding: 6px 12px;
        background-color: transparent;
        color: rgb(241 246 255);
        box-sizing: border-box;
        font-weight: 800;
        font-size: 12px;
        text-transform: uppercase;
        display: flex;
        align-items: center;
        justify-content: center;

        &>* {
            height: auto;
        }
    }

    &.vertical {
        max-height: unset;
        height: 100%;
        width: 100%;
    }
}
Enter fullscreen mode Exit fullscreen mode

Edit src/style.scs:

body {
    font-family: Arial, Helvetica, sans-serif;
    background-color: #0f0f11;
    padding: 0 40px 40px;
    margin: 0;
    width: calc(100% - 80px);
    height: calc(100vh - 80px);
}
Enter fullscreen mode Exit fullscreen mode

Let’s launch the project:

ng serve
Enter fullscreen mode Exit fullscreen mode

Go to localhost:4200 in the browser

Demo project

Conclusion

We implemented an “extremely” optimized grouped virtual list with dynamically sized content elements in minutes.

GIT repository of the project ng-virtual-list

Top comments (1)

Collapse
 
djonnyx profile image
Evgenii Grebennikov • Edited

Made a small benchmark for comparison. Sample 200,000 elements.

Each solution has links to demos. You can see for yourself the huge difference in resource consumption!

Result:

  • Initializing the ng-virtual-list list (on my machine) took ~3 seconds.
  • Initializing a regular HTML list (on my machine) took ~20 seconds.
  • The CPU load of ng-virtual-list is ~10 times lower than that of standard HTML lists.
  • The RAM load of ng-virtual-list is ~10 times less than that of standard HTML lists.

List initialization refers to the total time before the list is displayed. It consists of generating a collection with random data and constructing screen area objects.

Virtual list (ng-virtual-list):
benchmark-virtual-list.eugene-greb...

Simple HTML list:
benchmark-simple-list.eugene-grebe...