Vous avez déployé une liste. Ça fonctionnait en staging. Puis les vrais utilisateurs sont arrivés : quelqu’un a perdu sa place après avoir tapé « Retour », les analytics se sont effondrés, des tickets de support mentionnaient « ça charge sans fin », et le SRE demande pourquoi un endpoint représente maintenant la moitié des IOPS de lecture.
La pagination et le défilement infini ne sont pas de simples « choix UX ». Ce sont des décisions d’architectures distribuées déguisées en UI. Choisissez mal et vous pénaliserez les utilisateurs, votre base de données, votre cache et votre on-call — souvent en même temps.
Prendre la décision comme un opérateur, pas comme un designer
La pagination et le défilement infini optimisent des choses différentes. Si vous choisissez selon le goût, vous optimiserez par erreur pour la personne la plus bruyante de la pièce. Choisissez selon l’intention utilisateur, la récupération d’erreur et le coût opérationnel.
L’intention utilisateur décide du comportement par défaut
- Utilisateurs cherchant quelque chose de précis (produits, tickets, comptes, docs) : privilégiez la pagination. Les gens veulent des points de repère : numéros de page, URLs stables, « Retour » qui fonctionne et un sentiment de progression.
- Utilisateurs qui naviguent pour être divertis (feeds, galeries d’inspiration, timelines sociales) : le défilement infini peut convenir parce que le « suivant » compte moins que le « maintenant ».
- Utilisateurs comparant des éléments (achats, tableaux de bord) : pagination, ou un hybride avec « Charger plus » et état persistant. Les comparaisons nécessitent de revenir à un endroit précédent sans perdre le contexte.
Réalités opérationnelles qui doivent influencer l’UX
Le défilement infini n’est pas « sans pages ». C’est beaucoup de petites pages récupérées en séquence, ce qui implique :
- Plus de requêtes réseau pendant une session.
- Plus de pression mémoire côté client à moins de virtualiser.
- Cache plus difficile si votre API n’utilise pas de curseur.
- Attribution analytics plus difficile à moins d’en tenir compte.
- Débogage plus difficile parce qu’un seul « scroll » peut toucher plusieurs services.
La pagination n’est pas « résolue ». Elle peut encore être lente, inexacte et coûteuse quand elle est implémentée comme « OFFSET 90000 LIMIT 50 » contre une table chaude. Quand vous voyez ce motif de requête, vous pouvez presque entendre la base de données soupirer.
Une citation par laquelle vous pouvez réellement gérer un service
L’espoir n’est pas une stratégie.
— General Gordon R. Sullivan
Si votre défilement infini dépend de « espérer que l’utilisateur n’ira pas jusqu’à 10 000 éléments », vous avez construit une bombe à retardement avec une barre de défilement.
Quelques faits et une histoire qui comptent encore
- Les premières listes web étaient paginées principalement à cause de la bande passante : le dial-up rendait « tout charger » impossible, donc les frontières de page étaient un hack de performance avant d’être un modèle UX.
- Le défilement infini s’est popularisé à la fin des années 2000 quand les feeds et timelines sociales ont optimisé l’engagement, pas l’accomplissement de tâches.
- Les moteurs de recherche ont historiquement eu du mal avec le défilement infini parce que le contenu sans URLs stables est difficile à crawler et indexer ; le « beau » peut être invisible.
- La pagination par offset a un coût algorithmique : les offsets profonds nécessitent souvent de scanner/sauter des lignes, ce qui transforme « page 2000 » en une marche lente dans la base.
- La pagination par curseur est devenue courante pour les API à grande échelle car elle est stable face aux insertions et suppressions concurrentes : votre « page suivante » se réorganise moins.
- Les listes virtualisées sont une réponse à la surpopulation du DOM : rendre des milliers de nœuds casse le FPS et la batterie ; la virtualisation n’affiche que ce qui est visible.
- Le comportement du bouton « Retour » est un contrat UX historique : les navigateurs ont appris aux gens que Retour restaure l’état ; le défilement infini le casse sauf si vous gérez l’historique correctement.
- Le caching HTTP aime les URLs stables : les URLs paginées se cache bien ; « donne-moi la suite du feed après le curseur X » est aussi cacheable, mais seulement si vous le concevez ainsi.
Modèles qui n’énervent pas les utilisateurs
Modèle 1 : pagination pour les listes guidées par l’intention
Utilisez la pagination quand l’utilisateur se soucie de la position et du retour : résultats de recherche, tableaux d’administration, journaux d’audit, rapports, inventaire. Donnez :
- Paramètres d’URL stables (query + page ou curseur).
- Progression visible (numéros de page ou « 1–50 sur 12 430 »).
- Contrôles compatibles navigation clavier et lecteurs d’écran.
- Un « saut vers la page » seulement si vraiment nécessaire (plus loin sur ce point).
Modèle 2 : défilement infini pour la navigation passive
Le défilement infini convient lorsque le travail de l’utilisateur est de « continuer à regarder ». Mais n’allez pas coller mécaniquement le système TikTok par défaut dans un journal d’audit d’entreprise et appeler ça moderne.
Un défilement infini réussi présente quelques traits :
- Fort comportement « reprendre là où j’en étais » après Retour/avance/navigation.
- États de chargement explicites (et un arrêt clair en cas d’erreur).
- Virtualisation, sinon votre UI devient un radiateur d’espace.
- Une sortie : accès au footer, « Retour en haut », « Aller aux filtres », et un état de fin de liste quand pertinent.
Blague #1 : Le défilement infini, c’est comme un buffet : délicieux jusqu’à ce que vous réalisiez que vous ne trouvez plus la sortie et que votre téléphone est à 3 %.
Modèle 3 : « Charger plus » pour un défilement infini plus calme
Si vous voulez l’engagement du défilement infini sans le chaos, sortez un bouton Charger plus. C’est explicite, débogable et plus accessible. Ça évite aussi les « scroll storms » accidentels quand un trackpad se montre zélé.
Modèle 4 : « Pagination avec préfetch » pour la vitesse sans perdre la structure
La pagination n’a pas à être lente. Préchargez la page suivante quand l’utilisateur a atteint 70–80 % de la page courante, puis échangez instantanément au clic. Gardez la frontière de page pour les URLs et l’analytics, mais supprimez l’attente.
Modèle 5 : « Défilement ancré » pour une sémantique d’historique correcte
C’est la version adulte du défilement infini : au fur et à mesure que l’utilisateur défile, vous mettez à jour l’URL pour refléter l’ancre courante (numéro de page ou curseur) et vous stockez la position de défilement dans l’état d’historique. Retour le ramène à l’endroit exact. C’est du travail en plus. C’est aussi la façon d’éviter les tickets de support qui commencent par « je l’ai perdu ».
Pagination bien faite (UI + API)
Règles UI qui évitent le rage-click
- Montrez toujours où est l’utilisateur : numéro de page et intervalle de résultats. « Page 7 » vaut mieux que « plus de trucs en bas ».
- Gardez la taille de page prévisible : changer le nombre d’éléments par page en cours de session casse la cartographie mentale.
- Faites fonctionner « Retour » : stockez l’état dans l’URL et restaurez filtres/tri. Si votre appli demande trois clics pour revenir où ils étaient, vous avez construit un labyrinthe.
- Évitez les liens de pages excessifs : montrez une fenêtre (ex. 1 … 6 7 8 … 200). Les utilisateurs ne veulent pas une vue calendrier de votre dataset.
- Autorisez le « saut vers une page » seulement avec garde-fous : sauts vers des pages profondes peuvent être coûteux et incohérents sauf si votre backend le supporte efficacement.
Règles API : offset vs curseur, et quand chacun pose problème
Pagination par offset (page=7, size=50) est simple et stable pour les petits jeux de données. Elle se dégrade quand :
- Vous avez un paging profond (page 500+).
- Des lignes sont insérées/supprimées fréquemment ; « page 7 » dérive et des duplicatas apparaissent.
- Votre requête DB devient une opération O(n) de saut.
Pagination par curseur (after=cursor) est meilleure pour les datasets volumineux et dynamiques. Elle requiert :
- Une clé de tri stable (timestamp + ID de départage, ou une clé primaire monotone).
- Un token de curseur encodant la « dernière position vue ».
- Une réflexion soignée autour des filtres et tris pour que les curseurs restent valides.
Concevez votre ordre de tri comme si c’était sérieux
La pagination par curseur n’est aussi bonne que l’ordre que vous utilisez. « ORDER BY updated_at DESC » paraît raisonnable jusqu’à ce que vous vous rappeliez que les mises à jour arrivent. Les éléments sautent alors et les curseurs deviennent peu fiables. Préférez des clés d’ordre immuables :
- Temps de création pour les feeds (si le concept est la « nouveauté »).
- Ordre de clé primaire pour les listes d’administration (si la « stabilité » compte plus que le sens).
- Clés composites pour l’unicité (created_at, id) pour éviter les duplicatas quand les timestamps sont égaux.
Rendez les frontières de page cacheables
La pagination brille quand vous pouvez mettre en cache. Si votre endpoint est « /search?q=…&page=3 », c’est une clé de cache propre. Avec les curseurs, les clés de cache peuvent aussi fonctionner, mais seulement si les curseurs sont stables et pas des secrets spécifiques à l’utilisateur. Si ce sont des jetons par utilisateur, attendez-vous à des taux de hits de cache plus faibles et planifiez la capacité en conséquence.
Défilement infini bien fait (sans chaos)
Règle 1 : virtualisez la liste ou payez en batterie et bugs
Si vous ajoutez des nœuds DOM sans arrêt, vous finirez par faire planter mobile Safari, ou au minimum dégrader le défilement en diaporama. Virtualiser signifie n’afficher que les éléments visibles plus une marge, en gardant la taille du DOM bornée.
Règle 2 : contrôlez la concurrence des requêtes
Le défilement infini déclenche des fetchs en fonction de la position de défilement. Sans limites de concurrence vous allez :
- Envoyer plusieurs requêtes chevauchantes pour le même curseur.
- Faire concourir les réponses et réordonner les éléments.
- Marteler votre backend quand l’utilisateur lance un fling vers le bas.
Utilisez une seule requête en vol par segment de feed. Annulez les requêtes obsolètes. Dédupliquez les éléments par ID.
Règle 3 : rendre les états d’erreur finaux et récupérables
Quand une requête échoue, ne laissez pas tourner indéfiniment. Affichez un bouton « Réessayer ». Loggez le curseur, l’état des filtres et l’ID de corrélation afin de pouvoir rejouer le problème côté serveur. « Quelque chose s’est mal passé » sans contexte est la version ingénieur du haussement d’épaules.
Règle 4 : corriger explicitement la sémantique d’historique
« Retour » doit vous ramener au même élément. Cela requiert :
- Sauvegarder la position de défilement dans l’état d’historique (pas seulement en mémoire).
- Mettre à jour l’URL quand l’ancre change (numéro de page ou curseur).
- Restaurer les éléments à partir du cache (côté client) ou refetcher rapidement.
Règle 5 : fournir une issue de secours vers le footer
Le défilement infini enlève souvent le footer, ce qui supprime la navigation, les liens de support et les mentions légales. Les utilisateurs ont toujours besoin de ces éléments. Donnez-leur un moyen d’atteindre le bas ou fournissez un footer/sticky utility panel.
Blague #2 : Déboguer le défilement infini sans logs, c’est comme rassembler des chats — sauf que les chats sont des requêtes HTTP et qu’ils savent où vous habitez.
Patterns hybrides qui fonctionnent en production
Hybride A : défilement infini à l’intérieur d’une limite de page
Vous affichez « Page 1 » avec 50 éléments, mais les chargez progressivement au fur et à mesure que l’utilisateur défile, et conservez l’URL et l’état comme « page=1 ». Quand l’utilisateur atteint la fin, il clique sur « Page suivante ». Cela réduit le temps de chargement initial et donne malgré tout une structure.
Hybride B : « Charger plus » avec numéros de page dans l’URL
Chaque « Charger plus » incrémente un compteur de page interne et met à jour l’URL pour refléter la dernière page chargée. Le Retour fonctionne, l’analytics peut attribuer l’engagement aux pages, et l’utilisateur garde un défilement ininterrompu.
Hybride C : navigation à deux niveaux pour « explorer puis affiner »
Commencez par du défilement infini pour la découverte, mais lorsque l’utilisateur applique des filtres ou trie, basculez en pagination. Le filtrage change l’intention : les gens arrêtent d’errer et commencent à chasser. Votre UI doit détecter ce changement.
Performance et stockage : ce qui casse en premier
Le coût caché en stockage du « encore une page »
Vu depuis la place d’un ingénieur stockage, le défilement infini tend à créer de longues sessions avec de nombreuses petites requêtes. Cela change votre profil I/O :
- Plus d’amplification de lecture dans la base si le paging profond se fait par offset.
- Plus de churn du cache au niveau CDN ou reverse proxy si les tokens de curseur ne sont pas cacheables.
- Plus de pression sur le stockage d’objets si chaque carte référence plusieurs images et que vous lazy-load sans headers de cache.
Conception des requêtes backend : le tueur silencieux
Si votre API utilise « OFFSET … LIMIT … » sur une grande table, vous verrez la latence augmenter approximativement avec la profondeur d’offset. En production, cela se traduit par des pics de latence tail. La latence tail est ce dont se souviennent les utilisateurs.
Les requêtes par curseur ressemblent généralement à « WHERE (created_at, id) < (last_created_at, last_id) ORDER BY created_at DESC, id DESC LIMIT 50. » Cela scale mieux, utilise les index efficacement et se comporte face aux écritures concurrentes.
Performance côté client : mémoire et temps du thread principal
Sans virtualisation, le navigateur conserve chaque nœud rendu, images, handlers d’événements et état de layout. La mémoire croit. Le garbage collector devient coûteux. Le scroll rame. Le CPU se réveille plus souvent, ce qui tue la batterie mobile.
Rate limiting : votre dernier rempart
Les bugs de défilement infini peuvent déclencher des inondations de trafic accidentelles. Limitez le débit par utilisateur et par IP. Mais faites-le intelligemment : les limites doivent dégrader gracieusement (« ralentissez, réessayez ») et non échouer brutalement en affichant un écran blanc.
Observabilité : mesurer ce que ressent l’utilisateur
Si vous ne pouvez pas le mesurer, vous allez en débattre. Instrumentez à la fois l’UI et le backend avec un ID de requête partagé. Puis mesurez :
- Temps jusqu’aux premiers éléments significatifs : à quelle vitesse le contenu apparaît ?
- Indicateurs de jank au scroll : longues tâches sur le main thread, chutes de frames.
- Requêtes par session : le défilement infini augmente souvent les appels ; vérifiez si cela vaut le coût.
- Taux d’erreur par curseur/page : les échecs peuvent se concentrer sur les pages profondes à cause d’une inefficacité des requêtes.
- Abandon après chargement : les gens partent-ils parce que le chargement est lent ou parce que le contenu n’est pas pertinent ?
- Succès de la navigation Retour : mesurez à quelle fréquence le retour restaure la position précédente.
Un truc pratique : loggez le plus grand index d’item atteint et l’ultime ancre stable (page ou curseur). Ça transforme « les utilisateurs détestent ça » en « 70 % des sessions ne chargent jamais au-delà de 40 éléments, donc arrêtez de précharger 200. »
Trois mini-récits d’entreprise issus du terrain
Mini-récit 1 : l’incident causé par une mauvaise hypothèse
Nous avons hérité d’une console d’administration interne affichant des événements d’audit. Un chef de produit a demandé « un défilement infini comme les apps modernes » parce que la pagination semblait vieillotte. L’équipe a suivi. Ça a été livré avec de la pagination par offset sous le capot : chaque scroll appelait le même endpoint avec offset et limit.
L’hypothèse était : « Personne ne scrolle aussi loin. » C’était vrai pour la navigation occasionnelle. Pas pour la réponse à incident. Pendant une enquête de sécurité, des analystes ont scrollé sur des heures, puis des jours. Les requêtes offset devenaient de plus en plus profondes et la latence a grimpé. L’UI a réagi en déclenchant encore plus de requêtes car le seuil de scroll continuait d’être atteint pendant que la requête précédente était encore en cours.
La base de données a fait ce que font les bases quand on lui demande de sauter une montagne de lignes : elle est devenue chaude. Le CPU a monté. Le lag de réplication est apparu. Soudain, la console d’administration n’était plus la seule affectée : d’autres services partageant le même cluster ont commencé à timeout. L’on-call a dû limiter le endpoint et désactiver le défilement infini derrière un feature flag.
La solution n’a pas été « ajouter plus de BD ». La solution a été la pagination par curseur avec un index qui correspondait à l’ordre de tri, plus une porte de concurrence côté client (une seule requête en vol). Nous avons aussi ajouté un filtre « Sauter à l’heure », parce que les enquêteurs ne veulent pas scroller mardi pour atteindre lundi ; ils veulent un timestamp.
Mini-récit 2 : l’optimisation qui s’est retournée contre eux
Une autre équipe a essayé de rendre un feed instantané en préchargeant agressivement : au chargement de page, fetch des pages 1, 2, 3 et 4 en parallèle. Ils étaient fiers. Leurs tableaux montraient une excellente latence médiane pour le « first page render », parce que la première page revenait vite et le reste chargeait discrètement en arrière-plan.
Puis les utilisateurs mobiles ont commencé à se plaindre de la batterie et de la consommation de données. Pendant ce temps, le taux de cache s’est dégradé : les requêtes de préfetch étaient personnalisées, les tokens de curseur étaient spécifiques à l’utilisateur, et le hit rate a chuté. Le backend a vu un saut du nombre de requêtes par session, même pour des utilisateurs qui rebondissaient après cinq secondes.
Le vrai mode d’échec était la coordination. Le préfetch ne respectait pas l’intention utilisateur. Il supposait que chaque session serait profonde. Il a aussi créé des motifs de trafic en rafale : chaque vue de page déclenchait plusieurs appels, augmentant la charge aux heures de pointe de façon très synchronisée.
La correction a été ennuyeuse : précharger une seule page en avance, seulement après que l’utilisateur montre une intention (scroll au-delà d’un seuil), et ne jamais paralléliser avec le rendu initial. Nous avons aussi ajouté des hints serveur : retourner has_more et un prefetch_after_ms recommandé dans la réponse pour un réglage dynamique. Le feed paraissait identique. L’infrastructure s’est calmée.
Mini-récit 3 : la pratique ennuyeuse mais correcte qui a sauvé la mise
Une équipe recherche e-commerce voulait du défilement infini pour augmenter l’engagement. Le SRE était sceptique. Le compromis a été un déploiement progressif avec garde-fous : feature flag, canaries, budgets d’erreur stricts et un kill switch actionnable sans déploiement.
Ils ont aussi fait le travail peu glamour : tests synthétiques qui scrollaient jusqu’à une profondeur fixe, capturaient des traces waterfall, et validaient que « Retour » restaure la position de défilement. Ils ont gardé les URLs de pagination même en utilisant « Charger plus », mettant à jour l’état d’historique au fur et à mesure.
Deux semaines après le lancement, un changement de dépendance dans le service de redimensionnement d’images a augmenté les temps de réponse. Le nouveau UI de défilement infini a amplifié l’effet parce que les utilisateurs chargeaient plus d’images par session. Mais comme l’équipe avait des métriques pour « requêtes par session » et « temps jusqu’au prochain lot », ils ont vu la régression rapidement et utilisé le kill switch pour revenir à la navigation paginée pendant la réparation du service d’images.
Pas de drame. Pas de war room. Un plan ennuyeux a fait des choses ennuyeuses, ce qui est exactement ce qu’on veut en production.
Mode d’intervention rapide
Quand les utilisateurs rapportent « le scroll est cassé » ou « la pagination est lente », ne commencez pas par débattre l’UI. Trouvez le goulot d’étranglement en trois passes.
Premier : est-ce du jank côté client ou de la latence réseau/backend ?
- Vérifiez les traces de performance du navigateur (longues tâches, thrash de layout, croissance mémoire).
- Vérifiez la waterfall des requêtes : les appels sont-ils lents, ou simplement trop nombreux ?
- Vérifiez si les images dominent le temps de transfert.
Second : le modèle de pagination de l’API est-il en conflit avec le modèle de données ?
- Offset deep paging ? Attendez-vous à des scans DB et à de la latence tail.
- Pagination par curseur mais ordre instable ? Attendez-vous à des duplicatas/éléments manquants et des utilisateurs en colère.
- Filtres non inclus dans le curseur ? Attendez-vous à un « next page » erroné.
Troisième : le caching/les rate limits font-ils quelque chose d’inattendu ?
- Le taux de hit du cache a chuté après le rollout du défilement infini ? Les tokens de curseur sont probablement non-cacheables ou trop granulaires.
- Les 429s grimpent ? Le frontend peut surfetcher ou retry trop agressivement.
- Les octets CDN ont augmenté ? Le lazy-loading d’images peut déclencher trop de variantes uniques.
Tâches pratiques : commandes, sorties et décisions
Voici des tâches que vous pouvez lancer aujourd’hui pour cesser de deviner. Chacune inclut une commande réaliste, ce que la sortie signifie, et la décision à en prendre.
Task 1 : Confirmer si des requêtes OFFSET profondes ont lieu
cr0x@server:~$ sudo grep -E "OFFSET [1-9][0-9]{4,}" /var/log/postgresql/postgresql-15-main.log | tail -n 3
2025-12-29 09:10:02 UTC LOG: duration: 812.433 ms statement: SELECT id, created_at FROM events ORDER BY created_at DESC OFFSET 50000 LIMIT 50;
2025-12-29 09:10:03 UTC LOG: duration: 944.120 ms statement: SELECT id, created_at FROM events ORDER BY created_at DESC OFFSET 60000 LIMIT 50;
2025-12-29 09:10:04 UTC LOG: duration: 1102.009 ms statement: SELECT id, created_at FROM events ORDER BY created_at DESC OFFSET 70000 LIMIT 50;
Sens : Vous effectuez de la pagination par offset profonde ; la latence augmente avec l’offset.
Décision : Passez à la pagination par curseur ou ajoutez un filtre temporel/saut ; ne « passez pas » par l’optimisation en ajoutant plus de retries applicatifs.
Task 2 : Vérifier l’utilisation d’index pour la requête paginée
cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, created_at FROM events ORDER BY created_at DESC OFFSET 50000 LIMIT 50;"
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
Limit (cost=28450.12..28450.25 rows=50 width=16) (actual time=801.122..801.140 rows=50 loops=1)
Buffers: shared hit=120 read=980
-> Gather Merge (cost=23650.00..28600.00 rows=120000 width=16) (actual time=620.440..796.300 rows=50050 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=120 read=980
-> Sort (cost=22650.00..22800.00 rows=60000 width=16) (actual time=580.110..590.230 rows=25025 loops=3)
Sort Key: created_at DESC
Sort Method: external merge Disk: 14560kB
-> Seq Scan on events (cost=0.00..12000.00 rows=60000 width=16) (actual time=0.220..220.300 rows=60000 loops=3)
Planning Time: 0.220 ms
Execution Time: 802.010 ms
Sens : Seq scan + tri + external merge : vous payez le paging profond avec du travail disque.
Décision : Ajoutez un index correspondant au tri et basculez vers la pagination par curseur ; si vous devez garder l’offset pour l’instant, limitez la profondeur maximale de page.
Task 3 : Repérer les plaintes de duplicata d’items en corrélant les tokens de curseur
cr0x@server:~$ sudo grep "cursor=" /var/log/nginx/access.log | awk '{print $7}' | tail -n 5
/feed?limit=50&cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6IjIwMjUtMTItMjlUMDk6MDk6MDAuMDAwWiJ9
/feed?limit=50&cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6IjIwMjUtMTItMjlUMDk6MDk6MDAuMDAwWiJ9
/feed?limit=50&cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6IjIwMjUtMTItMjlUMDk6MDk6MDAuMDAwWiJ9
/feed?limit=50&cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6IjIwMjUtMTItMjlUMDk6MDk6MDAuMDAwWiJ9
/feed?limit=50&cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6IjIwMjUtMTItMjlUMDk6MDk6MDAuMDAwWiJ9
Sens : Même curseur répété : le client refetch la même page (probablement boucle de retry ou bug de concurrence).
Décision : Ajoutez de la déduplication côté client pour les requêtes en vol, un backoff sur les retries, et de l’idempotence/dédoublonnage côté serveur par ID de requête.
Task 4 : Vérifier le rate limiting et voir si le défilement infini le déclenche
cr0x@server:~$ awk '$9==429 {count++} END{print "429s:", count}' /var/log/nginx/access.log
429s: 384
Sens : Des utilisateurs sont throttlés. C’est souvent auto-infligé par un overfetch + retry.
Décision : Ajustez les seuils et retries frontend ; ajoutez des hints serveur (retry-after) et assurez-vous que le rate limiting est par utilisateur et non global.
Task 5 : Vérifier si les réponses sont cacheables
cr0x@server:~$ curl -sI "http://app.internal/search?q=router&page=2" | egrep -i "cache-control|etag|vary"
Cache-Control: public, max-age=60
ETag: "9a1d-17c2f2c"
Vary: Accept-Encoding
Sens : Bon : réponse cacheable avec ETag ; les URLs paginées se cachent probablement bien.
Décision : Gardez les URLs de pagination stables ; pour le défilement infini/curseurs, envisagez des tokens de curseur compatibles cache et des TTL courts.
Task 6 : Détecter des tokens de curseur personnalisés qui tuent le hit rate du cache
cr0x@server:~$ curl -sI "http://app.internal/feed?limit=50&cursor=abc" | egrep -i "cache-control|vary|set-cookie"
Cache-Control: private, no-store
Vary: Authorization
Set-Cookie: session=...
Sens : La réponse est explicitement non-cacheable et varie selon Authorization.
Décision : Acceptez le coût (plan de capacité), ou redesign : séparez les données personnalisées du contenu public des cartes ; mettez en cache ce que vous pouvez.
Task 7 : Trouver des tempêtes de requêtes induites par l’UI (requêtes par minute)
cr0x@server:~$ sudo awk '{print $4}' /var/log/nginx/access.log | cut -d: -f1,2 | sort | uniq -c | tail -n 5
812 [29/Dec/2025:09:09
945 [29/Dec/2025:09:10
990 [29/Dec/2025:09:11
1044 [29/Dec/2025:09:12
1202 [29/Dec/2025:09:13
Sens : Le trafic monte rapidement. Si cela coïncide avec un déploiement frontend, suspectez des triggers/prefetch du défilement infini.
Décision : Rollback ou désactivez le feature flag ; puis corrigez les seuils et limites de concurrence.
Task 8 : Confirmer la croissance mémoire client via le RSS du process node (SSR ou BFF)
cr0x@server:~$ ps -o pid,rss,cmd -C node | head -n 5
PID RSS CMD
3221 485000 node server.js
3380 512300 node server.js
Sens : Le RSS est grand et croît ; cela peut être du rendu côté serveur qui garde trop d’état de liste par session.
Décision : Arrêtez de conserver l’état de liste par session côté serveur ; mettez en cache des templates ou fragments, pas l’historique de scroll spécifique utilisateur.
Task 9 : Mesurer la latence tail de l’API en edge (stats proxy p95/p99)
cr0x@server:~$ sudo awk '$7 ~ /^\/feed/ {print $NF}' /var/log/nginx/access.log | tail -n 5
rt=0.112
rt=0.984
rt=1.203
rt=0.221
rt=1.544
Sens : Les temps de réponse varient fortement ; le p99 donnera l’impression que « l’app est cassée » même si la médiane est correcte.
Décision : Corrigez la forme des requêtes backend ; ajoutez timeouts + UI de secours ; réduisez la charge utile par requête.
Task 10 : Confirmer la pression I/O disque pendant le paging profond
cr0x@server:~$ iostat -xm 1 3
Linux 6.5.0 (db01) 12/29/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
12.40 0.00 6.10 9.80 0.00 71.70
Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s w_await aqu-sz %util
nvme0n1 520.0 41280.0 0.0 0.00 18.20 79.38 40.0 2048.0 3.10 9.55 88.00
Sens : Forte I/O en lecture et forte utilisation ; le paging profond peut forcer des lectures disque et des tris.
Décision : Corrigez les index et le plan de requête ; ajoutez du caching ; envisagez des replicas de lecture seulement après avoir assaini la forme de la requête.
Task 11 : Vérifier le caching CDN/objet pour les images dans les listes
cr0x@server:~$ curl -sI "http://cdn.internal/images/item123?w=640" | egrep -i "cache-control|age|etag"
Cache-Control: public, max-age=31536000, immutable
ETag: "img-7c21"
Age: 18422
Sens : Bon caching. Le défilement infini chargera tout de même beaucoup d’images, mais les vues répétées ne re-téléchargeront pas autant.
Décision : Gardez les URLs d’images stables et immuables ; évitez de générer des URLs uniques par requête.
Task 12 : Détecter les erreurs « spinner sans fin » dans les logs client envoyés au serveur
cr0x@server:~$ sudo grep -E "feed_load_failed|pagination_fetch_error" /var/log/app/client-events.log | tail -n 5
2025-12-29T09:11:22Z feed_load_failed cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6Ii4uLiJ9 status=504
2025-12-29T09:11:25Z feed_load_failed cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6Ii4uLiJ9 status=504
2025-12-29T09:11:28Z feed_load_failed cursor=eyJsYXN0X2lkIjoxMjM0NTYsImxhc3RfY3JlYXRlZF9hdCI6Ii4uLiJ9 status=504
Sens : 504 répétés pour le même curseur : timeout backend plus boucle de retry client.
Décision : Ajoutez un backoff exponentiel et un bouton « Réessayer » visible pour l’utilisateur ; corrigez la cause du timeout backend avant d’augmenter les timeouts.
Task 13 : Confirmer que l’API retourne des clés d’ordre stables pour les curseurs
cr0x@server:~$ curl -s "http://app.internal/feed?limit=3" | jq '.items[] | {id, created_at}'
{
"id": 981223,
"created_at": "2025-12-29T09:13:01.002Z"
}
{
"id": 981222,
"created_at": "2025-12-29T09:13:00.991Z"
}
{
"id": 981221,
"created_at": "2025-12-29T09:13:00.990Z"
}
Sens : La liste expose des clés stables ; vous pouvez construire un curseur sur (created_at, id).
Décision : Utilisez un curseur composite ; évitez d’ordonner par des champs mutables comme updated_at pour la pagination principale.
Task 14 : Vérifier si des ancres d’historique HTML existent pour le SEO et la navigation Retour
cr0x@server:~$ curl -s "http://app.internal/search?q=router&page=2" | grep -Eo 'rel="(next|prev)"' | sort | uniq -c
1 rel="next"
1 rel="prev"
Sens : La page déclare des relations next/prev. Cela aide les crawlers et clarifie la structure de navigation.
Décision : Conservez cela pour les listes paginées ; pour le défilement infini, exposez des URLs paginées équivalentes en backend.
Erreurs courantes : symptômes → cause racine → correctif
1) « Le bouton Retour me ramène en haut »
Symptôme : Les utilisateurs cliquent un élément, reviennent et perdent leur place.
Cause racine : La position de défilement n’est pas persistée ; l’URL n’est pas mise à jour avec une ancre ; l’état de liste est jeté.
Fix : Stockez la position de défilement dans l’état d’historique ; mettez à jour l’URL avec la page/curseur courants ; restaurez depuis le cache d’items ou refetch rapidement.
2) « Je vois des duplicatas / des éléments manquants en scrollant »
Symptôme : Les éléments se répètent, ou des trous apparaissent après chargement.
Cause racine : Tri instable (updated_at), curseur non lié à une clé d’ordre unique, requêtes concurrentes en compétition, ou absence de déduplication.
Fix : Utilisez un ordre immuable (created_at + id) ; imposez une seule requête en vol ; dédupliquez par ID côté client et serveur.
3) « Ça charge sans fin » (spinner infini)
Symptôme : Le loader tourne ; rien de nouveau n’apparaît ; l’utilisateur continue de scroller.
Cause racine : Boucle de retry sur 5xx/timeout ; état d’échec non affiché ; le trigger de scroll continue de se déclencher.
Fix : Rendre les échecs finaux avec « Réessayer » ; ajouter un backoff exponentiel ; comportment de circuit breaker ; loggez le curseur et l’ID de requête.
4) « La page 1 est rapide, la page 200 inutilisable »
Symptôme : La navigation profonde est lente ; le p99 explose.
Cause racine : Pagination par offset scannant de larges plages ; index composites manquants.
Fix : Pagination par curseur ; ajoutez un index correspondant ; limitez l’accès aux pages profondes ; proposez des sauts basés sur le temps/filtre.
5) « Le scroll est saccadé sur mobile »
Symptôme : FPS bas, réponse tactile retardée, appareil qui chauffe.
Cause racine : Trop de nœuds DOM, images lourdes, thrash de layout, travail synchrone sur les événements de scroll.
Fix : Virtualisez ; utilisez des placeholders d’images et tailles correctes ; évitez le travail synchrone sur les événements de scroll ; throttlez les observers.
6) « Les analytics sont absurdes après le passage au défilement infini »
Symptôme : Les conversions chutent (ou montent) mystérieusement ; l’attribution casse.
Cause racine : Le tracking basé pageview ne correspond pas au défilement infini ; absence d’événements pour « items vus » et « profondeur atteinte ».
Fix : Suivez les événements d’exposition (items rendus/visibles), profondeur atteinte et changements d’ancre ; gardez les URLs à jour pour conserver la sémantique.
7) « Notre hit rate de cache s’est effondré »
Symptôme : Le ratio hits du CDN/reverse proxy chute après le rollout.
Cause racine : Curseurs personnalisés, variation Authorization, headers private/no-store.
Fix : Séparez public et privé ; mettez en cache les payloads de carte séparément ; rendez les tokens de curseur déterministes ; utilisez des TTL courts quand c’est sûr.
8) « Les utilisateurs ne peuvent pas atteindre le footer »
Symptôme : Le support dit « Je ne trouve pas contact/mentions/settings. »
Cause racine : Le défilement infini a supprimé la fin naturelle de page.
Fix : Fournissez une barre utilitaire sticky, un moyen d’accéder au footer via « Pause loading », ou une option « Aller au footer ».
Checklists / plan pas-à-pas
Pas-à-pas : choisir le pattern
- Classifiez l’intention : recherche/comparaison (pagination) vs navigation (infinite ou charger-plus).
- Définissez « retourner à ma position » : est-ce requis ? Si oui, concevez les ancres d’historique dès le départ.
- Choisissez un modèle d’API : curseur pour les datasets larges/dynamiques ; offset seulement pour les listes petites et stables.
- Décidez des clés d’ordre stables : clés de tri immuables avec tiebreaker.
- Fixez un budget de performance : temps max jusqu’au prochain lot, requêtes max par session, nœuds DOM max.
- Planifiez le caching : ce qui peut être public, ce qui doit être privé, et où les TTL ont du sens.
- Instrumentez les sémantiques analytics : exposition, profondeur, changements d’ancre, comportement de retry.
- Déployez avec garde-fous : feature flag, canary et kill switch.
Checklist : UI pagination qui n’irrite pas
- L’URL reflète l’état (filtres/tri/page/curseur).
- Affiche le nombre total ou une approximation significative (et le labelle honnêtement).
- Contrôles accessibles clavier, gestion du focus, et labels ARIA.
- Next/prev plus fenêtre de pages ; pas de monstruosité à 200 liens.
- Préchargez la page suivante seulement si cela n’engendre pas de rafales de trafic.
- L’accès aux pages profondes est supporté efficacement ou délibérément restreint.
Checklist : défilement infini qui ne fait pas fondre les appareils
- Virtualisation activée ; nombre de nœuds DOM borné.
- Une seule requête en vol ; annule les appels obsolètes ; dédupe les items.
- États d’erreur clairs avec Réessayer ; pas de spinner infini.
- État d’historique + mise à jour d’ancre URL ; Retour restaure la position.
- Footer/navigation accessible via un élément UI persistant.
- Budget de requêtes appliqué (profondeur max, préfetch max, chargements médias concurrents max).
Checklist : exigences backend pour les deux patterns
- L’index correspond à l’ordre de tri.
- Les tokens de curseur incluent tout le contexte d’ordre/filtres nécessaire ou sont rejetés en toute sécurité.
- La réponse inclut
has_moreet un curseur/page suivante. - Rate limiting et retries coordonnés (429 avec Retry-After).
- Observabilité : IDs de requête, curseur/page dans les logs, et percentiles de latence.
FAQ
1) Dois-je toujours préférer le défilement infini sur mobile ?
Non. Les utilisateurs mobiles ont moins de patience pour des chargements lents et moins de mémoire pour des DOM gigantesques. Si la tâche est une recherche/comparaison, la pagination (ou « charger plus ») est souvent meilleure.
2) « Charger plus » n’est-il que de la pagination paresseuse ?
C’est de la pagination avec une interaction plus conviviale. C’est explicite, plus facile à rendre accessible et plus simple à déboguer. Pour beaucoup de produits, c’est le compromis idéal.
3) Pourquoi la pagination par offset devient-elle lente aux grands numéros de page ?
Parce que la base de données doit souvent scanner/sauter beaucoup de lignes pour atteindre l’offset, puis trier ou filtrer. Même avec des index, les offsets profonds peuvent forcer un travail proportionnel à la distance sautée.
4) La pagination par curseur corrigera-t-elle complètement les duplicatas ?
Elle corrige beaucoup de causes, mais pas toutes. Il vous faut toujours des clés d’ordre stables et un tiebreaker. Et vous devrez encore faire de la déduplication client si des requêtes en double peuvent être émises.
5) Comment rendre le défilement infini compatible SEO ?
Exposez des URLs paginées représentant les mêmes tranches de contenu, et rendez-les accessibles (server-rendered ou au moins discoverable). Le défilement infini peut être l’expérience client ; la structure crawlable doit toujours exister.
6) Les utilisateurs préfèrent-ils le défilement infini ?
Les utilisateurs préfèrent ce qui leur permet d’achever leur tâche avec le moins de friction. Pour la navigation, le défilement peut sembler fluide. Pour trouver, comparer et revenir, la pagination gagne généralement.
7) Quelle est la manière la plus simple d’empêcher le défilement infini de provoquer des pics de trafic ?
Imposez une requête en vol, préchargez au maximum une page en avance, et exigez une intention utilisateur (seuil de scroll) avant le préfetch. Ajoutez du backoff et limitez les retries.
8) Dois-je afficher le nombre total de résultats ?
Si les utilisateurs prennent des décisions basées sur l’étendue (« seulement 23 résultats » vs « 12 000 résultats »), oui. Si les comptes sont coûteux, affichez une estimation ou omettez-les en montrant des plages — ne mentez pas.
9) Puis-je garder des numéros de page avec la pagination par curseur ?
C’est possible, mais délicat. La pagination par curseur ne se prête pas naturellement aux sauts arbitraires de page. Si les numéros de page sont requis, pensez à stocker des curseurs par page dans la session client, ou fournissez plutôt des sauts basés sur le temps.
10) Quel est le défaut par défaut pour les tableaux d’administration en entreprise ?
Pagination, avec tri et filtrage côté serveur, et URLs stables. Ajoutez « charger plus » seulement si vous pouvez garantir le comportement Retour et la performance en cas d’usage profond.
Conclusion : prochaines étapes qui ne gâcheront pas votre trimestre
Si votre liste est un outil, livrez la pagination (ou « charger plus ») avec des URLs stables et des APIs basées sur curseur. Si votre liste est du divertissement, le défilement infini peut convenir — mais seulement avec virtualisation, contrôle de concurrence et vraie sémantique d’historique.
Prochaines étapes qui rapportent rapidement :
- Auditez vos requêtes backend pour repérer l’usage profond de
OFFSETet corrigez la forme des requêtes avant de modifier l’UI. - Décidez et documentez la clé d’ordre pour la pagination et rendez-la immuable avec un tiebreaker.
- Ajoutez un modèle d’ancre (page/curseur) aux URLs et à l’état d’historique pour que Retour se comporte comme les utilisateurs s’y attendent.
- Instrumentez profondeur, exposition, retries et latence tail — puis fixez un budget de requêtes par session.
- Déployez avec un kill switch. Vous vous en remercierez plus tard, généralement à 02:13 du matin.