16 GB Disk Size with Only 150 Users (and How I Solved It)
Supabase auto-scaled the Sideout disk from 18 GB to 27 GB. The app data was fine — a recursive webhook loop had silently ballooned two internal extension tables to 16 GB.
Originalmente em inglês
Got an automated email from Supabase: the Sideout project disk had been auto-scaled from 18 GB to 27 GB. The app had about 150 users, 25 events, and a handful of posts. That size made no sense.
First look: where is the data?
A quick query to check the actual database size confirmed it: 16 GB. Then I ran a table-size breakdown across all schemas — not just public — and the picture became clear immediately:
net._http_response 14 GB
supabase_functions.hooks 1713 MB
cron.job_run_details 51 MB
public.profiles 10080 kB
public.notifications 768 kB
The entire public schema was a rounding error. The weight was in internal Supabase extension tables: net._http_response (used by the pg_net extension for async HTTP calls) and supabase_functions.hooks (webhook audit history).
The culprit: a recursive webhook
Digging into supabase_functions.hooks, I found over 13 million rows — all from a single webhook named generate-profile-embedding. The app had 1,962 notification rows total. Something was looping.
The trigger was configured to fire on every UPDATE on public.profiles, with no conditions. The flow:
- A profile is updated.
- The webhook fires and calls an Edge Function.
- The Edge Function generates a vector embedding and writes it back to
public.profiles. - That write triggers another
UPDATE. - Go to step 2.
The sync-subscriber webhook was also listening to all profile updates, so it was caught in the same loop. The feature itself — semantic player matching — was fine. The trigger was the problem.
Cleanup
Truncating the two bloated tables brought the database from 16 GB down to about 82 MB:
truncate table net._http_response;
truncate table supabase_functions.hooks;
That’s the fastest part. The fix that actually mattered was tightening the trigger.
The fix: scoped triggers + defensive function
On the trigger side, I dropped the broad UPDATE trigger and replaced it with two precise ones:
- An
INSERTtrigger for new profiles. - An
UPDATE OF full_name, city, state, experience_level, biotrigger with an explicitWHENclause that checks if any of those fields actually changed.
create trigger "generate-profile-embedding-update"
after update of full_name, city, state, experience_level, bio on public.profiles
for each row
when (
old.full_name is distinct from new.full_name or
old.city is distinct from new.city or
old.state is distinct from new.state or
old.experience_level is distinct from new.experience_level or
old.bio is distinct from new.bio
)
execute function supabase_functions.http_request(...);
On the Edge Function side, I added a guard that compares record and old_record from the webhook payload. If none of the embedding fields changed, it returns early with { "skipped": true }. Two layers of protection, in case the broad trigger ever comes back by accident.
There was also a deploy issue — a 403 from an outdated Supabase CLI. Fixed by upgrading from 2.90.0 to 2.98.2 and using a personal access token instead of browser login.
What I’d do differently
Any webhook that writes back to the same table it listens to is a loop waiting to happen. The rule now: always use UPDATE OF specific_columns plus a WHEN clause. And treat supabase_functions.hooks and net._http_response as tables worth monitoring — they don’t show up in the usual app dashboards, but they can quietly dominate your disk.
Discussão
Comentários
Leia a conversa ou entre com Google para deixar sua própria nota.
Quer comentar?
Entre com Google só quando quiser escrever. A leitura dos comentários continua pública.