Blog
Feature
5 janvier 20269 min

Feed React Native : de 3s a 200ms avec cache et RPC

Chaque app a cet écran qui concentre toute la complexité. Pour TAMSIV, c'est le Feed. Un flux unique qui mélange tout : activité récente, tâches complétées, mémos créés, événements du calendrier, badges débloqués, progression de niveau, activité de groupe. C'est l'écran le plus ambitieux de l'app — et celui qui m'a pris le plus de temps à construire.

Ce que je vais te raconter ici, c'est le parcours complet : de la première version qui mettait 3 secondes à charger à la version actuelle qui se charge instantanément depuis le cache. Les choix techniques, les optimisations, et les leçons apprises en trois semaines de travail intense.

Points clés à retenir :
- Une seule RPC PostgreSQL (get_consolidated_feed) agrège tous les types de contenu
- L'optimisation des JOINs et l'indexation ont réduit le chargement de 3s à 200ms
- Le cache L1 (mémoire) + L2 (AsyncStorage) permet un affichage instantané
- Les signed URLs Supabase expirent après 60 minutes — le refresh batch est critique
- Chaque type d'élément a son composant dédié pour une FlatList performante

Pourquoi un feed unifié plutôt que des écrans séparés ?

La question est légitime. Pourquoi ne pas avoir un écran "tâches récentes", un écran "badges", un écran "activité de groupe" ? Plusieurs raisons :

1. Réduction de la charge cognitive. L'utilisateur ouvre une seule vue et voit tout ce qui s'est passé. Pas besoin de naviguer entre 4 onglets pour avoir une vue d'ensemble. C'est le pattern qu'utilisent toutes les apps sociales — de Instagram à LinkedIn — et les utilisateurs le comprennent intuitivement.

2. Découverte passive. Un utilisateur qui ouvre le feed pour voir ses tâches récentes va aussi découvrir qu'il a débloqué un badge ou qu'un collègue a commenté dans un groupe. C'est du cross-engagement : une feature en pousse une autre.

3. Engagement par la gamification. Le système de gamification de TAMSIV (12 niveaux, 10 badges, streaks, challenges quotidiens) n'a d'impact que s'il est visible. Un badge débloqué dans un écran caché, personne ne le voit. Un badge dans le feed, tout le monde le célèbre.

Écran de smartphone montrant un flux d'activité avec des badges de réussite colorés, des barres de progression et des cartes de notification
Le Feed TAMSIV : tâches, mémos, badges et activité de groupe dans un flux unifié.

Comment fonctionne la RPC consolidée ?

Le cœur technique du feed, c'est une seule fonction PostgreSQL : get_consolidated_feed. Cette RPC agrège des données de 5 sources différentes :

  • Tâches récentes (schéma privat.)
  • Mémos récents (schéma privat.)
  • Événements du calendrier (schéma privat.)
  • Activité de gamification (schéma gamification.) — badges, level ups, streaks
  • Activité de groupe (schéma collaborative.) — tâches partagées, commentaires, assignations

Le tout retourne dans un type unifié avec un champ item_type pour le routing côté frontend. Chaque élément du feed est identifié par son type, et le frontend sait exactement quel composant rendre.

Pourquoi une RPC plutôt que des requêtes séparées ? Parce qu'une seule requête SQL est toujours plus rapide que 5 requêtes séparées, même avec le connection pooling de Supabase. Moins d'allers-retours réseau, moins de latence, et surtout la possibilité de trier et paginer au niveau de la base plutôt que côté client.

Comment optimiser une requête de 3 secondes à 200ms ?

Les premières versions du feed étaient pénibles. 3 secondes de chargement. Pour un écran d'accueil, c'est éliminatoire — les utilisateurs ferment l'app avant que le contenu apparaisse.

Le problème : des JOINs mal optimisés. La RPC initiale faisait des JOINs sur des tables sans index, avec des sous-requêtes corrélées. EXPLAIN ANALYZE montrait des sequential scans là où il fallait des index scans.

Écran d'ordinateur montrant un tableau de bord de monitoring de performance de base de données avec des graphiques colorés
Monitoring de performance : chaque milliseconde compte dans un feed.

Les optimisations appliquées :

1. Indexation ciblée. J'ai ajouté des index composites sur les colonnes utilisées dans les WHERE et ORDER BY de la RPC. Un index sur (user_id, created_at DESC) a divisé le temps de la requête par 3 à lui seul.

