Skip to content
Angular Performance Optimization - Virtual Scroll

Angular Performance Optimization - Virtual Scroll

This is the third blog post dedicated to Angular performance optimizations. In the previous one we saw how to set the ChangeDetectionStrategy of a component to improve the performances of a simple web application that displays a list of 100000 books (a simple component that shows a title and an author).

ChangeDetectionStrategy.OnPush strategy doubled the performances of said application, but there is still room for improvement. Indeed, rendering this many elements can be slow in any web browser.

So the subject of the current blog post is how to use virtual-scrolling to improve the performances of an Angular application?

Need professional services?
Rely on our Expertise

The concept of Virtual Scroll

Classic scrolling

When the number of displayed element does not fit inside their container (the viewport), a scrollbar is usually displayed to browse through the list. But the invisible elements are still rendered in the DOM:

Normal Scrolling Schema

Here the number of rendered Items does not depend on the viewport size. ALL of them are always rendered, potentially leading to a performance issue. This is necessary for the web browser to compute their height, and match the scrollbar size and position to the viewport content.

The consequence for our Books list use case is that even though only a small portion of the books are visible on the screen, all 100,000 are in fact rendered:

Books list DOM

Virtual scrolling

The idea of virtual scrolling is to only display the items that are visible in the viewport, plus "before" and "after" buffers to avoid glitches when the user moves the scrollbar:

Virtual Scrolling Schema

The advantage is obviously the performance improvement. The downside is that the rendered items must have a fixed size (or at least a predictable size without rendering them) in order to compute the fake scrollbar position and height.

Angular CDK virtual scroll

An implementation of the virtual scroll strategy is available in the Angular Material CDK Scrolling Module.

Loading hundreds of elements can be slow in any browser; virtual scrolling enables a performant way to simulate all items being rendered by making the height of the container element the same as the height of total number of elements to be rendered, and then only rendering the items in view.

The viewport described above is done by the <cdk-virtual-scroll-viewport> component. Its [itemSize] input must be set to give the item height in pixels (unless you use a custom scroll strategy).

Instead of using the *ngFor directive to iterate over the items, you must use the dedicated *cdkVirtualFor directive that supports the exact same API.

CdkVirtualScroll Installation

The Angular CDK library can be added by updating your project's package.json file (set the version to match your @angular/core version):

{
  "dependencies": {
    "@angular/cdk": "^14.2.0"
  }
}

Then install it by typing the command npm install in a terminal at the root of your Angular project.

Books List implementation

Back to our Books list application. See the previous blog post about Angular performance improvement using ChangeDetectionPolicy to know more about our simple use case.

AppModule import

First, the module ScrollingModule must be added to the imports of our root AppModule:

import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';

import {AppComponent} from './app.component';
import {ReactiveFormsModule} from "@angular/forms";
import {BookComponent} from './book/book.component';
import {ScrollingModule} from "@angular/cdk/scrolling";

@NgModule({
    declarations: [
        AppComponent,
        BookComponent,
    ],
    imports: [
        BrowserModule,
        ReactiveFormsModule,
        ScrollingModule
    ],
    bootstrap: [AppComponent]
})
export class AppModule {
}

AppComponent update

The previous version of the AppComponent component was using a simple *ngFor directive and <ul><li> for the layout:

...
<ul>
  <li *ngFor="let book of books">
    <app-book [book]="book"></app-book>
  </li>
</ul>

We must rewrite its HTML template to use the CDK virtual scroll:

<form [formGroup]="formGroup">
  <input type="number" [formControl]="indexControl">
  <input type="text" [formControl]="titleControl">
  <button (click)="update()">Update</button>
</form>

<cdk-virtual-scroll-viewport itemSize="20" class="viewport">
  <app-book-on-push *cdkVirtualFor="let book of books" 
                    [book]="book" class="item">
  </app-book-on-push>
</cdk-virtual-scroll-viewport>

