DEV Community

Cover image for How I turned a single Supabase query into 19GB of egress
Victor Caña
Victor Caña

Posted on

How I turned a single Supabase query into 19GB of egress

The problem

I caught it in Supabase billing metrics: one dashboard query was responsible for a massive jump in egress, way out of proportion to the actual traffic.

At first, it looked like a normal internal dashboard issue. In reality, a single .select('*') on a large table was quietly pulling far more data than the UI ever needed.

Technical context

The app is ReadyToRelease, built with Next.js 14 and Supabase, and this query lived inside an internal dashboard. It was not a Stripe bug, not a Groq issue, and not a frontend rendering problem. The real issue was simpler and more dangerous: the dashboard was asking Supabase for every column in every matching row.

That kind of query is easy to miss during development because it works fine on small datasets. But once the table grows and the dashboard starts loading often, the cost profile changes fast. In my case, the result was 19GB of egress before I noticed what was happening.

The problematic code

Here was the original query:

const { data } = await supabase
  .from('reports')
  .select('*')
  .eq('user_id', userId)
Enter fullscreen mode Exit fullscreen mode

This looks harmless at first glance. The problem is that * is a convenience shortcut, not a performance strategy. It returns every column, including fields the dashboard never displayed, which increases payload size and therefore egress.

The second issue was that the query did not limit the number of rows. Even if each row is moderate in size, a few hundred or a few thousand rows can become expensive when the query runs often.

The fix

The solution was to make the query explicit and bounded:

const { data } = await supabase
  .from('reports')
  .select('id, created_at, status, title')
  .eq('user_id', userId)
  .range(0, 24)
Enter fullscreen mode Exit fullscreen mode

This changed two things:

  • It fetched only the columns the dashboard actually rendered.
  • It limited the result set to 25 rows.

That reduced payload size immediately, which brought egress down to almost nothing and made the dashboard feel faster at the same time.

Why this worked

The lesson is that egress problems are often query design problems in disguise. When you fetch more fields than you render, you pay for data transfer you do not use. When you fetch more rows than the interface needs, you multiply that waste across every load.

In this case, the fix was not a bigger cache, a new edge layer, or a database rewrite. It was simply making the query match the UI.

General rule

If a screen only shows five fields and 25 rows, the query should probably ask for five fields and 25 rows.

That rule sounds obvious, but it is easy to ignore when you are moving quickly. Defaults like .select('*') are convenient during prototyping, yet they can become expensive once real users and real tables enter the picture.

One useful habit is to treat every dashboard query like a public API response: return only what the client needs, nothing more. That keeps payloads smaller, latency lower, and billing surprises less likely.

Closing note

A single query can be cheap in development and expensive in production. In my case, that difference showed up as 19GB of Supabase egress and a very avoidable bill.

The fix was small, but the lesson was not: data transfer costs are often hidden in plain sight, inside code that “works” but returns far too much.

Related reading: you can find more details about Supabase cost traps (including how .maybeSingle() can silently fail when multiple rows exist) in my post Supabase: When .maybeSingle() silently fails with multiple rows.

Top comments (0)