~10 min de lecture

@for sur 10 000 lignes : ton DOM explose. Le CDK Virtual Scroll en rend 15

Le ticket dit "la page clients rame". Tu ouvres le composant : un @for propre, avec track, sur un signal. Le code est irréprochable. Et pourtant, au chargement, l'onglet se fige une bonne seconde, et le scroll accroche. La liste fait 10 000 lignes, et ton @for a fait exactement ce qu'on lui a demandé : il a créé 10 000 lignes de DOM.

Le problème n'est pas ta boucle : c'est de rendre des nœuds que personne ne verra jamais. Ton écran en affiche une dizaine, les 9 990 autres coûtent de la mémoire et du layout pour zéro pixel utile. Voyons comment le CDK Virtual Scroll ramène ce DOM à 15 nœuds, ce que ça coûte, et quand il ne faut surtout pas l'utiliser.


TL;DR

Un @for sur 10 000 items crée 10 000 nœuds DOM, et track n'y change rien : il n'optimise que les mises à jour. Le CDK Virtual Scroll rend un nombre de nœuds indépendant de la taille de la liste : 15 au repos, 17-18 en scroll avec un viewport de 480px et des lignes de 48px. En échange : hauteur de ligne fixe, Ctrl+F cassé, lecteurs d'écran limités à la fenêtre, HTML SSR sans lignes. Quelques centaines de lignes ou un backend qui pagine : n'y touche pas ; liste moyenne : essaie content-visibility: auto d'abord.

Les exemples sont écrits en syntaxe moderne (signals, control flow) et tournent sur Angular 17+, sauf l'exemple viewChild, qui demande 17.2 minimum. Le module de scrolling du CDK existe depuis bien plus longtemps ; installe simplement le @angular/cdk aligné sur ta version d'Angular. Les mesures de cet article ont été faites en Angular 22 avec le CDK 22.


Non, track ne te sauvera pas

Premier réflexe quand une liste rame : vérifier le track. Bon réflexe, mauvaise question.

track optimise les mises à jour : quand le tableau change, Angular réutilise les vues existantes au lieu de tout détruire et recréer - et s'y tromper rebuild ton DOM à chaque tick, ce qui est un vrai sujet, mais pas celui-ci. Sur le rendu initial, en revanche, il n'y a rien à réutiliser : 10 000 items, c'est 10 000 instanciations de template, quoi que tu écrives dans ton track.

Mesure-le toi-même :

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

interface Client {
  id: number;
  name: string;
  email: string;
}

const CLIENTS: Client[] = Array.from({ length: 10_000 }, (_, i) => ({
  id: i,
  name: `Client ${i}`,
  email: `client-${i}@corp.com`,
}));

@Component({
  selector: 'app-client-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul>
      @for (client of clients(); track client.id) {
        <li class="row">{{ client.name }} - {{ client.email }}</li>
      }
    </ul>
  `,
})
export class ClientList {
  readonly clients = signal(CLIENTS);
}

Dans la console :

document.querySelectorAll('.row').length; // 10000

10 000 <li>. Chacun avec son text binding, ses styles, sa place dans le layout. Le coût est payé au chargement, puis en continu : chaque recalcul de style ou de layout traverse un arbre DOM gigantesque. Et si ta ligne est un composant au lieu d'un <li>, tu paies en plus 10 000 instanciations de composant.

L'idée du virtual scrolling : ne rendre que les lignes visibles (plus une marge), et déplacer ce petit paquet de nœuds pendant le scroll. Le reste de la hauteur est simulé par un spacer, un élément vide que le CDK insère derrière tes lignes pour que la scrollbar garde la bonne taille.


La solution : 15 nœuds au lieu de 10 000

Le CDK est la couche bas niveau d'Angular Material, publiée par l'équipe Angular et utilisable sans Material.

pnpm add @angular/cdk

Le passage à la version virtualisée tient en quelques lignes :

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-client-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport">
      <div *cdkVirtualFor="let client of clients(); trackBy: trackById" class="row">
        {{ client.name }} - {{ client.email }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .viewport {
      height: 480px;
    }
    .row {
      height: 48px;
    }
  `,
})
export class ClientList {
  readonly clients = signal(CLIENTS); // the same 10 000 clients as before

  trackById(index: number, client: Client): number {
    return client.id;
  }
}

Même mesure dans la console :

document.querySelectorAll('.row').length; // 15 au repos, 17-18 pendant le scroll