The itemSize is set to 20 pixels. Update the app.component.scss file to have a matching fixed 20px height for the items and 200px for the viewport (10 books should be visible at once):

.viewport {
  height: 200px;
}

.item {
  display: block;
  height: 20px;
}

Start the application with ng serve and open it. Unfortunately, clicking on the Update button does not seem to update the book title:

Books list bug

Books array reference bug

If you play with the scrollbar, hide the updated book and show it again, you may see that its title is now updated. It feels like the CDK Virtual Scroll is not aware that a modification was made to the books list, and only refreshes its content when the scrollbar is used.

The current implementation of the update() method is:

export class AppComponent {

    books: Book[] = [...Array(100000).keys()]
      .map((index) => new Book(index, `Title ${index}`, `Author ${index}`));  

    update(): void {
        const value = this.formGroup.getRawValue();
        const book = this.books[value.index];
        if (book) {
            book.title = value.title;
            const updated = new Book(book.id, value.title, book.author);
            this.books[value.index] = updated;
        }
    }
}

We force an update of the book reference with the line this.books[value.index] = updated;. That triggers the change detection of the BookComponent.

But the AppComponent books array reference is not updated by this operation. A quick and dirty fix is to reset this array reference with this.books = this.books.concat(); :

export class AppComponent {

    books: Book[] = [...Array(100000).keys()]
      .map((index) => new Book(index, `Title ${index}`, `Author ${index}`));

    update(): void {
        const value = this.formGroup.getRawValue();
        const book = this.books[value.index];
        if (book) {
            book.title = value.title;
            const updated = new Book(book.id, value.title, book.author);
            this.books[value.index] = updated;
            this.books = this.books.concat();
        }
    }
}

Back to the application, we can see now that clicking on the Update button immediately sets a new title in the books list:

Books list fix

But there might be a better way to code this?

Behavior Subject

A cleaner solution is to use a BehaviorSubject instead of a raw array for our books list:

export class AppComponent {
    books: BehaviorSubject<Book[]> = new BehaviorSubject<Book[]>(
      [...Array(100000).keys()]
        .map((index) => new Book(index, `Title ${index}`, `Author ${index}`))
    );

    update(): void {
        const value = this.formGroup.getRawValue();
        const books = this.books.value;
        const book = books[value.index];
        if (book) {
            book.title = value.title;
            const updated = new Book(book.id, value.title, book.author);
            books[value.index] = updated;
            this.books.next(books);
        }
    }
}
No change is needed in the HTML template as the *cdkVirtualFor directive can take an observable input:

  <app-book-on-push *cdkVirtualFor="let book of books" [book]="book" class="item"></app-book-on-push>

On the plus side, using a BehaviorSubject allows you to easily react to changes made to the books list in your application: all Subjects extend RxJs Observables.

Now that the code is properly fixed, let's open the web browser console to inspect the DOM:

DOM with virtual scrolling

We can see that only a few <app-book> elements are rendered: the 10 visible ones plus a few others for the buffer. How does that translate in terms of performances?

Virtual Scroll Performances Check

Check our previous blog post to install the tools required to measure Angular change detection cycle performances.

The application performances are now perfect! The timeChangeDetection() debug method gives us 0.01ms per check:

ng.profiler.timeChangeDetection()

The profiler gives us 3.4ms to handle the Book update:

Angular Devtools Profiler

Conclusion

We studied the concept of virtual scrolling and used it in our Books list sample application with the Angular CDK Scrolling module.

For a large list of elements, the performance improvements induced by virtual scrolling is impressive!

But implementing it is not always that simple. We managed to use virtual scroll for a tree in OctoPerf's load testing application. We also wrapped Angular Material table: dynamically sized sticky headers and footers are used as placeholders for invisible rows.

An issue is opened on Angular's GitHub to integrate virtual-scrolling as an add-on for existing components.

Want to become a super load tester?
Request a Demo