2. Réécriture des JOINs. Les sous-requêtes corrélées (SELECT dans SELECT) ont été remplacées par des JOINs latéraux. PostgreSQL les optimise beaucoup mieux.

3. Limitation des colonnes. Au lieu de SELECT *, je ne récupère que les colonnes nécessaires pour l'affichage dans le feed. Moins de données transférées = moins de temps.

4. Pagination server-side. La RPC accepte des paramètres p_offset et p_limit. Seuls 20 éléments sont chargés à la fois. La pagination infinie côté client demande les 20 suivants quand l'utilisateur approche la fin de la liste.

Résultat : de 3 secondes à 200ms. Un facteur 15 d'amélioration. C'est le genre d'optimisation qui transforme une app "utilisable" en une app "agréable".

Comment renderer chaque type d'élément efficacement ?

Le feed mélange des éléments très différents : une tâche a un titre, une priorité, une date limite. Un badge a un icon, un nom, une description. Un événement a une heure, un lieu, des participants. Chaque type nécessite un composant dédié.

Les composants du feed :

  • FeedTaskItem : tâche avec priorité, date, assignation
  • FeedMemoItem : mémo avec aperçu du contenu et image de couverture
  • FeedGamificationItem : badge, level up, streak milestone
  • FeedGroupItem : activité collaborative (nouveau membre, tâche assignée, commentaire)
  • FeedCalendarItem : événement à venir

Le tout dans une FlatList de react-native-gesture-handler (obligatoire, pas celle de react-native — voir les gotchas du gesture-handler) avec getItemLayout pour le calcul de hauteur et keyExtractor basé sur le couple (item_type, id).

Le pattern getItemLayout est crucial pour la performance : il permet à la FlatList de calculer la position de chaque élément sans le rendre. Sans ça, le scroll saccade quand la liste contient des centaines d'éléments.

Comment résoudre le problème des images expirées ?

C'est un des pièges les plus vicieux de Supabase Storage. Les signed URLs expirent après 60 minutes. Si un élément du feed contient une image (pièce jointe d'une tâche, couverture d'un mémo), l'URL stockée dans le feed expire et l'image ne s'affiche plus.

La solution en deux parties :

1. Stocker le storage_path, pas l'URL. La RPC get_consolidated_feed retourne un champ firstImageStoragePath en plus de firstImageUri. Le path est permanent, l'URL est temporaire.

2. Refresh batch des URLs. GamificationService.refreshFeedImageUrls() appelle StorageService.refreshAttachmentUrlsBatch() pour régénérer toutes les URLs expirées en un seul appel batch. Pas d'appel individuel par image — un seul appel pour toutes les images du feed.

Ce pattern est détaillé dans l'article sur la réduction de l'egress Supabase. Les signed URLs sont un pattern puissant pour la sécurité, mais elles créent une complexité de gestion que beaucoup de développeurs sous-estiment.

Comment fonctionne le cache multi-niveaux ?

Le cache du feed est le secret de l'affichage instantané. Il fonctionne en trois niveaux :

Cache L1 — Mémoire (Map). Les données du feed sont gardées dans une Map en mémoire via le ContentCacheService. C'est le plus rapide : accès en O(1), pas de deserialization. Le feed se charge depuis le L1 en moins de 10ms.

