Reducing Supabase Egress: How We Found the Root Cause After Two DB Moves
There are problems you don’t solve, you just postpone. For quite a while the data egress of our web app was exactly that kind of case — and I’ll honestly admit we made it hard on ourselves for a long time. Every little change to the vision board pushed hundreds of kilobytes through the wire, every few seconds. The egress quota at Supabase melted away, and instead of tackling the cause, we moved to a fresh database twice, just to get some breathing room.
That’s symptom-fighting in its purest form. And those moves really hurt.
Why two moves weren’t the solution
A database move sounds clean at first, but it isn’t. The standard dump only takes the data catalog with it — the images and other files in storage do not migrate automatically. So for each of the two moves (one on May 9, one on June 1) we had to write our own rescue scripts that back up storage, restore it, and finally compare whether everything actually made it across.
And the best part: after each move the underlying problem was of course still there. We’d only reset the quota, not lowered the consumption. It was like regularly emptying a leaky bucket instead of plugging the hole.
In between we fiddled with all kinds of symptoms: throttled the saving, refined the realtime filters, even cut individual large pieces of content out of the data by hand and parked them as a JSON file — one vision with eleven long plans, 75 kilobytes on its own, we parked manually, just so the data blob wouldn’t keep growing. It grew anyway.
The root cause: one single giant JSON field
At some point we sat down and measured, instead of just reacting. And the result was pretty unambiguous: the realtime updates, multiplied by the size of the data blob, made up around 65 percent of the entire egress. So the root wasn’t some single feature, it was the architecture itself.
Technically the situation was this: the data of a vision board sat entirely in one JSONB blob per board. At its peak that was around 640 kilobytes, most recently still 261. And every edit, however small, wrote that whole blob back. Worse still: from about 700 kilobytes on it got dangerous, because loading then hit a time limit. We were walking straight toward a tipping point, eyes wide open.
A growing single-row blob is a ticking clock
It works flawlessly — until suddenly it doesn’t. As long as everything’s in the green, you notice nothing. That’s exactly what makes it so treacherous.
The deliberate wait decision
Here comes something I’m a little proud of in hindsight: we deliberately parked the root solution for now. The reasoning on June 9 was that a big structural rebuild was on the horizon anyway — and that we could then handle both in one cut, instead of operating on the open heart twice.
And that’s exactly how it played out. A new feature — freely fillable areas on each card — needed a rebuild of how the data is stored anyway. Two birds, one stone. Sometimes the most patient option really is the most efficient one.
The cut: skeleton stays, content moves out
The solution itself is simple at its core. The blob now keeps only the skeleton: structure, titles, order, pins, parent references, and a few counters. The heavy content — the long plan texts and task lists — moves into an already-existing table as small individual rows, each addressed via a generic key.
The nice part: that table already had a proven system for locks, conflicts, and realtime. So we didn’t have to build anything new, we reused what was tried and tested. There’s exactly one exception — the mission stays in the blob, because it’s a one-off without its own identifier.
It mattered to me that we deliberately decided against full normalization. Had we cleanly broken everything into individual tables, things like reordering, cascading deletes, or the pin rules would suddenly have become complicated. This way they stay trivial in the skeleton. Bottom line: around 90 percent of the benefit at a fraction of the risk.
The result speaks for itself. A content edit now writes a small row in the kilobyte range instead of the complete 261-kilobyte blob — that’s over 99 percent less on the write path. Opening a board transfers 60 instead of 261 kilobytes, a good quarter. Content also loads only lazily, when you actually open a card. The 65-percent egress item is thereby structurally gone.
How it went — and where it got exciting
The whole thing ran as an AI-orchestrated big project over three days, in four phases and ten work packages. The interesting part: two AI teams — one for the web app, one for the server — had to keep a hard ordering when switching over and coordinate it through a shared note, complete with a written contract and a joint smoke test that passed four out of four points in the end.
The actual migration was two-stage and built so it could be repeated safely. First the backfill — 134 plan rows and 7 task rows, with newer data never being overwritten. Then the verification that every element with content really has its own row and no orphans remain. Only after that the cleanup of the 16 boards. Beforehand there was of course a full backup, and I’d tested the SQL scripts against a local throwaway database in advance, including a negative test: the cleanup without a prior backfill has to abort hard and leave the data untouched. It did.
The race bug: only visible under load
And then came the moment every story needs. During the live test, freshly created plan entries vanished right away — before my eyes. You type something in, it appears briefly, and it’s gone the next instant.
The cause was a race condition. Several save operations on the same row ran in parallel and overtook each other. They then ran into conflict loops with a stale lock token, and after two failed attempts the interface simply rolled the state back — hence the “appears briefly and vanishes.” The treacherous part: it only became visible at all because a backup happened to be running in parallel, slowing everything to 13–15 seconds per request.
Load makes race conditions visible that can lie dormant for months at normal speed.
The fix was then unspectacular but effective: a write queue per row, so the save operations run cleanly one after another instead of overtaking each other. Plus a new test that simulates exactly these parallel saves, so the bug doesn’t sneak back.
Result and a feeling
We were really close to the quota limit, and we just barely made it. Now the quota is comfortably enough, and a third database move is off the table. That relief after months of suffering is hard to describe.
But what stuck with me most: this was real teamwork. I set the direction, gave the critical approvals — backup, the destructive cleanup step — and tested live. The coordinated rebuild itself was carried by two AI teams. Exactly this split — human gives direction and approvals, AI builds in a coordinated way — feels like the right way to work.
Keep reading
- The other side of the same blob story: It Won’t Save — the Limit Is on the Read Side
- How we hard-wire routines: AI Workflows by Keyword
- When the AI says “yes” too quickly: Cascading Failures
Entdecke mehr
The Editor Won't Save — And the Limit Is on the Read Side, Not the Write
A growing JSONB blob froze our editor. The suspect was harmless, the real cause counterintuitive: reading failed, not saving. A debugging detective story.
LexikonModern Image Optimization for the Web
AVIF and WebP over JPEG/PNG, responsive images, correct lazy-loading and width/height against CLS — images are one of the biggest levers for LCP.
LexikonRendering Strategies for Fast Websites
Static, SSR, ISR, edge rendering and hydration from a dev angle: what each strategy means for TTFB, LCP, and maintainability — and when each fits.