Blog
Performance
18 février 202610 min

Cached egress Supabase : -90% de bande passante en 2026

Un matin, j'ai ouvert mon dashboard Supabase et j'ai failli recracher mon café. L'egress — la bande passante sortante de ma base de données — grimpait bien plus vite que le nombre d'utilisateurs de TAMSIV. Chaque ouverture de l'app déclenchait des dizaines de requêtes. Et chaque requête, c'est de la donnée qui transite. Multiplie ça par des centaines d'utilisateurs qui ouvrent l'app plusieurs fois par jour, et tu obtiens une facture qui fait mal.

Je suis dev solo. Je n'ai pas de budget infra illimité. Chaque euro compte. Alors j'ai retroussé mes manches et j'ai cherché comment réduire drastiquement cette consommation sans dégrader l'expérience utilisateur. Spoiler : j'ai réussi à baisser l'egress de 80 à 90%. Et en bonus, l'app est devenue plus rapide.

Points clés à retenir :
- Le problème N+1 peut exploser ta bande passante sans que tu t'en rendes compte
- Un cache à deux niveaux (mémoire + persistant) élimine la majorité des requêtes inutiles
- Supabase Realtime permet d'invalider le cache en temps réel sans polling
- Le batching de requêtes transforme 20 appels en 1 seul
- Optimiser les coûts et optimiser l'UX, c'est souvent le même travail

Pourquoi l'egress Supabase explose-t-il sur une app mobile ?

Avant de parler solution, il faut comprendre le problème. Supabase, comme tout service cloud, facture la bande passante sortante. Chaque fois que ton app fait une requête à la base de données, les données transitent du serveur vers le client. C'est l'egress.

Sur une app web classique, l'utilisateur charge une page et c'est terminé. Sur une app mobile, c'est différent. L'utilisateur navigue entre les onglets, pull-to-refresh, ouvre une tâche, revient au feed, ouvre une autre tâche. Chaque navigation déclenche des requêtes. Et si ton code n'est pas optimisé, chaque requête recharge l'intégralité des données, même si rien n'a changé depuis 30 secondes.

Dans TAMSIV, j'avais deux problèmes majeurs qui multipliaient l'egress par un facteur énorme.

Développeur analysant un tableau de bord montrant la baisse des coûts de bande passante serveur
Le moment où tu réalises que ton dashboard de coûts ressemble à une courbe de croissance startup — sauf que c'est ta facture.

Qu'est-ce que le problème N+1 et comment le détecter ?

Le premier coupable, c'était le classique problème N+1. Dans le feed de TAMSIV, j'affiche une liste de tâches avec leurs statistiques de vues (qui a vu la tâche, quand, combien de fois). Pour chaque tâche, je faisais un appel individuel pour récupérer ses ViewStats.

20 tâches affichées = 20 requêtes individuelles. 50 tâches = 50 requêtes. Tu vois le problème. Chaque requête a un coût fixe en termes de latence réseau et de données transférées (headers HTTP, métadonnées de réponse, etc.). Multiplie ça par le nombre de tâches, et l'egress explose.

Le pire, c'est que ce pattern est invisible si tu ne regardes pas tes métriques. L'app fonctionne. Les données s'affichent. Tout a l'air normal. Mais en arrière-plan, tu fais 20 fois plus de requêtes que nécessaire.

Comment le détecter ? Dans Supabase, va dans le dashboard, section Reports > API. Regarde le nombre de requêtes par endpoint. Si tu vois un endpoint appelé des dizaines de fois dans la même seconde, c'est un N+1. Tu peux aussi utiliser les outils d'inspection Supabase pour analyser les requêtes lentes.

Comment fonctionne le batching de requêtes avec Supabase ?

La solution au N+1 est simple en théorie : au lieu de faire N requêtes individuelles, tu en fais une seule qui récupère toutes les données d'un coup. C'est le batching.

J'ai créé une fonction RPC dans Supabase, getTaskViewStatsBatch(taskIds), qui prend un tableau d'IDs en paramètre et retourne les stats de toutes les tâches en une seule requête. Pareil pour les mémos avec getMemoViewStatsBatch(memoIds).

-- Avant : 20 appels individuels
SELECT * FROM view_stats WHERE task_id = 'xxx';
-- x 20 fois...

-- Apres : 1 seul appel
SELECT * FROM view_stats WHERE task_id = ANY($1);
-- $1 = tableau de 20 IDs

Le résultat est immédiat : 20 requêtes deviennent 1. L'egress pour cette opération est divisé par un facteur significatif — pas exactement par 20 car les données elles-mêmes n'ont pas changé, mais l'overhead réseau (headers, handshake, etc.) est éliminé 19 fois sur 20.

Si tu utilises les fonctions RPC de Supabase, le batching est trivial à implémenter. L'opérateur ANY() de PostgreSQL est ton meilleur ami.