Cache L2 — AsyncStorage. Si le L1 est vide (premier lancement, redémarrage de l'app), les données sont récupérées depuis AsyncStorage. Plus lent que la mémoire (~50ms) mais plus rapide qu'un appel réseau.

Source de vérité — Supabase. En arrière-plan, les données fraîches sont récupérées depuis Supabase et le cache est mis à jour. L'utilisateur voit les données du cache immédiatement, puis la vue se met à jour silencieusement si de nouvelles données sont disponibles.

Personne détendue sur un canapé faisant défiler un flux d'application mobile avec du contenu qui charge de manière fluide
Le feed se charge instantanément depuis le cache, puis se met à jour en arrière-plan.

Le pattern "cache-first + background refresh" est utilisé par la plupart des apps performantes. C'est ce que SWR fait sur le web (stale-while-revalidate). En React Native, je l'ai implémenté manuellement avec le ContentCacheService.

Supabase Realtime ajoute-t-il de la valeur au feed ?

Oui, et c'est ce qui rend le feed "vivant". Supabase Realtime est configuré sur les tables privat.tasks et privat.memos. Deux channels sont ouverts : content-cache-tasks et content-cache-memos.

Quand une tâche est créée, complétée ou modifiée (par l'utilisateur ou par un membre de son groupe), l'événement Realtime est reçu et le cache L1 est invalidé. La prochaine fois que le feed est affiché, il récupère les données fraîches.

Le résultat : quand un collègue complète une tâche dans un groupe collaboratif, l'activité apparaît dans ton feed en quelques secondes. Sans refresh manuel, sans pull-to-refresh — ça arrive tout seul.

Quelles sont les performances sur les appareils d'entrée de gamme ?

Un feed complexe avec des images, des badges et des animations peut être problématique sur les appareils modestes. Voici les optimisations spécifiques :

  • Recyclage des composants : la FlatList ne rend que les éléments visibles. Les éléments hors écran sont recyclés, pas supprimés et recréés.
  • Images lazy-loaded : les images ne sont chargées que quand elles entrent dans le viewport + une marge de 300px.
  • Animations réduites : sur les appareils détectés comme "lents" (via InteractionManager), les animations sont simplifiées.
  • Batch des view stats : les statistiques de vue (combien de fois un élément a été vu) sont envoyées en batch, pas individuellement. C'est passé de N+1 requêtes à 1 seule grâce aux RPC getTaskViewStatsBatch et getMemoViewStatsBatch.

Ces optimisations permettent au feed de fonctionner correctement sur des appareils avec 2 Go de RAM, ce qui couvre la majorité du marché Android.

Comment la gamification s'intègre-t-elle dans le feed ?

Le système de gamification de TAMSIV comprend 12 niveaux, 10 badges, des streaks (jusqu'à 365 jours), et des challenges quotidiens. Chaque événement de gamification (badge débloqué, level up, streak milestone) apparaît dans le feed comme un élément dédié.

Le FeedGamificationItem est visuellement distinct des autres éléments : une couleur d'accent différente, une animation subtile, et un message de félicitation. C'est un moment de célébration dans le flux — il casse le rythme monotone des tâches et mémos et injecte de l'émotion.

L'intégration se fait via le GamificationService (singleton) qui est appelé depuis useTaskDetail (à la complétion d'une tâche) et memoCreation.ts (à la création d'un mémo). Le service vérifie les conditions de badges et de niveaux et crée les éléments feed correspondants via les RPC dédiées. Le système de notifications est aussi déclenché pour les achievements importants.

Ce que j'ai appris en construisant le feed

Cet écran m'a pris trois semaines. C'est aussi celui dont je suis le plus fier. Pas parce qu'il est spectaculaire visuellement — c'est un feed assez classique — mais parce qu'il fonctionne bien. Il est rapide, fiable, et agréable à utiliser.

La leçon principale : la performance est une feature. Un feed qui met 3 secondes à charger, personne ne l'utilise, peu importe la quantité de features qu'il contient. Un feed qui se charge instantanément, les utilisateurs y reviennent naturellement.

C'est la même philosophie que j'ai appliquée à toute l'app : les micro-interactions fluides, l'onboarding sans friction, la recherche instantanée. La vitesse et la fluidité sont les meilleures features de rétention qu'un dev solo puisse implémenter.

FAQ

Combien d'éléments le feed peut-il contenir ?

Techniquement, le feed est infini grâce à la pagination server-side (20 éléments par page). En pratique, les utilisateurs actifs accumulent quelques centaines d'éléments par mois. La FlatList avec recyclage gère des milliers d'éléments sans problème de mémoire.

Le feed consomme-t-il beaucoup de données mobiles ?

Non. Le cache L1/L2 évite les requêtes répétées. Les images sont compressées et lazy-loaded. Un refresh complet du feed consomme environ 50 Ko de données (hors images). Les images représentent l'essentiel du trafic, mais ne sont chargées qu'une fois et mises en cache.

Peut-on filtrer le feed par type de contenu ?

Pas encore dans la version actuelle. Le feed affiche tout par ordre chronologique. C'est un choix délibéré : le feed est un lieu de découverte, pas de recherche. Pour trouver une tâche spécifique, il y a la recherche dédiée.

Le Realtime fonctionne-t-il en arrière-plan ?

Non. Les channels Supabase Realtime sont fermés quand l'app passe en arrière-plan (pour économiser la batterie). Quand l'app revient au premier plan, les channels sont réouverts et le cache est rafraîchi. Le délai de reconnexion est de 1 à 2 secondes.

Comment le feed gère-t-il les contenus supprimés ?

Les éléments supprimés sont retirés du cache L1 et L2 immédiatement via les événements Realtime (event type DELETE). Si un élément supprimé est encore visible dans la FlatList, il disparaît avec une animation de fade-out.