Apprenez à concevoir des listes de tableau rapides pour 100k lignes en utilisant pagination, virtualisation, filtrage intelligent et requêtes optimisées afin que les outils internes restent réactifs.

Un écran de liste semble généralement correct… jusqu'à ce que ce ne soit plus le cas. Les utilisateurs remarquent de petites saccades qui s'accumulent : le défilement saccade, la page se fige un instant après chaque mise à jour, les filtres mettent des secondes à répondre, et un spinner apparaît après chaque clic. Parfois l'onglet du navigateur paraît gelé parce que le thread UI est occupé.
100k lignes est un point de bascule courant parce que cela met sous tension toutes les parties du système en même temps. Le jeu de données reste normal pour une base, mais il est assez grand pour rendre visibles les petites inefficacités dans le navigateur et sur le réseau. Si vous essayez d'afficher tout en une fois, un écran simple devient une pipeline lourde.
L'objectif n'est pas de rendre toutes les lignes. L'objectif est d'aider quelqu'un à trouver rapidement ce dont il a besoin : les 50 lignes pertinentes, la page suivante, ou une tranche étroite via un filtre.
Il est utile de répartir le travail en quatre parties :
Si une seule de ces parties est coûteuse, l'écran entier paraît lent. Une simple recherche peut déclencher une requête qui trie 100k lignes, renvoie des milliers d'enregistrements, puis force le navigateur à tous les afficher. C'est ainsi que la saisie devient lente.
Quand des équipes construisent rapidement des outils internes (y compris avec des plateformes de type "vibe-coding" comme Koder.ai), les écrans de liste sont souvent le premier endroit où la croissance réelle des données expose l'écart entre « ça marche sur un jeu de démonstration » et « ça paraît instantané tous les jours ».
Avant d'optimiser, décidez ce que "rapide" signifie pour cet écran. Beaucoup d'équipes courent après le débit (tout charger) alors que les utilisateurs ont surtout besoin d'une faible latence (voir quelque chose s'actualiser vite). Une liste peut sembler instantanée même si elle ne charge jamais les 100k lignes, tant qu'elle répond rapidement au défilement, tri et filtres.
Un objectif pratique est le temps jusqu'à la première ligne, pas le temps de chargement complet. Les utilisateurs font confiance à la page quand ils voient rapidement les 20 à 50 premières lignes et que les interactions restent fluides.
Choisissez un petit ensemble de chiffres que vous pouvez suivre à chaque changement :
COUNT(*) et SELECT larges)Ces mesures correspondent aux symptômes courants. Si le CPU du navigateur monte quand vous faites défiler, le frontend fait trop de travail par ligne. Si le spinner attend mais le défilement est OK après, le backend ou le réseau est généralement en cause. Si la requête est rapide mais la page se fige, c'est presque toujours le rendu ou un traitement lourd côté client.
Faites une expérience simple : gardez l'UI identique, mais limitez temporairement le backend à ne renvoyer que 20 lignes avec les mêmes filtres. Si ça devient rapide, le goulot est la taille de la charge ou le temps de requête. Si c'est encore lent, regardez du côté du rendu, du formatage et des composants par ligne.
Exemple : un écran interne de Commandes semble lent quand vous tapez une recherche. Si l'API renvoie 5 000 lignes et que le navigateur les filtre à chaque frappe, la saisie laguera. Si l'API met 2 secondes à cause d'un COUNT sur un filtre non indexé, vous verrez une attente avant que la première ligne ne change. Différentes causes, même plainte utilisateur.
Le navigateur est souvent le premier goulot. Une liste peut sembler lente même quand l'API est rapide, simplement parce que la page essaie de peindre trop. La première règle est simple : ne rendez pas des milliers de lignes dans le DOM à la fois.
Avant d'ajouter une virtualisation complète, gardez chaque ligne légère. Une ligne avec wrappers imbriqués, icônes, tooltips et styles conditionnels complexes dans chaque cellule vous coûte à chaque défilement et mise à jour. Privilégiez du texte simple, quelques petits badges et seulement un ou deux éléments interactifs par ligne.
Une hauteur de ligne stable aide plus qu'on ne le pense. Quand chaque ligne a la même hauteur, le navigateur peut mieux prédire le layout et le défilement reste fluide. Les lignes à hauteur variable (descriptions renvoyées, notes extensibles, grosses avatars) déclenchent des mesures et des reflows supplémentaires. Si vous avez besoin de détails, envisagez un panneau latéral ou une zone extensible unique, pas une ligne multiligne complète.
Le formatage est une autre taxe silencieuse. Dates, devises et manipulations de chaînes lourdes s'additionnent quand elles sont répétées sur de nombreuses cellules.
Si une valeur n'est pas visible, ne la calculez pas encore. Mettez en cache les résultats de formatage coûteux et calculez-les à la demande, par exemple quand une ligne devient visible ou quand l'utilisateur ouvre une ligne.
Une série d'actions rapides qui apporte souvent un gain notable :
Exemple : un tableau de Factures interne qui formate 12 colonnes de devises et de dates va saccader au défilement. Cacher les valeurs formatées par facture et retarder le travail pour les lignes hors écran peut le rendre instantané, même avant un travail plus profond côté backend.
La virtualisation signifie que le tableau ne dessine que les lignes que vous pouvez réellement voir (plus un petit buffer au-dessus et en dessous). En défilant, il réutilise les mêmes éléments DOM et remplace simplement les données à l'intérieur. Cela empêche le navigateur d'essayer de peindre des dizaines de milliers de composants ligne en même temps.
La virtualisation convient bien quand vous avez de longues listes, des tableaux larges ou des lignes lourdes (avatars, chips de statut, menus d'action, tooltips). Elle est aussi utile quand les utilisateurs défilent beaucoup et attendent une vue continue et fluide plutôt que de sauter page par page.
Ce n'est pas magique. Quelques points causent souvent des surprises :
L'approche la plus simple est ennuyeuse mais efficace : hauteur de ligne fixe, colonnes prévisibles et pas trop de widgets interactifs dans chaque ligne.
Vous pouvez combiner les deux : utiliser la pagination (ou un chargement "load more" basé sur curseur) pour limiter ce que vous cherchez côté serveur, et la virtualisation pour réduire le rendu à l'intérieur de la tranche récupérée.
Un pattern pratique consiste à récupérer une page normale (souvent 100 à 500 lignes), virtualiser à l'intérieur de cette page et offrir des contrôles clairs pour naviguer entre les pages. Si vous utilisez le défilement infini, ajoutez un indicateur Visible X sur Y pour que les utilisateurs comprennent qu'ils ne voient pas tout.
Si vous voulez un écran de liste utilisable à mesure que les données croissent, la pagination est généralement le choix le plus sûr. Elle est prévisible, fonctionne bien pour les workflows d'administration (revoir, éditer, approuver) et prend en charge des besoins communs comme l'export de "page 3 avec ces filtres" sans surprises. Beaucoup d'équipes reviennent à la pagination après avoir essayé des défilements plus sophistiqués.
Le défilement infini peut être agréable pour la navigation occasionnelle, mais il a des coûts cachés. Les gens perdent le sens de leur position, le bouton retour ne ramène souvent pas au même endroit, et les longues sessions peuvent accumuler de la mémoire à mesure que plus de lignes se chargent. Un compromis est un bouton "Charger plus" qui utilise toujours des pages, pour garder l'orientation de l'utilisateur.
La pagination par offset est la classique page=10&size=50. C'est simple, mais elle peut devenir plus lente sur de grandes tables parce que la base doit sauter beaucoup de lignes pour atteindre les pages plus profondes. Elle peut aussi paraître étrange quand de nouvelles lignes arrivent et que les éléments se déplacent entre les pages.
La pagination par curseur (souvent appelée keyset) demande "les 50 lignes suivantes après le dernier élément vu", généralement en utilisant un id ou created_at. Elle reste souvent rapide car elle évite de compter et de sauter autant de travail.
Règle pratique :
Les utilisateurs aiment voir des totaux, mais un COUNT complet sur tous les enregistrements correspondants peut être coûteux avec des filtres lourds. Les options incluent la mise en cache des comptes pour les filtres populaires, la mise à jour du compte en arrière-plan après le chargement de la page, ou l'affichage d'un nombre approximatif (par exemple « 10 000+ »).
Exemple : un écran interne Commandes peut montrer les résultats instantanément avec une pagination par keyset, puis remplir le total exact seulement quand l'utilisateur cesse de changer les filtres pendant une seconde.
Si vous construisez cela dans Koder.ai, traitez la pagination et le comportement des comptes comme faisant partie de la spécification d'écran dès le départ, afin que les requêtes backend générées et l'état UI ne se contredisent pas plus tard.
La plupart des écrans de liste paraissent lents parce qu'ils commencent trop larges : tout charger, puis demander à l'utilisateur d'affiner. Inversez cela. Commencez avec des valeurs par défaut sensées qui renvoient un ensemble petit et utile (par exemple : 7 derniers jours, Mes éléments, Statut : Ouvert), et faites de "Toutes les périodes" un choix explicite.
La recherche textuelle est un piège fréquent. Si vous lancez une requête à chaque frappe, vous créez un backlog de requêtes et une UI qui clignote. Débouncez la recherche pour ne requêter qu'après une pause de l'utilisateur, et annulez les requêtes plus anciennes quand une nouvelle commence. Règle simple : si l'utilisateur tape encore, ne touchez pas encore le serveur.
Le filtrage paraît rapide quand il est aussi clair. Affichez des chips de filtre en haut du tableau pour que les utilisateurs voient ce qui est actif et puissent l'enlever d'un clic. Gardez les étiquettes lisibles par un humain, pas des noms de champs bruts (par ex. Owner : Sam au lieu de owner_id=42). Quand quelqu'un dit « mes résultats ont disparu », c'est souvent à cause d'un filtre invisible.
Patterns qui gardent les grandes listes réactives sans complexifier l'UI :
Les vues enregistrées sont l'héroïne discrète. Au lieu d'apprendre aux utilisateurs à construire un combo de filtres parfait à chaque fois, donnez-leur quelques presets qui correspondent à des workflows réels. Une équipe ops peut basculer entre Paiements échoués aujourd'hui et Clients à haute valeur. Ceux-ci sont accessibles en un clic, compréhensibles instantanément et plus faciles à garder rapides côté backend.
Si vous construisez un outil interne dans un constructeur piloté par chat comme Koder.ai, considérez les filtres comme partie du flux produit, pas comme un ajout. Commencez par les questions les plus courantes, puis concevez la vue par défaut et les vues enregistrées autour de celles-ci.
Un écran de liste a rarement les mêmes besoins de données qu'une page de détail. Si votre API renvoie tout sur tout, vous payez double : la base de données fait plus de travail, et le navigateur reçoit et rend plus que nécessaire. Le façonnage des requêtes consiste à demander uniquement ce dont la liste a besoin maintenant.
Commencez par ne retourner que les colonnes nécessaires pour rendre chaque ligne. Pour la plupart des tableaux, c'est un id, quelques labels, un statut, un propriétaire et des timestamps. Les gros textes, blobs JSON et champs calculés peuvent attendre que l'utilisateur ouvre une ligne.
Évitez les jointures lourdes pour le premier rendu. Les jointures passent bien quand elles utilisent des indexes et retournent peu de résultats, mais elles deviennent coûteuses quand vous joignez plusieurs tables puis triez ou filtrez sur ces données jointes. Un pattern simple : récupérez rapidement la liste depuis une table, puis chargez les détails liés à la demande (ou en batch pour les lignes visibles seulement).
Limitez les options de tri et triez sur des colonnes indexées. « Trier par n'importe quoi » semble utile, mais force souvent des tris lents sur de larges ensembles. Préférez quelques choix prévisibles comme created_at, updated_at ou status, et assurez-vous que ces colonnes sont indexées.
Soyez prudent avec l'agrégation côté serveur. COUNT(*) sur un grand jeu filtré, DISTINCT sur une colonne large ou le calcul du nombre total de pages peuvent dominer le temps de réponse.
Approche pratique :
COUNT et DISTINCT comme optionnels, et mettez en cache ou approximiez quand possibleSi vous construisez des outils internes sur Koder.ai, définissez une requête de liste légère séparée de la requête de détails en phase de planification, afin que l'UI reste réactive à mesure que les données grandissent.
Si vous voulez un écran de liste qui reste rapide à 100k lignes, la base doit faire moins de travail par requête. La plupart des listes lentes ne sont pas "trop grosses" : ce sont de mauvais schémas d'accès aux données.
Commencez par des index qui correspondent à ce que vos utilisateurs font réellement. Si votre liste est souvent filtrée par status et triée par created_at, vous voulez un index qui supporte les deux, dans cet ordre. Sinon la base peut scanner bien plus de lignes que prévu puis les trier, ce qui devient rapidement coûteux.
Correctifs qui apportent généralement les plus gros gains :
tenant_id, status, created_at).OFFSET profondes. OFFSET fait parcourir beaucoup de lignes pour les sauter.Exemple simple : une table Commandes interne qui montre nom client, statut, montant et date. Ne joignez pas toutes les tables reliées et ne récupérez pas les notes complètes des commandes pour la vue liste. Retournez seulement les colonnes utilisées dans le tableau et chargez le reste dans une requête séparée quand l'utilisateur clique sur une commande.
Si vous construisez avec une plateforme comme Koder.ai, gardez cet état d'esprit même si l'UI est générée depuis du chat. Assurez-vous que les endpoints API générés supportent la pagination par curseur et les champs sélectifs, pour que le travail en base reste prévisible à mesure que la table grandit.
Si une page de liste paraît lente aujourd'hui, ne commencez pas par réécrire tout. Commencez par définir ce que l'usage normal ressemble, puis optimisez ce chemin.
Définir la vue par défaut. Choisissez les filtres par défaut, l'ordre de tri et les colonnes visibles. Les listes ralentissent quand elles essaient de tout montrer par défaut.
Choisir un style de pagination adapté. Si les utilisateurs consultent surtout les premières pages, la pagination classique suffit. Si les gens vont profondément (page 200+) ou vous avez besoin d'une performance stable quel que soit l'endroit, utilisez la pagination par keyset (sur un tri stable comme created_at plus un id).
Ajouter la virtualisation pour le corps du tableau. Même si le backend est rapide, le navigateur peut s'étouffer quand il rend trop de lignes à la fois.
Rendre la recherche et les filtres instantanés. Débouncez la saisie pour ne pas déclencher une requête à chaque frappe. Gardez l'état des filtres dans l'URL ou un store partagé pour que le refresh, le bouton retour et le partage fonctionnent correctement. Cachez le dernier résultat réussi pour éviter que le tableau ne clignote vide.
Mesurer, puis optimiser requêtes et index. Enregistrez le temps serveur, le temps base, la taille du payload et le temps de rendu. Puis taillez la requête : sélectionnez seulement les colonnes affichées, appliquez les filtres tôt et ajoutez des index qui correspondent au filtre + tri par défaut.
Exemple : un tableau support interne avec 100k tickets. Par défaut : Ouvert, assigné à mon équipe, trié par plus récent, afficher six colonnes et ne récupérer que id, sujet, assigné, statut et timestamps. Avec pagination keyset et virtualisation, vous rendez la base et l'UI prévisibles.
Si vous construisez des outils internes dans Koder.ai, ce plan se prête bien à un workflow itératif : ajustez la vue, testez le défilement et la recherche, puis optimisez la requête jusqu'à ce que la page reste réactive.
La façon la plus rapide de casser un écran de liste est de traiter 100k lignes comme une page normale de données. La plupart des tableaux lents ont quelques pièges prévisibles.
Un gros piège est tout rendre puis masquer avec du CSS. Même si on voit seulement 50 lignes, le navigateur a payé le coût de la création de 100k nœuds DOM, de leur mesure et des repaint au défilement. Si vous avez de longues listes, montez seulement ce que l'utilisateur peut voir (virtualisation) et gardez les composants de ligne simples.
La recherche peut aussi silencieusement ruiner la perf quand chaque frappe déclenche un scan complet de la table. Cela arrive quand les filtres ne sont pas indexés, quand vous cherchez sur trop de colonnes, ou quand vous faites des requêtes de type contains sur de grands champs texte sans plan. Règle utile : le premier filtre que l'utilisateur utilise doit être bon marché en base, pas seulement pratique en UI.
Autre problème courant : récupérer des enregistrements complets quand la liste n'a besoin que de résumés. Une ligne de liste a généralement besoin de 5 à 12 champs, pas de l'objet complet, pas de longues descriptions, et pas des données reliées. Récupérer des données supplémentaires augmente le travail en DB, le temps réseau et le parsing côté frontend.
L'export et les totaux peuvent figer l'UI si vous les calculez sur le main-thread ou attendez une requête lourde avant de répondre. Gardez l'UI interactive : lancez les exports en arrière-plan, montrez la progression et évitez de recalculer les totaux à chaque changement de filtre.
Enfin, trop d'options de tri peuvent se retourner contre vous. Si les utilisateurs peuvent trier par n'importe quelle colonne, vous finirez par trier de grands ensembles en mémoire ou forcer la base à adopter des plans lents. Limitez les tris à un petit ensemble de colonnes indexées et faites en sorte que le tri par défaut corresponde à un index réel.
Checklist rapide :
Traitez la performance des listes comme une fonctionnalité produit, pas comme un ajustement ponctuel. Une liste est rapide seulement quand elle paraît rapide pendant que de vraies personnes défilent, filtrent et trient sur de vraies données.
Utilisez cette checklist pour confirmer que vous avez corrigé les bons points :
Vérification simple : ouvrez la liste, faites défiler pendant 10 secondes, puis appliquez un filtre courant (par ex. Statut : Ouvert). Si l'UI se fige, le problème est en général le rendu (trop de nœuds DOM) ou une transformation lourde côté client (tri, groupement, formatage) qui s'exécute à chaque mise à jour.
Prochaines étapes, dans l'ordre pour ne pas rebondir entre correctifs :
Si vous construisez cela avec Koder.ai (koder.ai), commencez en Planning Mode : définissez d'abord les colonnes exactes de la liste, les champs de filtre et la forme de réponse API. Puis itérez en utilisant des snapshots et revenez en arrière si une expérience ralentit l'écran.
Commencez par changer l'objectif de « charger tout » vers « afficher rapidement les premières lignes utiles ». Optimisez le temps jusqu'à la première ligne et la fluidité des interactions (filtrage, tri, défilement), même si l'ensemble des données n'est jamais chargé en une fois.
Mesurez le temps jusqu'à la première ligne après ouverture ou changement de filtre, le temps pour qu'un filtre/tri affiche les résultats, la taille de la réponse (payload), les requêtes lentes en base (en particulier SELECT larges et COUNT(*)) et les pics sur le main-thread du navigateur. Ces chiffres correspondent directement à ce que les utilisateurs perçoivent comme du « lag ».
Limitez temporairement l'API à 20 lignes avec les mêmes filtres et tri. Si c'est fluide, le coût vient surtout des requêtes ou de la taille du payload ; si c'est toujours lent, le goulot d'étranglement est généralement le rendu, le formatage ou le travail côté client par ligne.
N'affichez pas des milliers de lignes dans le DOM en même temps, simplifiez les composants de ligne et préférez une hauteur de ligne fixe. Évitez de faire du formatage coûteux pour des lignes hors écran : calculez et mettez en cache le formatage seulement quand la ligne devient visible ou est ouverte.
La virtualisation ne monte que les lignes visibles (plus un petit buffer) et réutilise les mêmes éléments DOM pendant le défilement. Elle vaut le coup quand les utilisateurs défilent beaucoup ou quand les lignes sont « lourdes », mais fonctionne mieux si la hauteur des lignes est constante et la mise en page prévisible.
La pagination est l'option la plus sûre pour la plupart des interfaces admin et workflows internes : elle garde l'orientation de l'utilisateur et limite le travail serveur. Le défilement infini peut aller pour une navigation occasionnelle, mais il complique la navigation, le bouton retour et peut augmenter l'utilisation mémoire.
La pagination par offset (page=10&size=50) est simple mais peut ralentir pour les pages profondes car la base doit sauter beaucoup de lignes. La pagination par curseur (keyset) demande « les 50 lignes suivantes après le dernier élément vu » (souvent via un id ou created_at) et reste généralement rapide. Choisissez l'offset pour des listes petites/stables ou quand il faut sauter à une page précise ; préférez le keyset pour de très larges listes et des inserts fréquents.
Ne déclenchez pas une requête à chaque frappe. Débouncez la saisie, annulez les requêtes en cours quand une nouvelle arrive, et par défaut proposez des filtres restrictifs (ex. 7 derniers jours, Mes éléments) afin que la première requête retourne peu et soit utile.
Retournez uniquement les champs que la liste affiche — en général un id, un label, un statut, un propriétaire et des timestamps. Laissez les gros textes, blobs JSON et données reliées à une requête de détail quand l'utilisateur ouvre la ligne.
Faites en sorte que les filtres et tris par défaut correspondent à l'utilisation réelle, puis ajoutez des index adaptés (souvent un index composite combinant tenant/champs de filtre et la colonne de tri). Considérez le total exact comme optionnel : mettez le en cache, pré-calculé ou affichez une approximation afin qu'il ne bloque pas la réponse principale.