Qu'est-ce que le ContentCacheService et pourquoi en avoir besoin ?

Le batching a réglé le problème N+1, mais il restait le deuxième coupable : les données rechargées intégralement à chaque navigation, même si rien n'avait changé.

Quand l'utilisateur ouvre le feed, les tâches sont chargées depuis Supabase. Quand il va sur l'onglet Agenda puis revient au feed, les tâches sont rechargées depuis Supabase. Les mêmes données, la même réponse, le même coût en egress. Pour rien.

La solution : un cache intelligent. J'ai conçu le ContentCacheService, un singleton qui gère le cache de toutes les données de contenu dans TAMSIV. Son principe est simple : ne jamais refaire une requête si les données n'ont pas changé.

Salle de serveurs avec câbles à fibre optique et indicateurs lumineux bleus et verts
Chaque octet qui sort de ces serveurs a un coût. Le cache réduit drastiquement ce trafic sortant.

Comment implémenter un cache à deux niveaux dans une app React Native ?

Le ContentCacheService utilise un cache à deux niveaux, chacun avec un rôle précis :

  • L1 — Cache mémoire (Map JavaScript) : Accès instantané, zéro latence. Les données sont en RAM. Quand l'utilisateur navigue entre les onglets, le feed s'affiche depuis le L1 sans aucune requête. Le problème : les données disparaissent quand l'app est fermée.
  • L2 — AsyncStorage : Cache persistant sur le device. Plus lent que le L1 (quelques millisecondes de lecture), mais survit aux redémarrages de l'app. Quand l'utilisateur rouvre TAMSIV, les données du L2 sont chargées en L1 et le feed s'affiche immédiatement, avant même que la première requête Supabase ne parte.

Le flow de lecture est le suivant :

  1. Chercher dans le L1 (Map en mémoire). Si trouvé et pas expiré → retourner immédiatement.
  2. Sinon, chercher dans le L2 (AsyncStorage). Si trouvé et pas expiré → copier en L1 et retourner.
  3. Sinon, requête Supabase → stocker en L1 et L2 → retourner.

Chaque entrée du cache a un timestamp. Un TTL (Time To Live) configurable détermine quand une entrée est considérée comme périmée. Mais le vrai game-changer, c'est l'invalidation en temps réel avec Supabase Realtime.

Comment Supabase Realtime élimine-t-il le polling ?

Le problème classique du cache, c'est l'invalidation. Comment savoir que les données ont changé sans refaire la requête ? La solution naïve, c'est le polling : vérifier toutes les X secondes si quelque chose a bougé. Mais le polling, c'est du gaspillage — tu fais des requêtes pour rien 90% du temps.

Supabase Realtime résout ce problème élégamment. C'est un système de souscription en temps réel basé sur les notifications PostgreSQL. Tu t'abonnes à une table, et Supabase te pousse un événement chaque fois qu'une ligne est créée, modifiée ou supprimée.

Dans TAMSIV, j'ai configuré deux channels :

  • content-cache-tasks : écoute les changements sur privat.tasks
  • content-cache-memos : écoute les changements sur privat.memos

Quand un changement est détecté, le ContentCacheService invalide l'entrée correspondante dans le L1 et le L2. Au prochain rendu du composant React, les données sont rechargées depuis Supabase et remises en cache. Les composants qui écoutent les changements via des listeners se re-rendent automatiquement avec les nouvelles données.

Le résultat : zéro polling, zéro requête inutile. Les données sont toujours fraîches sans gaspiller de bande passante. C'est exactement le pattern que j'utilise aussi dans le feed de gamification et dans les groupes collaboratifs.

Quel est l'impact réel sur les coûts et la performance ?

Les chiffres parlent d'eux-mêmes. Après la mise en place du ContentCacheService + batching :

  • Egress réduit de 80 à 90% selon les périodes et le nombre d'utilisateurs actifs
  • Nombre de requêtes API divisé par 15 à 20 grâce au batching + cache
  • Temps d'affichage du feed : quasi-instantané depuis le cache L1, contre 200-500ms avant
  • Rechargement après fermeture : ~50ms depuis le cache L2, contre 300-800ms depuis Supabase

Le bonus inattendu : l'UX s'est considérablement améliorée. Le feed s'affiche instantanément, les transitions entre onglets sont fluides, et le pull-to-refresh est devenu un vrai rafraîchissement (qui ne recharge que ce qui a changé) au lieu d'un rechargement complet.

Téléphone mobile affichant une application rapide avec contenu en cache à côté d un ordinateur portable
L'expérience utilisateur bénéficie directement du cache : le contenu s'affiche instantanément.

Comment appliquer cette stratégie à ton propre projet ?

