Blog
Performance
18. Februar 202610 min

Gecachter Egress Supabase: -90% Bandbreite bis 2026

Eines Morgens öffnete ich mein Supabase-Dashboard und hätte fast meinen Kaffee ausgespuckt. Der Egress – die ausgehende Bandbreite meiner Datenbank – stieg viel schneller an als die Anzahl der TAMSIV-Benutzer. Jedes Öffnen der App löste Dutzende von Anfragen aus. Und jede Anfrage bedeutet, dass Daten übertragen werden. Multipliziere das mit Hunderten von Benutzern, die die App mehrmals täglich öffnen, und du erhältst eine Rechnung, die wehtut.

Ich bin ein Solo-Entwickler. Ich habe kein unbegrenztes Infrastruktur-Budget. Jeder Euro zählt. Also krempelte ich die Ärmel hoch und suchte nach Wegen, diesen Verbrauch drastisch zu reduzieren, ohne die Benutzererfahrung zu beeinträchtigen. Spoiler: Es ist mir gelungen, den Egress um 80 bis 90 % zu senken. Und als Bonus wurde die App schneller.

Wichtige Punkte zum Merken:
- Das N+1-Problem kann deine Bandbreite explodieren lassen, ohne dass du es merkst
- Ein zweistufiger Cache (Speicher + persistent) eliminiert die meisten unnötigen Anfragen
- Supabase Realtime ermöglicht die Echtzeit-Invalidierung des Caches ohne Polling
- Das Batching von Anfragen verwandelt 20 Aufrufe in 1
- Kostenoptimierung und UX-Optimierung sind oft dieselbe Arbeit

Warum explodiert der Supabase-Egress bei einer mobilen App?

Bevor wir über Lösungen sprechen, müssen wir das Problem verstehen. Supabase, wie jeder Cloud-Dienst, berechnet die ausgehende Bandbreite. Jedes Mal, wenn deine App eine Anfrage an die Datenbank sendet, werden Daten vom Server zum Client übertragen. Das ist der Egress.

Bei einer klassischen Web-App lädt der Benutzer eine Seite und das war's. Bei einer mobilen App ist das anders. Der Benutzer navigiert zwischen Tabs, macht Pull-to-Refresh, öffnet eine Aufgabe, kehrt zum Feed zurück, öffnet eine andere Aufgabe. Jede Navigation löst Anfragen aus. Und wenn dein Code nicht optimiert ist, lädt jede Anfrage die gesamten Daten neu, selbst wenn sich seit 30 Sekunden nichts geändert hat.

In TAMSIV hatte ich zwei große Probleme, die den Egress um einen enormen Faktor multiplizierten.

Entwickler analysiert ein Dashboard, das den Rückgang der Server-Bandbreitenkosten zeigt
Der Moment, in dem du merkst, dass dein Kosten-Dashboard wie eine Startup-Wachstumskurve aussieht – nur dass es deine Rechnung ist.

Was ist das N+1-Problem und wie erkennt man es?

Der erste Übeltäter war das klassische N+1-Problem. Im TAMSIV-Feed zeige ich eine Liste von Aufgaben mit ihren Ansichtsstatistiken an (wer hat die Aufgabe wann wie oft gesehen). Für jede Aufgabe habe ich einen individuellen Aufruf gemacht, um ihre ViewStats abzurufen.

20 angezeigte Aufgaben = 20 individuelle Anfragen. 50 Aufgaben = 50 Anfragen. Du siehst das Problem. Jede Anfrage hat feste Kosten in Bezug auf Netzwerklatenz und übertragene Daten (HTTP-Header, Antwort-Metadaten usw.). Multipliziere das mit der Anzahl der Aufgaben, und der Egress explodiert.

Das Schlimmste ist, dass dieses Muster unsichtbar ist, wenn du deine Metriken nicht betrachtest. Die App funktioniert. Die Daten werden angezeigt. Alles sieht normal aus. Aber im Hintergrund machst du 20-mal mehr Anfragen als nötig.

Wie erkennt man es? Gehe in Supabase im Dashboard zum Bereich Reports > API. Sieh dir die Anzahl der Anfragen pro Endpunkt an. Wenn du einen Endpunkt siehst, der Dutzende Male in derselben Sekunde aufgerufen wird, ist das ein N+1. Du kannst auch die Supabase-Inspektionstools verwenden, um langsame Anfragen zu analysieren.

