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.
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.
Discussion
Comments
Read the conversation or sign in with Google to add your own note.
Want to comment?
Sign in with Google only when you want to write. Reading comments stays public.