Avec un viewport de 480px et des lignes de 48px, le CDK rend 15 lignes au repos : les 10 visibles, plus le buffer rendu sous la fenêtre - la réserve de lignes que le CDK garde prête hors écran, arrondie à la ligne entière, pour que le scroll rapide n'affiche pas de zone blanche. En haut de liste, rien à préparer au-dessus, d'où 15 ; pendant le scroll, le CDK entretient la marge des deux côtés et la plage monte à 17 ou 18 lignes. Quinze nœuds au lieu de 10 000, et surtout un nombre constant : que ta liste fasse 10 000 ou 500 000 items, le DOM ne grossit plus.

Trois choses à remarquer, chacune étant un piège :

1. Le viewport a une hauteur contrainte. cdk-virtual-scroll-viewport est le conteneur de scroll. Sans hauteur contrainte (fixe, flex, grid, peu importe, mais contrainte), il ne peut pas calculer ce qui est visible. Symptôme classique : une poignée de lignes en haut, puis un vide immense - le spacer, lui, garde sa taille - et aucune erreur en console.

2. itemSize doit dire la vérité. C'est la hauteur d'une ligne en pixels, et la stratégie par défaut - l'objet qui décide quelles lignes rendre, ici FixedSizeVirtualScrollStrategy - s'en sert pour tout : la taille du spacer, donc de la scrollbar, et le calcul de la plage à rendre. Si tes lignes font 56px et que tu déclares 48, la scrollbar ment d'un facteur fixe : spacer de 480 000px pour un contenu réel de 560 000px, donc 56px de contenu parcourus par 48px de barre. Et la liste saute visiblement à chaque mise à jour de la plage rendue (mesuré : 11 sauts sur 11 mises à jour, zéro avec un itemSize juste), sans que rien ne te le signale. C'est le bug le plus fréquent avec cet outil.

3. C'est *cdkVirtualFor, pas @for. Le nouveau control flow n'a pas d'équivalent : en Angular 22, il n'existe pas de bloc @virtualFor. cdkVirtualFor reste une directive structurelle à micro-syntaxe à étoile, avec les variables locales habituelles (index, count, first, last, even, odd) et un trackBy au format fonction, comme au temps de *ngFor.


Les 3 réglages que tu finiras par toucher

Les buffers : minBufferPx et maxBufferPx

Par défaut, minBufferPx vaut 100 et maxBufferPx vaut 200 : quand la marge rendue hors écran passe sous 100px, le CDK rend de nouvelles lignes jusqu'à remonter à 200px.

<cdk-virtual-scroll-viewport itemSize="48" minBufferPx="300" maxBufferPx="600">