Wie funktioniert das Batching von Anfragen mit Supabase?

Die Lösung für N+1 ist theoretisch einfach: Anstatt N individuelle Anfragen zu stellen, machst du eine einzige, die alle Daten auf einmal abruft. Das ist Batching.

Ich habe eine RPC-Funktion in Supabase erstellt, getTaskViewStatsBatch(taskIds), die ein Array von IDs als Parameter nimmt und die Statistiken aller Aufgaben in einer einzigen Anfrage zurückgibt. Dasselbe gilt für Memos mit getMemoViewStatsBatch(memoIds).

-- Vorher: 20 individuelle Aufrufe
SELECT * FROM view_stats WHERE task_id = 'xxx';
-- x 20 Mal...

-- Nachher: 1 einziger Aufruf
SELECT * FROM view_stats WHERE task_id = ANY($1);
-- $1 = Array von 20 IDs

Das Ergebnis ist sofort sichtbar: 20 Anfragen werden zu 1. Der Egress für diesen Vorgang wird um einen signifikanten Faktor reduziert – nicht genau um 20, da sich die Daten selbst nicht geändert haben, aber der Netzwerk-Overhead (Header, Handshake usw.) wird 19 von 20 Mal eliminiert.

Wenn du die Supabase RPC-Funktionen verwendest, ist das Batching trivial zu implementieren. Der ANY()-Operator von PostgreSQL ist dein bester Freund.

Was ist der ContentCacheService und warum braucht man ihn?

Das Batching hat das N+1-Problem gelöst, aber der zweite Übeltäter blieb: die Daten, die bei jeder Navigation vollständig neu geladen wurden, selbst wenn sich nichts geändert hatte.

Wenn der Benutzer den Feed öffnet, werden die Aufgaben von Supabase geladen. Wenn er zum Agenda-Tab wechselt und dann zum Feed zurückkehrt, werden die Aufgaben erneut von Supabase geladen. Dieselben Daten, dieselbe Antwort, dieselben Egress-Kosten. Umsonst.

Die Lösung: ein intelligenter Cache. Ich habe den ContentCacheService entwickelt, ein Singleton, das den Cache aller Inhaltsdaten in TAMSIV verwaltet. Sein Prinzip ist einfach: niemals eine Anfrage wiederholen, wenn sich die Daten nicht geändert haben.

Serverraum mit Glasfaserkabeln und blauen und grünen Leuchtanzeigen
Jedes Byte, das diese Server verlässt, kostet Geld. Der Cache reduziert diesen ausgehenden Datenverkehr drastisch.

Wie implementiert man einen zweistufigen Cache in einer React Native App?

Der ContentCacheService verwendet einen zweistufigen Cache, jeder mit einer bestimmten Rolle:

  • L1 — Speicher-Cache (JavaScript Map): Sofortiger Zugriff, keine Latenz. Die Daten befinden sich im RAM. Wenn der Benutzer zwischen den Tabs navigiert, wird der Feed aus dem L1 ohne jegliche Anfrage angezeigt. Das Problem: Die Daten verschwinden, wenn die App geschlossen wird.
  • L2 — AsyncStorage: Persistenter Cache auf dem Gerät. Langsamer als L1 (einige Millisekunden Lesezeit), aber überlebt App-Neustarts. Wenn der Benutzer TAMSIV neu öffnet, werden die Daten aus L2 in L1 geladen und der Feed wird sofort angezeigt, noch bevor die erste Supabase-Anfrage gesendet wird.

Der Lesefluss ist wie folgt:

  1. Im L1 (Map im Speicher) suchen. Wenn gefunden und nicht abgelaufen → sofort zurückgeben.
  2. Andernfalls im L2 (AsyncStorage) suchen. Wenn gefunden und nicht abgelaufen → in L1 kopieren und zurückgeben.
  3. Andernfalls Supabase-Anfrage → in L1 und L2 speichern → zurückgeben.

Jeder Cache-Eintrag hat einen Zeitstempel. Ein konfigurierbarer TTL (Time To Live) bestimmt, wann ein Eintrag als veraltet gilt. Aber der wahre Game-Changer ist die Echtzeit-Invalidierung mit Supabase Realtime.

Wie eliminiert Supabase Realtime das Polling?