Si tu utilises Supabase (ou n'importe quel backend cloud) et que ton egress commence à grimper, voici la démarche que je recommande :

  1. Audite tes requêtes : Utilise le dashboard Supabase ou un outil de monitoring pour identifier les endpoints les plus appelés. Cherche les patterns N+1.
  2. Batch les requêtes répétitives : Tout ce qui fait une requête par élément dans une liste doit être converti en une seule requête avec un tableau d'IDs.
  3. Implémente un cache à deux niveaux : Mémoire pour la vitesse, storage persistant pour la survie aux redémarrages.
  4. Utilise Realtime pour l'invalidation : Pas de polling. Les données te préviennent quand elles changent.
  5. Mesure avant et après : Sans métriques, tu ne sais pas si ton optimisation a vraiment fonctionné.

Cette approche n'est pas spécifique à React Native ou à Supabase. Le pattern cache L1/L2 + invalidation événementielle fonctionne avec Firebase, AWS AppSync, ou n'importe quel backend qui supporte les notifications en temps réel.

Quelles erreurs éviter lors de l'optimisation du cache ?

J'ai fait quelques erreurs en cours de route. Voici ce que j'ai appris :

  • Ne pas cacher les données sensibles dans AsyncStorage : AsyncStorage n'est pas chiffré par défaut sur Android. Pour des données sensibles, utilise un stockage sécurisé. Dans mon cas, les tâches et mémos ne sont pas des données critiques en elles-mêmes (les tokens d'auth sont dans un keychain sécurisé).
  • Gérer la taille du cache : Sans limite, le cache L2 peut grossir indéfiniment. J'ai implémenté une politique d'éviction LRU (Least Recently Used) et une taille maximale.
  • Attention aux URLs signées : Les images générées par l'IA dans TAMSIV utilisent des signed URLs Supabase qui expirent après 1 heure. Le cache doit stocker le storage_path et régénérer l'URL au besoin, pas cacher l'URL signée elle-même.
  • Tester le cache vide : L'expérience premier lancement (cache vide) doit rester correcte. Ne pas supposer que le cache contiendra toujours des données.

Comment le cache interagit-il avec les autres services de TAMSIV ?

Le ContentCacheService n'est pas isolé. Il interagit avec plusieurs autres composants de l'architecture de TAMSIV :

  • GamificationService : Utilise le cache pour afficher les points, badges et streaks sans requêtes supplémentaires. La méthode refreshFeedImageUrls() régénère les URLs signées expirées.
  • Pipeline vocal : Quand l'IA crée une tâche via le dictaphone, l'événement Realtime invalide le cache et le feed se met à jour automatiquement.
  • Agenda collaboratif : Les événements partagés utilisent le même pattern de cache avec invalidation Realtime.
  • Recherche : Le SearchService interroge d'abord le cache L1 avant de lancer une requête Supabase, ce qui rend la recherche quasi-instantanée pour les données déjà chargées.

Ce système de cache est devenu le pilier invisible de la performance de TAMSIV. L'utilisateur ne le voit jamais directement, mais il le ressent à chaque interaction.

FAQ

L'egress Supabase est-il vraiment un problème pour les petits projets ?

Le plan gratuit de Supabase inclut un quota d'egress généreux. Mais si ton app fait beaucoup de requêtes répétitives (ce qui est courant sur mobile), tu peux atteindre la limite plus vite que prévu. Mieux vaut optimiser tôt que découvrir le problème en pleine croissance.

Le cache ne risque-t-il pas d'afficher des données obsolètes ?

C'est tout l'intérêt de Supabase Realtime. Dès qu'une donnée change en base, un événement est poussé au client qui invalide le cache. En pratique, le délai entre la modification et la mise à jour côté client est de l'ordre de la seconde. Pour une app de productivité comme TAMSIV, c'est imperceptible.

Pourquoi ne pas utiliser React Query ou SWR à la place ?

React Query et SWR sont d'excellentes librairies pour le cache de requêtes sur le web. Mais dans un contexte React Native avec AsyncStorage comme cache persistant et Supabase Realtime pour l'invalidation, un service sur-mesure offre plus de contrôle. Le ContentCacheService gère les deux niveaux de cache, le TTL, l'éviction, et l'invalidation Realtime dans un seul singleton cohérent.

Ce pattern fonctionne-t-il avec d'autres bases de données que Supabase ?

Le principe est universel. Le cache L1/L2 est indépendant du backend. Pour l'invalidation en temps réel, il faut un équivalent aux notifications PostgreSQL : Firebase Realtime Database, AWS AppSync subscriptions, ou même un simple WebSocket maison. L'important, c'est d'éviter le polling.

Quel est le coût de maintenance de ce système de cache ?

Une fois en place, le ContentCacheService est très stable. Je n'y ai pratiquement pas touché depuis sa mise en place, à part pour ajouter de nouvelles entités au cache (événements de l'agenda par exemple). Le code est un singleton avec une API claire — les autres services n'ont qu'à appeler get() et invalidate().