What vibe coding leaves out

Every site I run went dark at once on 13 September, because a query I never measured read nine million rows in a day. What the vibe coding graphic leaves out, what the scans of thirty thousand apps actually found, and what my own audit found in my own code.

You can tell an app was built by prompting before you can say why. Press a button and nothing happens for a second. Not a spinner, not a shade change, nothing, because the button is waiting for the server to answer before it admits it was pressed. Then the whole page changes at once. That second is the tell. Every layer of the thing that would have covered it, the optimistic state, the acknowledgement, the caching, was a decision nobody made, because nobody asked for it, because you do not know to ask for it until an app of yours has done it to you.

I build with AI every day. The agency’s site, its admin, a time-tracking product, a project tracker, two Shopify apps, this page: all of it was written with a model in the loop and most of it would not exist otherwise. So this is not a piece against vibe coding. It is about the part the reels leave out, told through the two days in September when it left me out.

The list is right and it does not help

There is a graphic going round that shows what non-technical people think vibe coding is (a prompt, then a website) beside what a real product needs: system design, architecture, front end, APIs, database, auth, hosting, CI, security, rate limiting, caching, error tracking, monitoring and alerts, testing, scaling. Sixteen lines. Every line is true.

It is also useless in the way a syllabus is useless, because nothing on it tells you which one you skipped. You do not discover a missing layer by reading the list. You discover it when the layer fails, and the thing about a layer you never built is that it fails silently, since the alarm was in the same layer.

Thirteen minutes past one

On 13 September at about 13:00 UTC every site I run went down at once. Brassdeck, the project tracker. The Tarly portal my team clocks into. The contact form and the blog on the agency site. The list of posts on this page. Different apps, different databases, no deploy in progress, all dark within the same minute.

The cause was a number I had never looked at. Cloudflare’s free tier allows five million database rows read per day, and the limit is per account, not per app. Brassdeck had read nine million in twenty-four hours. When the account crossed the line, the database refused every read for everything on it, and the tracker took the agency down with it.

Nine million rows for an app with a few dozen users is not traffic. It is a query. Loading one board ran six correlated subqueries over the steps table, once per card, and a single load cost 44,112 rows; I know the figure because measuring it was the first thing I did afterwards and the last thing I had done before. The assistant inside the app read the whole board twice per message. And I had spent that day testing against the live database in a loop, because it was there.

Nothing warned me. There was no counter, no threshold, no email. Monitoring and alerts is line thirteen on the graphic, and it is precisely the line a prompt never produces, because you cannot describe the alarm for a failure you have not had yet. The model builds what you can imagine. The outage is by definition the thing you did not.

The fix took an afternoon. The board reads in one pass now, 3,119 rows instead of 44,112, with the output checked card by card against the old query so nothing changed but the cost. The assistant answers from the board it already fetched. Every read is counted into a daily row, and a nightly job emails me when yesterday passed a million or three times the recent average; tested against the real numbers from the 13th, it fires, and on a normal day it is silent. The same twenty-four hours would now cost 640,000 rows. And I paid Cloudflare the five dollars a month that lifts the cap, which is what I should have done the week the first paying customer arrived.

What the scans say

My outage was an availability failure, which is the polite kind. The scans that get quoted on Instagram are about the other kind.

The line doing the rounds is “57% of vibe coded apps have an open database”. The source is a passive scan of 30,998 live apps in August, and the figure is real with one word missing. Of the 3,680 apps in that set backed by Supabase, 2,096 let an anonymous visitor read tables without logging in. That is 57 per cent of the Supabase-backed ones, not of everything. The same scan found 1,332 apps, one in twenty-three, shipping a secret in their public code, and 99 per cent with at least one finding, which the authors themselves say is less alarming than it sounds because most of those are missing headers.

The “one in six” comes from a different study. Symbiotic Security scanned 1,072 apps built on Lovable, v0, Bolt, Replit, Windsurf and Tempo in June: 172 of them, about 16 per cent, allowed anyone to delete and modify data without authentication, and 309 exposed their database keys in JavaScript.

Read together, the two numbers describe one mistake. Supabase, like most modern backends, lets the browser talk to the database directly and relies on row-level security rules to decide who sees what. If you never write the rules, the database is a public spreadsheet. The prompt that builds the login page does not build the rules, because the login page works fine without them. That is line six on the graphic, auth and permissions, failing exactly the way line thirteen did for me: silently, with every screen looking correct.

I ran the scan on myself

The same day as the outage, for unrelated reasons, I had a full security review run on the Worker behind the agency’s five sites, code I had been building with a model since July and considered careful. It found real holes. A two-factor re-enrolment that did not ask for the current code first. An invoice lookup keyed on the invoice number alone rather than the number and the organisation. A timer endpoint that trusted a member id sent from the form. A field that would store a link beginning with javascript: and render it. Every one of them had passed every test I had, because every test I had was checking that the feature worked, not that it refused.

All of it was fixed the same day, and then the fix broke something, which is the honest end of the story. The hardening added a strict content-security policy with a per-request nonce, and I tested it on the paths I always test, which share one request context. The admin subdomains start a fresh one. For about ninety minutes the admin panel and the team portal loaded with every script blocked, on the day I was making them safer. The lesson was not new to me and I had it written down: verify on the host people actually use. I verified on the host that was convenient.

What I do differently, and what I do not

I still build by prompting. What changed is what surrounds it.

There is a set of scripts that runs before anything deploys, and it has grown by one every time something looked right and did nothing: a class name that matched no stylesheet, a link with no href, a script tag that ate its own backslashes, a query that read another organisation’s rows, now a count of database reads. None of those checks came from a plan. Each one is a bug that shipped, turned into a test that would have caught it. That is the real syllabus, and it is written in the order you fail.

I have a second model audit what the first one built, and I read the report rather than the summary of the report. I do not loop tests against a live database any more; there is a local copy for that. I count things, because a number nobody is watching is a number that will surprise you, and the difference between a generated app and an engineered one is not who wrote the code. It is whether anyone counted.

And I still press the buttons. Not to see whether they work, which they always do, but to see whether they wait.

More