The Editor Won't Save — And the Limit Is on the Read Side, Not the Write
It started harmlessly. I typed something into the Vision Board of our web app, the editor saved — and then suddenly didn’t anymore. No bang, no error message up front, just: the change was gone after the next reload. And because I’d typed a --> into a title shortly before, I had a suspect right away. Sounds logical, doesn’t it? A control character in the text that breaks something. That obvious suspicion is exactly what sent me off in completely the wrong direction at the start.
The wrong suspect
The --> was a red herring. I looked at it closely: it sat there perfectly harmlessly as plain text in a field, cleanly escaped, fine as far as the JSON was concerned. No control character breaking out anywhere, no broken structure. The thing was plain text and entirely innocent.
That’s the first lesson of this story, and I fall for it again and again myself: the first suspect is almost never the culprit. You’ve just done something noticeable, so the brain immediately links the symptom to it. But “I typed X and then it broke” is a correlation, not proof.
Narrowing it down, step by step
So I went where the truth lives: the console. Instead of guessing further, I read the error logs, and there were two messages there that changed everything.
The first: a browser error, roughly “Content-Length header of network response exceeds response Body.” Translated, that means the server announces a certain response size but then delivers less — the response breaks off midway. The second: a 504 MIDDLEWARE_INVOCATION_TIMEOUT. A timeout. Something took too long.
And that’s the moment my picture flipped. Both errors talk about reading, not writing. A response that aborts. A timeout while fetching. My whole suspicion had revolved around saving — while the console had been shouting the entire time that the problem was on the way back.
The real cause
With that knowledge, the rest was detective work. Through direct database access I measured how big the affected row actually was. And there was the heart of it: the entire board sat as one single JSONB blob in one single database row. Over time this blob had grown to around 725 KB of text, filled up with nearly a hundred objectives, a hundred initiatives, dozens of other entries and long free-text notes. A single collection item held 76 KB all on its own.
And now the real punchline, which I’d never seen this clearly before:
The limit is on the read side, not the write side
Writing was never the problem. A JSONB field can grow up to 255 MB in Postgres — an update with 725 KB runs server-side without any trouble at all. What tips over is the delivery of the read response.
On a perfectly normal fetch of the row, the database layer has to serialize the whole row and push it through a pooled connection. At a few kilobytes that’s nothing. At 700 KB and up it gets so slow that it tears — the response aborts (hence the Content-Length error), and the upstream layer with its 15-second hard timeout pulls the plug (hence the 504).
Importantly: there’s no fixed KB ceiling above which it would be forbidden. It’s simply time times throughput. Below the threshold everything works, above it tips over reproducibly. The blob was a ticking clock — it worked flawlessly until, one day, the delivery slid past the time limit.
The knock-on damage that took down the whole app
The nasty thing about errors like this is that they rarely come alone. Here it escalated in two stages.
Stage one: every failed save triggered a conflict recovery — which in turn read. So another slow read that failed again, which triggered another recovery. An endless loop in the console, feeding itself.
Stage two then got genuinely ugly. All these hanging large-read connections drained the connection pool. At some point all I got everywhere was “unable to check out connection from the pool after 15000ms.” No connection was free anymore — so much so that even the app server’s startup hung, because it couldn’t get a connection either. I raised the pool from 15 to 20; that didn’t help one bit, because the old, hanging connections held the pool hostage. Only a full restart of the database freed the connections again.
That’s the most inconspicuous but perhaps most important lesson: a seemingly harmless read error can build up through retry loops until it forces the whole connection pool, and with it the entire app, to its knees. When you isolate a bug, always ask what knock-on damage it sets off.
The fix — and why nothing got corrupted
The immediate measure was unspectacular, but it had to be clean. First a backup: the full blob once, and the note item to be extracted backed up separately again. Then I removed the 76 KB item from the array deliberately. The blob dropped from 725 KB to 649 KB — and that was already enough. The full read request came back with HTTP 200 afterwards, in 0.34 seconds instead of a 504 or an abort.
One nuance that genuinely reassured me in hindsight: nothing was ever lost. An optimistic-lock guard — a mechanism that only writes if the row hasn’t moved since it was read — had cleanly rejected all the failing saves instead of overwriting someone else’s data. The flip side of that is treacherous, though: every edit after the last successful save never reached the database. To me as a user, that felt exactly like “won’t save,” even though technically everything was correctly safeguarded.
The takeaways
- “Won’t save” doesn’t automatically mean “writing is broken.” Check first whether the accompanying read is failing. In my case the truth was entirely on the read side.
- A growing single-row blob is a ticking clock. It runs until delivery slides past the time limit. After that it tips over without warning.
- Long free text doesn’t belong in a shared blob. Structure and bullet points, yes; novels, no. Store large content as its own records and load it on demand.
- Think about the knock-on damage. A single slow read can drain the pool through retry loops and take everything with it.
- Optimistic locking protects against data loss but masks the symptom. When the protection kicks in, it looks “broken” to the user — plan for understandable error feedback from the start.
Conclusion
In the end it wasn’t a dramatic bug but a structural problem that had grown slowly and hidden behind the wrong symptom. The suspect (-->) was innocent, the perceived problem (saving) wasn’t the problem at all, and the solution sat one level below the reflex. That’s exactly why I like debugging sessions like this: you don’t just learn the one bug, you learn a way of thinking. When something won’t save, don’t look at the write path first — check whether the read is still getting through.
Entdecke mehr
Reducing Supabase Egress: How We Found the Root Cause After Two DB Moves
Every tiny edit pushed hundreds of kilobytes through the wire. After two full database moves, we finally treated the architecture instead of the symptoms.
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.