Das klassische Problem des Caches ist die Invalidierung. Wie weiß man, dass sich die Daten geändert haben, ohne die Anfrage erneut zu stellen? Die naive Lösung ist Polling: alle X Sekunden prüfen, ob sich etwas geändert hat. Aber Polling ist Verschwendung – du machst 90 % der Zeit unnötige Anfragen.

Supabase Realtime löst dieses Problem elegant. Es ist ein Echtzeit-Abonnementsystem, das auf PostgreSQL-Benachrichtigungen basiert. Du abonnierst eine Tabelle, und Supabase sendet dir ein Ereignis, jedes Mal wenn eine Zeile erstellt, geändert oder gelöscht wird.

In TAMSIV habe ich zwei Kanäle konfiguriert:

  • content-cache-tasks: überwacht Änderungen an privat.tasks
  • content-cache-memos: überwacht Änderungen an privat.memos

Wenn eine Änderung erkannt wird, invalidiert der ContentCacheService den entsprechenden Eintrag in L1 und L2. Beim nächsten Rendern der React-Komponente werden die Daten von Supabase neu geladen und erneut zwischengespeichert. Komponenten, die Änderungen über Listener überwachen, rendern sich automatisch mit den neuen Daten neu.

Das Ergebnis: kein Polling, keine unnötigen Anfragen. Die Daten sind immer aktuell, ohne Bandbreite zu verschwenden. Das ist genau das Muster, das ich auch im Gamification-Feed und in den hierarchischen Gruppen verwende.

Welche tatsächlichen Auswirkungen hat dies auf Kosten und Leistung?

Die Zahlen sprechen für sich. Nach der Implementierung des ContentCacheService + Batching:

  • Egress um 80 bis 90 % reduziert je nach Zeitraum und Anzahl der aktiven Benutzer
  • Anzahl der API-Anfragen um das 15- bis 20-fache reduziert dank Batching + Cache
  • Anzeigezeit des Feeds: nahezu sofort aus dem L1-Cache, gegenüber 200-500 ms vorher
  • Neuladen nach dem Schließen: ~50 ms aus dem L2-Cache, gegenüber 300-800 ms von Supabase

Der unerwartete Bonus: Die UX hat sich erheblich verbessert. Der Feed wird sofort angezeigt, die Übergänge zwischen den Tabs sind flüssig, und Pull-to-Refresh ist zu einer echten Aktualisierung geworden (die nur das neu lädt, was sich geändert hat) anstatt eines vollständigen Neuladens.

Mobiltelefon, das eine schnelle Anwendung mit zwischengespeicherten Inhalten neben einem Laptop anzeigt
Die Benutzererfahrung profitiert direkt vom Cache: Der Inhalt wird sofort angezeigt.

Wie wendest du diese Strategie auf dein eigenes Projekt an?

Wenn du Supabase (oder ein beliebiges Cloud-Backend) verwendest und dein Egress zu steigen beginnt, empfehle ich dir folgende Vorgehensweise:

  1. Überprüfe deine Anfragen: Verwende das Supabase-Dashboard oder ein Überwachungstool, um die am häufigsten aufgerufenen Endpunkte zu identifizieren. Suche nach N+1-Mustern.
  2. Bündle wiederholte Anfragen: Alles, was eine Anfrage pro Element in einer Liste macht, sollte in eine einzige Anfrage mit einem Array von IDs umgewandelt werden.
  3. Implementiere einen zweistufigen Cache: Speicher für Geschwindigkeit, persistenter Speicher für das Überleben von Neustarts.
  4. Verwende Realtime zur Invalidierung: Kein Polling. Die Daten benachrichtigen dich, wenn sie sich ändern.
  5. Messe vorher und nachher: Ohne Metriken weißt du nicht, ob deine Optimierung wirklich funktioniert hat.

Dieser Ansatz ist nicht spezifisch für React Native oder Supabase. Das L1/L2-Cache-Muster + ereignisbasierte Invalidierung funktioniert mit Firebase, AWS AppSync oder jedem Backend, das Echtzeitbenachrichtigungen unterstützt. Wichtig ist, Polling zu vermeiden.

Welche Fehler sollte man bei der Cache-Optimierung vermeiden?

