Tout SaaS B2B a un écran où tout le produit se joue. Dans le cas de ce post — une plateforme de messagerie utilisée par des équipes commerciales — cet écran est l'inbox : la liste de conversations que les utilisateurs gardent ouverte toute la journée. Dans les workspaces matures, cette liste accumule ~20 000 conversations. Et la version originale les chargeait toutes d'un coup.
Ce post documente la reconstruction de cet écran du point de vue frontend : pourquoi la pagination offset-based ne suffisait pas, à quoi ressemble le contrat d'API cursor-based, l'implémentation avec React Query et TanStack Virtual, et les trade-offs assumés. Le résultat : le payload est passé de ~6,7 MB à ~19 KB par requête (~400x), et les blocages de l'interface ont disparu.
Le problème : tout charger d'un coup
Le symptôme était visible à l'œil nu : ouvrir l'inbox dans un gros workspace prenait plusieurs secondes, et l'interface se figeait pendant le rendu. Le DevTools racontait le reste de l'histoire : une seule réponse JSON de ~6,7 MB, et le Performance profiler montrant des long tasks qui bloquaient la main thread pendant que React montait des milliers de lignes.
La cause racine était un endpoint sans pagination : le listing renvoyait toutes les conversations du workspace, et le client rendait la liste entière. Ce design a une propriété traître : il se dégrade silencieusement à mesure que le produit réussit. Avec 500 conversations, personne ne le remarque. Avec 20 000, l'écran le plus important du produit devient le plus lent.
Il y avait deux coûts distincts mélangés, et il vaut la peine de les séparer parce que les solutions sont différentes : le coût de réseau et de sérialisation (télécharger et parser 6,7 MB) et le coût de rendu (garder ~20 000 composants montés dans le DOM). La pagination résout le premier. Seule la virtualisation résout le second.
Pourquoi pas offset-based
La première idée évidente serait ?page=3&limit=50. Pour une inbox, la pagination offset-based échoue sur deux points :
- Coût de lecture croissant —
OFFSET 10000oblige la base de données à parcourir et à écarter 10 000 lignes avant de renvoyer les 50 suivantes. Les pages deviennent plus lentes à mesure que l'utilisateur navigue en profondeur. - Instabilité sous écritures concurrentes — une inbox reçoit de nouveaux messages en permanence, et chaque insertion décale l'offset : des éléments apparaissent en double ou disparaissent entre une page et la suivante. C'est exactement le type de bug intermittent que personne n'arrive à reproduire.
La pagination cursor-based (keyset) résout les deux : au lieu de compter des lignes, chaque page part d'un pointeur stable vers le dernier enregistrement chargé. Le coût de lecture reste constant et les nouvelles insertions ne décalent rien.
Le contrat de l'API
Le cursor est opaque pour le client : une string encodée que seul le backend sait interpréter — en pratique, la paire ordonnable (updated_at, id), où l'id départage les enregistrements ayant le même timestamp. Le contrat que consomme le frontend :
// GET /conversations?workspaceId=...&cursor=...&limit=50
type ConversationsPage = {
items: ConversationPreview[]
nextCursor: string | null // null => last page
}
// The list never receives the full conversation:
// only the fields the row actually renders
type ConversationPreview = {
id: string
contactName: string
lastMessageSnippet: string
lastMessageAt: string
unreadCount: number
status: "open" | "closed" | "snoozed"
}Un détail à l'impact disproportionné : la liste ne reçoit pas la conversation complète, seulement les champs que la ligne affiche. Le type ConversationPreview existe pour cela — empêcher que le payload du listing ne grossisse en même temps que le modèle complet de conversation. C'est le payload shaping et la pagination, combinés, qui produisent les ~19 KB par page.
Data fetching avec React Query
Le useInfiniteQuery a été fait exactement pour ce format : chaque page déclare d'où vient la suivante via getNextPageParam, et la bibliothèque s'occupe du cache, de la déduplication et des états de chargement :
function useConversations(workspaceId: string) {
return useInfiniteQuery({
queryKey: ["conversations", workspaceId],
queryFn: ({ pageParam }) =>
fetchConversations({ workspaceId, cursor: pageParam, limit: 50 }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
}
// Pages arrive as an array of arrays;
// the list consumes a single flat array
const allRows = data ? data.pages.flatMap((page) => page.items) : []Le point central de ce design est que l'accumulation se produit dans le cache, pas dans le DOM : naviguer en profondeur dans l'inbox ajoute des pages à React Query, mais — comme le montre la section suivante — le nombre d'éléments rendus reste le même.
Virtualisation avec TanStack Virtual
La pagination seule réduit le payload, pas le coût de rendu : si l'utilisateur scrolle suffisamment, les pages accumulées reconstruisent le problème original, ligne par ligne, à l'intérieur du DOM. La réponse est le windowing — ne rendre que les lignes visibles dans le viewport (plus une marge d'overscan), positionnées de façon absolue à l'intérieur d'un conteneur qui conserve la hauteur totale de la liste, ce qui garde la barre de défilement honnête.
Avec @tanstack/react-virtual, le useVirtualizer devient aussi le déclencheur naturel de la pagination : quand le dernier item virtuel s'approche de la fin de la liste chargée, on demande la page suivante :
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 88, // row height estimate; measureElement refines it
overscan: 8,
})
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const last = virtualItems.at(-1)
if (!last) return
if (last.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [virtualItems, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<div ref={parentRef} className="h-full overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualItems.map((row) => (
<ConversationRow
key={allRows[row.index]?.id ?? "loader"}
ref={virtualizer.measureElement}
data-index={row.index}
conversation={allRows[row.index]}
style={{
position: "absolute",
top: 0,
transform: `translateY(${row.start}px)`,
}}
/>
))}
</div>
</div>
)Deux décisions dans cet extrait méritent un commentaire. Le count inclut une ligne supplémentaire lorsqu'il y a une page suivante — elle devient le squelette de chargement à la fin de la liste. Et le measureElement mesure la hauteur réelle de chaque ligne une fois montée, parce que les lignes d'une inbox n'ont pas de hauteur fixe (snippets d'une ou deux lignes, badges, états). L'estimateSize a seulement besoin d'être assez bon pour le premier paint.
Résultats
- Payload par requête : de ~6,7 MB à ~19 KB (~400x)
- Fin des blocages de l'interface — avec un DOM stable de ~10–15 lignes, les long tasks de rendu ont disparu du profiler
- L'ouverture de l'inbox ne prend plus plusieurs secondes, même dans les plus gros workspaces
- Le coût de l'écran a cessé de croître avec la taille du workspace : 500 ou 20 000 conversations, le même travail par frame
Trade-offs assumés
Aucun de ces choix n'est gratuit, et il vaut la peine de noter ce que l'on perd :
- Pas de saut vers une page arbitraire — un cursor ne sait pas répondre à « emmène-moi à la page 37 ». Pour une inbox, parcourue comme un flux chronologique avec de la recherche et des filtres par-dessus, cela n'a pas manqué ; pour un tableau d'administration, ce serait un vrai problème.
- La virtualisation ajoute une complexité réelle — mesure des hauteurs dynamiques, restauration du scroll, overscan calibré. C'est un coût d'ingénierie qui ne se rentabilise que lorsque la liste est vraiment grande.
- Ce qui n'est pas dans le DOM n'existe pas pour le navigateur — le Cmd+F de la page ne trouve pas les lignes non rendues, et les lecteurs d'écran demandent une attention supplémentaire (
aria-setsize/aria-posinsetpour les comptages). La recherche du produit devient la vraie recherche.
Quand ne rien utiliser de tout cela
Si votre liste contient quelques centaines d'éléments, arrêtez-vous au payload shaping et à une pagination simple — la virtualisation est une réponse pour des milliers de lignes, pas un pattern par défaut. La règle que j'ai adoptée : pagination cursor-based quand la liste croît sans plafond au rythme de l'usage du produit ; virtualisation quand une seule liste menace de dépasser ~1 000 nœuds dans le DOM.
Ce qui mérite attention lors d'une deuxième passe
Deux points qui méritent du soin dans toute implémentation de cette architecture : la restauration du scroll entre les navigations — revenir d'une conversation vers la liste doit ramener l'utilisateur exactement là où il était, et cela exige de persister l'offset en dehors du composant, puisque le virtualiseur ne survit pas à l'unmount — et l'invalidation chirurgicale en temps réel : les nouveaux messages qui arrivent via WebSocket doivent mettre à jour la bonne page du cache avec setQueryData, et non invalider la query entière et refaire le travail que la pagination venait d'économiser.
La leçon qui reste porte moins sur les bibliothèques que sur la séparation des coûts : réseau, sérialisation et rendu se dégradent par des mécanismes différents et se résolvent avec des outils différents. La pagination cursor-based s'occupe de ce qui transite ; la virtualisation s'occupe de ce qui est rendu. Ensemble, elles ont fait de l'écran le plus lourd du produit le plus stable — et son coût a cessé de suivre la croissance des clients.
