Blog
Performance
February 18, 202610 min

Supabase Cached Egress: How I Cut My Bill by 90% (2026)

One morning, I opened my Supabase dashboard and almost spat out my coffee. Egress—the outgoing bandwidth from my database—was climbing much faster than the number of TAMSIV users. Every time the app opened, it triggered dozens of requests. And each request means data transfer. Multiply that by hundreds of users opening the app multiple times a day, and you get a bill that hurts.

I'm a solo developer. I don't have an unlimited infrastructure budget. Every euro counts. So I rolled up my sleeves and looked for ways to drastically reduce this consumption without degrading the user experience. Spoiler: I managed to reduce egress by 80 to 90%. And as a bonus, the app became faster.

Key takeaways:
- The N+1 problem can explode your bandwidth without you realizing it
- A two-level cache (memory + persistent) eliminates most unnecessary requests
- Supabase Realtime allows real-time cache invalidation without polling
- Request batching turns 20 calls into 1
- Optimizing costs and optimizing UX are often the same job

Why does Supabase egress explode on a mobile app?

Before talking about solutions, we need to understand the problem. Supabase, like any cloud service, charges for outgoing bandwidth. Every time your app makes a request to the database, data travels from the server to the client. This is egress.

On a classic web app, the user loads a page and that's it. On a mobile app, it's different. The user navigates between tabs, pull-to-refresh, opens a task, returns to the feed, opens another task. Each navigation triggers requests. And if your code isn't optimized, each request reloads all the data, even if nothing has changed for 30 seconds.

In TAMSIV, I had two major problems that multiplied egress by a huge factor.

Developer analyzing a dashboard showing a decrease in server bandwidth costs
The moment you realize your cost dashboard looks like a startup growth curve—except it's your bill.

What is the N+1 problem and how to detect it?

The first culprit was the classic N+1 problem. In the TAMSIV feed, I display a list of tasks with their view statistics (who viewed the task, when, how many times). For each task, I made an individual call to retrieve its ViewStats.

20 tasks displayed = 20 individual requests. 50 tasks = 50 requests. You see the problem. Each request has a fixed cost in terms of network latency and transferred data (HTTP headers, response metadata, etc.). Multiply that by the number of tasks, and egress explodes.

The worst part is that this pattern is invisible if you don't look at your metrics. The app works. The data displays. Everything looks normal. But in the background, you're making 20 times more requests than necessary.

How to detect it? In Supabase, go to the dashboard, Reports > API section. Look at the number of requests per endpoint. If you see an endpoint called dozens of times in the same second, it's an N+1. You can also use Supabase inspection tools to analyze slow queries.

How does request batching work with Supabase?

The solution to N+1 is simple in theory: instead of making N individual requests, you make a single one that retrieves all the data at once. This is batching.

I created an RPC function in Supabase, getTaskViewStatsBatch(taskIds), which takes an array of IDs as a parameter and returns the stats for all tasks in a single request. The same for memos with getMemoViewStatsBatch(memoIds).

-- Before: 20 individual calls
SELECT * FROM view_stats WHERE task_id = 'xxx';
-- x 20 times...

-- After: 1 single call
SELECT * FROM view_stats WHERE task_id = ANY($1);
-- $1 = array of 20 IDs

The result is immediate: 20 requests become 1. Egress for this operation is divided by a significant factor—not exactly by 20 because the data itself hasn't changed, but network overhead (headers, handshake, etc.) is eliminated 19 out of 20 times.

If you use Supabase RPC functions, batching is trivial to implement. PostgreSQL's ANY() operator is your best friend.

What is the ContentCacheService and why is it needed?

Batching solved the N+1 problem, but the second culprit remained: data fully reloaded with each navigation, even if nothing had changed.

When the user opens the feed, tasks are loaded from Supabase. When they go to the Calendar tab and then return to the feed, tasks are reloaded from Supabase. The same data, the same response, the same egress cost. For nothing.

The solution: an intelligent cache. I designed the ContentCacheService, a singleton that manages the cache of all content data in TAMSIV. Its principle is simple: never re-request if the data hasn't changed.

Server room with fiber optic cables and blue and green indicator lights
Every byte that leaves these servers has a cost. The cache drastically reduces this outgoing traffic.

How to implement a two-level cache in a React Native app?

The ContentCacheService uses a two-level cache, each with a specific role:

  • L1 — Memory Cache (JavaScript Map): Instant access, zero latency. Data is in RAM. When the user navigates between tabs, the feed displays from L1 without any requests. The problem: data disappears when the app is closed.
  • L2 — AsyncStorage: Persistent cache on the device. Slower than L1 (a few milliseconds to read), but survives app restarts. When the user reopens TAMSIV, L2 data is loaded into L1 and the feed displays immediately, even before the first Supabase request goes out.

The read flow is as follows:

  1. Look in L1 (in-memory Map). If found and not expired → return immediately.
  2. Otherwise, look in L2 (AsyncStorage). If found and not expired → copy to L1 and return.
  3. Otherwise, request Supabase → store in L1 and L2 → return.

Each cache entry has a timestamp. A configurable TTL (Time To Live) determines when an entry is considered stale. But the real game-changer is real-time invalidation with Supabase Realtime.

How does Supabase Realtime eliminate polling?