Si tes utilisateurs scrollent vite et voient du blanc, augmente les deux : plus de buffer, c'est moins de blanc mais plus de nœuds rendus. Et respecte maxBufferPx >= minBufferPx : en dev, le CDK lève une erreur et le viewport n'affiche plus rien ; en build de production, cette vérification disparaît avec le reste du code gardé par ngDevMode (le drapeau qu'Angular passe à false en prod), et la liste s'affiche sans erreur avec ta config bancale.

Le cache de vues : templateCacheSize

Quand une ligne sort de la fenêtre, sa vue n'est pas détruite : elle part dans un cache (20 vues par défaut) pour être recyclée à l'arrivée d'une nouvelle ligne, ce qui coûte moins cher que de la recréer. Le nom de l'option est d'ailleurs trompeur : ce sont des vues instanciées qui sont mises en cache, pas des templates.

<div *cdkVirtualFor="let client of clients(); templateCacheSize: 0">

0 désactive le cache. Deux cas où c'est pertinent : des lignes très lourdes en mémoire, où garder 20 vues de plus coûte davantage que de les recréer ; et des lignes qui portent de l'état DOM non bindé - un <input> libre, un <details> ouvert. Une vue recyclée transporte cet état sur un autre item : tape un texte dans l'input de la ligne 0, scrolle 2 000 lignes plus bas, et ta saisie réapparaît sur un autre client. Dans le doute côté perf, ne touche pas au défaut ; mais si tes lignes ont de l'état DOM non bindé, binde-le ou coupe le cache.

Piloter le scroll

Le viewport expose une API impérative, accessible via viewChild (17.2+) :

import { Component, ChangeDetectionStrategy, viewChild } from '@angular/core';
import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-client-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="viewport"
        (scrolledIndexChange)="onFirstVisibleChange($event)">
      <!-- ... -->
    </cdk-virtual-scroll-viewport>
  `,
})
export class ClientList {
  private readonly viewport = viewChild.required(CdkVirtualScrollViewport);

  scrollToClient(index: number): void {
    this.viewport().scrollToIndex(index);
  }

  onFirstVisibleChange(index: number): void {
    // persist scroll position, trigger loading, analytics...
  }
}

scrollToIndex restaure une position au retour sur la page ; scrolledIndexChange déclenche un chargement quand l'utilisateur approche de la fin.


Ce que le virtual scroll te coûte

Les hauteurs variables ne sont pas gérées en stable. itemSize impose des lignes de hauteur fixe. Pour des hauteurs variables (commentaires, messages de chat), la stratégie autosize existe, mais elle vit dans @angular/cdk-experimental/scrolling, et elle y est toujours en Angular 22. Avant de l'embarquer, demande-toi si tu peux fixer la hauteur de tes lignes (truncate + tooltip) : c'est souvent le meilleur compromis.

Ctrl+F ne trouve plus rien. Les lignes non rendues n'existent pas dans le DOM, donc la recherche du navigateur ne peut pas les trouver. Si tes utilisateurs cherchent dans la page, il te faut une recherche applicative.

Les lecteurs d'écran ne voient que la fenêtre. Même cause, même effet : seules les lignes rendues existent dans l'arbre d'accessibilité - 15 dans notre exemple, pas 10 000. Et le snippet ci-dessus aggrave son cas : des lignes en <div> n'exposent aucun rôle de liste, un lecteur d'écran y voit 15 textes sans structure. Ce second défaut ne vient pas de la virtualisation : c'est à toi de rétablir la sémantique de liste avec des rôles ARIA. Reste ensuite à décider si une liste annoncée à 15 éléments est acceptable pour ton public. Ça se décide ; ça ne se découvre pas en prod.

Ta liste est absente du HTML servi par le SSR. Côté serveur, le CDK ne tente même pas de rendre : le ngOnInit du viewport commence par une garde de plateforme et s'arrête là hors navigateur, donc la stratégie de scroll n'est jamais attachée et la plage rendue reste vide. Le HTML servi contient le viewport et son spacer, mais aucune ligne ; les lignes n'apparaissent qu'une fois l'app démarrée côté client. Pour un back-office interne, aucun impact SEO. Pour du contenu que tu veux indexé, c'est disqualifiant : garde un rendu paginé.


Les 3 cas où virtualiser est une erreur

En dessous de quelques centaines de lignes, n'y touche pas. Le DOM encaisse très bien quelques centaines de lignes simples. Tu paierais les contreparties ci-dessus pour un gain invisible.

La pagination reste souvent la vraie réponse. Si ton backend sait paginer, charger 10 000 lignes d'un coup est un problème de requête avant d'être un problème de rendu. Le virtual scroll se justifie quand l'usage exige tout le dataset en scroll continu : logs, monitoring, longues listes de sélection.

Pour les listes moyennes, le CSS suffit parfois. content-visibility: auto demande au navigateur de sauter le rendu (style, layout, paint) des éléments hors écran, tout en les gardant dans le DOM :

.row {
  content-visibility: auto;
  contain-intrinsic-size: auto 48px;
}

contain-intrinsic-size n'est pas décoratif : c'est la hauteur que le navigateur réserve pour le contenu qu'il ne rend pas. Mets-y la hauteur réelle de tes lignes, sinon la scrollbar sautille à mesure que le contenu entre et sort de l'écran.

Les nœuds existent toujours - tu paies la création DOM et la mémoire, mais tu gardes Ctrl+F et les lecteurs d'écran - et le navigateur ne paie plus le rendu du hors-écran. Zéro dépendance, zéro changement de template : pour quelques milliers de lignes simples, teste ça avant de sortir le CDK.


Récap actionnable

  • track optimise les mises à jour, pas le rendu initial : 10 000 items dans un @for, c'est 10 000 nœuds quoi qu'il arrive.
  • Le CDK Virtual Scroll rend un nombre de lignes indépendant de la taille de la liste : ScrollingModule, <cdk-virtual-scroll-viewport itemSize="X"> + *cdkVirtualFor.
  • Trois réglages : un itemSize exact (sinon scrollbar fausse et sauts au scroll), des buffers à élargir si tu vois du blanc (et maxBufferPx >= minBufferPx, que seul le build de dev vérifie), un cache de vues à laisser tranquille sauf état DOM non bindé.
  • Pas de bloc @virtualFor : *cdkVirtualFor garde la syntaxe à étoile et un trackBy fonction.
  • Contreparties à arbitrer : hauteurs fixes obligatoires en stable, Ctrl+F cassé, lecteurs d'écran limités à la fenêtre, HTML SSR sans lignes.
  • Quelques centaines de lignes : rien à faire. Backend paginable : pagine. Liste moyenne : essaie content-visibility: auto avant le CDK.

Le virtual scroll est un échange : tu troques des fonctionnalités natives du navigateur contre un DOM constant. Sur une grosse liste applicative, il est excellent. Ailleurs, c'est de la complexité pour rien.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.