Ich habe unterwegs einige Fehler gemacht. Hier ist, was ich gelernt habe:

  • Sensible Daten nicht in AsyncStorage cachen: AsyncStorage ist auf Android standardmäßig nicht verschlüsselt. Für sensible Daten verwende einen sicheren Speicher. In meinem Fall sind Aufgaben und Memos an sich keine kritischen Daten (Auth-Tokens befinden sich in einem sicheren Schlüsselbund).
  • Cache-Größe verwalten: Ohne Begrenzung kann der L2-Cache unbegrenzt wachsen. Ich habe eine LRU-Eviction-Richtlinie (Least Recently Used) und eine maximale Größe implementiert.
  • Vorsicht bei signierten URLs: Die KI-generierten Bilder in TAMSIV verwenden signierte Supabase-URLs, die nach 1 Stunde ablaufen. Der Cache sollte den storage_path speichern und die URL bei Bedarf neu generieren, nicht die signierte URL selbst cachen.
  • Leeren Cache testen: Die Erfahrung beim ersten Start (leerer Cache) muss korrekt bleiben. Gehe nicht davon aus, dass der Cache immer Daten enthalten wird.

Wie interagiert der Cache mit anderen TAMSIV-Diensten?

Der ContentCacheService ist nicht isoliert. Er interagiert mit mehreren anderen Komponenten der TAMSIV-Architektur:

  • GamificationService: Verwendet den Cache, um Punkte, Abzeichen und Streaks ohne zusätzliche Anfragen anzuzeigen. Die Methode refreshFeedImageUrls() generiert abgelaufene signierte URLs neu.
  • Sprach-Pipeline: Wenn die KI eine Aufgabe über das Diktiergerät erstellt, invalidiert das Realtime-Ereignis den Cache und der Feed wird automatisch aktualisiert.
  • Kollaborativer Kalender: Freigegebene Ereignisse verwenden dasselbe Cache-Muster mit Realtime-Invalidierung.
  • Suche: Der SearchService fragt zuerst den L1-Cache ab, bevor er eine Supabase-Anfrage startet, wodurch die Suche für bereits geladene Daten nahezu sofort erfolgt.

Dieses Cache-System ist zur unsichtbaren Säule der TAMSIV-Leistung geworden. Der Benutzer sieht es nie direkt, aber er spürt es bei jeder Interaktion.

FAQ

Ist der Supabase-Egress wirklich ein Problem für kleine Projekte?

Der kostenlose Supabase-Plan beinhaltet ein großzügiges Egress-Kontingent. Aber wenn deine App viele wiederholte Anfragen stellt (was auf Mobilgeräten üblich ist), kannst du das Limit schneller erreichen als erwartet. Es ist besser, frühzeitig zu optimieren, als das Problem mitten im Wachstum zu entdecken.

Besteht nicht die Gefahr, dass der Cache veraltete Daten anzeigt?

Das ist der ganze Sinn von Supabase Realtime. Sobald sich Daten in der Datenbank ändern, wird ein Ereignis an den Client gesendet, das den Cache invalidiert. In der Praxis beträgt die Verzögerung zwischen der Änderung und der Aktualisierung auf Clientseite etwa eine Sekunde. Für eine Produktivitäts-App wie TAMSIV ist das unmerklich.

Warum nicht stattdessen React Query oder SWR verwenden?

React Query und SWR sind ausgezeichnete Bibliotheken für das Anfragen-Caching im Web. Aber in einem React Native-Kontext mit AsyncStorage als persistentem Cache und Supabase Realtime für die Invalidierung bietet ein maßgeschneiderter Dienst mehr Kontrolle. Der ContentCacheService verwaltet die beiden Cache-Ebenen, den TTL, die Eviction und die Realtime-Invalidierung in einem einzigen kohärenten Singleton.

Funktioniert dieses Muster auch mit anderen Datenbanken als Supabase?

Das Prinzip ist universell. Der L1/L2-Cache ist backend-unabhängig. Für die Echtzeit-Invalidierung benötigt man ein Äquivalent zu PostgreSQL-Benachrichtigungen: Firebase Realtime Database, AWS AppSync-Abonnements oder sogar ein einfaches selbstgebautes WebSocket. Wichtig ist, Polling zu vermeiden.

Wie hoch sind die Wartungskosten dieses Cache-Systems?

Einmal eingerichtet, ist der ContentCacheService sehr stabil. Ich habe ihn seit seiner Implementierung praktisch nicht mehr angefasst, außer um neue Entitäten zum Cache hinzuzufügen (z. B. Ereignisse aus dem Kalender). Der Code ist ein Singleton mit einer klaren API – andere Dienste müssen nur get() und invalidate() aufrufen.