The classic cache problem is invalidation. How do you know that data has changed without re-requesting it? The naive solution is polling: check every X seconds if something has moved. But polling is wasteful—you make requests for nothing 90% of the time.

Supabase Realtime elegantly solves this problem. It's a real-time subscription system based on PostgreSQL notifications. You subscribe to a table, and Supabase pushes an event to you every time a row is created, modified, or deleted.

In TAMSIV, I configured two channels:

  • content-cache-tasks: listens for changes on privat.tasks
  • content-cache-memos: listens for changes on privat.memos

When a change is detected, the ContentCacheService invalidates the corresponding entry in L1 and L2. On the next render of the React component, the data is reloaded from Supabase and re-cached. Components listening for changes via listeners automatically re-render with the new data.

The result: zero polling, zero unnecessary requests. Data is always fresh without wasting bandwidth. This is exactly the pattern I also use in the gamification feed and in collaborative groups.

What is the real impact on costs and performance?

The numbers speak for themselves. After implementing ContentCacheService + batching:

  • Egress reduced by 80 to 90% depending on periods and the number of active users
  • Number of API requests divided by 15 to 20 thanks to batching + cache
  • Feed display time: almost instantaneous from L1 cache, compared to 200-500ms before
  • Reload after closing: ~50ms from L2 cache, compared to 300-800ms from Supabase

The unexpected bonus: UX significantly improved. The feed displays instantly, transitions between tabs are fluid, and pull-to-refresh became a true refresh (which only reloads what has changed) instead of a complete reload.

Mobile phone displaying a fast app with cached content next to a laptop
User experience directly benefits from caching: content displays instantly.

How to apply this strategy to your own project?

If you're using Supabase (or any cloud backend) and your egress is starting to climb, here's the approach I recommend:

  1. Audit your requests: Use the Supabase dashboard or a monitoring tool to identify the most called endpoints. Look for N+1 patterns.
  2. Batch repetitive requests: Anything that makes one request per item in a list should be converted into a single request with an array of IDs.
  3. Implement a two-level cache: Memory for speed, persistent storage for survival across restarts.
  4. Use Realtime for invalidation: No polling. Data notifies you when it changes.
  5. Measure before and after: Without metrics, you don't know if your optimization really worked.

This approach is not specific to React Native or Supabase. The L1/L2 cache + event-driven invalidation pattern works with Firebase, AWS AppSync, or any backend that supports real-time notifications.

What errors to avoid when optimizing the cache?

I made a few mistakes along the way. Here's what I learned:

  • Do not cache sensitive data in AsyncStorage: AsyncStorage is not encrypted by default on Android. For sensitive data, use secure storage. In my case, tasks and memos are not critical data in themselves (auth tokens are in a secure keychain).
  • Manage cache size: Without limits, the L2 cache can grow indefinitely. I implemented an LRU (Least Recently Used) eviction policy and a maximum size.
  • Beware of signed URLs: AI-generated images in TAMSIV use Supabase signed URLs that expire after 1 hour. The cache must store the storage_path and regenerate the URL as needed, not cache the signed URL itself.
  • Test with an empty cache: The first launch experience (empty cache) must remain correct. Do not assume that the cache will always contain data.

How does the cache interact with other TAMSIV services?

The ContentCacheService is not isolated. It interacts with several other components of TAMSIV's architecture:

  • GamificationService: Uses the cache to display points, badges, and streaks without additional requests. The refreshFeedImageUrls() method regenerates expired signed URLs.
  • Voice pipeline: When AI creates a task via the voice recorder, the Realtime event invalidates the cache and the feed updates automatically.
  • Collaborative calendar: Shared events use the same cache pattern with Realtime invalidation.
  • Search: The SearchService first queries the L1 cache before launching a Supabase request, making search almost instantaneous for already loaded data.

This caching system has become the invisible pillar of TAMSIV's performance. The user never sees it directly, but they feel it with every interaction.

FAQ

Is Supabase egress really a problem for small projects?

Supabase's free plan includes a generous egress quota. But if your app makes a lot of repetitive requests (which is common on mobile), you can hit the limit faster than expected. It's better to optimize early than to discover the problem during growth.

Doesn't caching risk displaying stale data?

That's the whole point of Supabase Realtime. As soon as data changes in the database, an event is pushed to the client which invalidates the cache. In practice, the delay between modification and client-side update is on the order of a second. For a productivity app like TAMSIV, it's imperceptible.

Why not use React Query or SWR instead?

React Query and SWR are excellent libraries for request caching on the web. But in a React Native context with AsyncStorage as a persistent cache and Supabase Realtime for invalidation, a custom service offers more control. The ContentCacheService manages both cache levels, TTL, eviction, and Realtime invalidation in a single coherent singleton.

Does this pattern work with databases other than Supabase?

The principle is universal. The L1/L2 cache is backend-independent. For real-time invalidation, you need an equivalent to PostgreSQL notifications: Firebase Realtime Database, AWS AppSync subscriptions, or even a simple custom WebSocket. The important thing is to avoid polling.

What is the maintenance cost of this caching system?

Once in place, the ContentCacheService is very stable. I've barely touched it since its implementation, except to add new entities to the cache (e.g., calendar events). The code is a singleton with a clear API—other services just need to call get() and invalidate().