“It was fine yesterday.” That is how a stalled app gets reported, and the sentence is true. Yesterday you were the only person using it, which means one request at a time, a handful of rows, and a database with nothing else to do. The first evening a hundred people arrive in the same minute is the first time your app has ever been asked to share, and it tends to be the biggest evening you’ve had.
Why your AI-built app stalls at 100 users
The stall is almost never slow code. Across the 21 third-party apps AxonBuild audited in June and July 2026, the Performance & Scale pillar averaged 53.3 out of 100, and the findings behind that number were wiring: a fresh database connection opened per request with no pooler, a query looping inside another query, a foreign key with no index, a list endpoint fetching the whole table. All four multiply with something solo use never multiplies, whether that’s concurrent requests, rows per account, or rows in the table, so all four sit dormant until a crowd shows up.
The fixes are correspondingly small: a connection string, a join, one line of SQL, a limit. The four failure modes below are ordered by how often they take apps down; the load drill at the end surfaces all four in one afternoon, no real traffic required.
Failure mode 1: the connection cliff
The most common stall is arithmetic. Postgres accepts a fixed number of direct connections, and a default (free-tier) instance caps out around 60. A serverless function opens a fresh direct connection every time it runs, and your host will happily spin up hundreds of function instances when traffic spikes. The moment concurrent requests outnumber the connection cap, new requests can’t get a connection at all. They wait, then they error, and they take every page that touches the database down with them.
The fix is a pooler, which keeps a small set of real database connections open and hands them out to many short-lived requests, so a thousand function invocations share sixty connections instead of demanding a thousand. Supabase connection pooling ships with every project; using it comes down to which connection string your app was generated with:
# Direct: one real Postgres connection per client. Long-lived servers only.
postgresql://postgres:[password]@db.<project>.supabase.co:5432/postgres
# Transaction-mode pooler: built for serverless. Same database, port 6543.
postgres://postgres.<project>:[password]@aws-<region>.pooler.supabase.com:6543/postgres
Supabase’s guide to connecting to Postgres is blunt about which is which: the direct string is for persistent servers, and transaction mode is for “serverless or edge functions, which require many transient connections.” The AI uses whichever string it saw first, which is usually the direct one.
What makes this failure mode cruel is how it presents. A crash would at least announce itself. A connection queue just hangs, then times out, or worse, returns 200 OK on a failure the app never noticed. From your side it feels like “the site got slow around 9pm,” on the one night that mattered.
The connection cliff waits for your best traffic day, the one day you can least afford to meet it.
Failure mode 2: the N+1 that works fine for ten
An N+1 query is one query to fetch a list, then one more query for each item in it. Ten items, eleven queries. It works fine until a customer has a thousand records instead of ten, at which point one page load fires a thousand queries at a database that was never asked to plan for it.
It hides behind an ORM’s convenience methods, which is exactly why AI generates it: the code reads cleanly and the demo, with its dozen rows, is instant.
// One query to load the list...
const orders = await db.order.findMany({ where: { userId } });
// ...then one more query per order. Ten orders, eleven round trips.
for (const order of orders) {
order.customer = await db.customer.findUnique({ where: { id: order.customerId } });
}
The fix is to ask for the related data once, with an include / join or a single where id in (...), so ten items cost two queries and a thousand items still cost two. Left unfixed, the query count grows with your data, and your data grows with your success.
Failure mode 3: the index the AI didn’t add
Postgres does not create an index on a foreign key column for you. It indexes primary keys and unique constraints automatically; the column that points at another table gets nothing unless you say so. The PostgreSQL documentation on constraints says it plainly: “it is often a good idea to index the referencing columns too,” and the constraint declaration deliberately doesn’t do it for you. The AI writes the constraint and stops there.
-- The AI wrote this: a foreign key, no index.
alter table orders add column customer_id uuid references customers(id);
-- Postgres won't add this. You have to:
create index on orders (customer_id);
Without the index, every where customer_id = ... and every join on that column reads the entire table. At fifty rows the scan is free and invisible. At a hundred thousand it runs on every request, and it is the single most common reason a query that was instant in dev crawls in production. The missing index is a scale risk rather than a fire today, but it triggers on exactly the row count real customers create.
Failure mode 4: the unbounded select(’*’)
An unbounded query fetches every matching row with no limit. select('*') with no pagination is fine when the table holds fifty rows, and it is still the same line of code when the table holds fifty thousand. The request gets slower in lockstep with your growth, transfers more data than any screen can show, and holds a database connection open the whole time, which feeds straight back into the connection cliff above.
I met the cleanest version of this in one of the audits, a Q&A platform. The hook that loaded questions fetched the entire table with no limit, and three of the app’s hottest pages reused that same hook. What stayed with me was the pagination component sitting in the same codebase, built, wired to nothing. The app had the fix on the shelf and shipped the unbounded query anyway. I still don’t know what the generator was responding to when it built that component; no code ever asked for it.
The fix is a limit and a cursor: fetch one page, offer “next,” and never let a single request try to load the whole table. If an endpoint’s response size grows every week, that endpoint isn’t finished.
Why your vibe-coded app gets slow as users arrive
Each of the four failure modes has a trigger your own testing never pulls. The connection cliff needs concurrency, and you are one tab. The N+1 needs an account carrying hundreds of records, but a test account accumulates a dozen. As for the missing index and the unbounded query, both need a table months into real growth, and yours was seeded last week. Local dev, in other words, is the one environment where these bugs cannot occur, and it is where the app spent its whole life before launch.
That is also why the failure feels so sudden. The app holds flat right up to the threshold and then drops, which is what “it was fine yesterday” is actually reporting: yesterday never pulled the trigger. When a founder concludes that a vibe-coded app won’t scale, this environment gap is usually the whole story. The same one-project blindness shows up on the deploy side too, where one database and no staging leave every schema change rehearsing in production.
Load-test without a launch
You don’t need real traffic to find any of this. A script that fires a hundred concurrent requests at your busiest endpoint, run against a database seeded with a realistic amount of data, pulls all four triggers on purpose:
- Seed the database with realistic volume: tens of thousands of rows on your busiest tables, not a dozen demo records.
- Fire 100 concurrent requests at your hottest endpoint (a load script, or a tool like k6 / autocannon) and watch response times, not just status codes.
- Confirm the connection count stays flat under the burst, which means the pooled connection string rather than one direct connection per request.
- Add the missing index, collapse the N+1, and put a limit on every list query; re-run the same script until the response times hold flat under the burst.
While the script runs, watch your bill as well as your latency, because a spike spends money at the same moment it spends patience: in the same 21-app corpus, 13 apps had no rate limiting on their most expensive endpoint, so the burst that queues your requests can also run up your email, AI, or compute spend unmetered. Slowness carries its own price even when nothing errors: the BBC found it lost an additional 10% of users for every additional second its site took to load.
Fix the cheapest high-impact item first, which is usually the index. The whole drill fits in an afternoon; if you’d rather have the triggers found for you, the Beyond the Demo Audit reads all four out of your code before launch day pulls them. Load is one of the seven questions that decide whether an AI-built app is ready to launch, and it is the only one of the seven you can fully answer with a script.
Common questions about AI app performance
Why does my app slow down with more users?
Because something in it multiplies with users or data without being designed to: a database connection per request, a query per list item, a table scan per lookup, or every row returned per page load. In AI-built apps these four account for most slowdowns, and each has a small, local fix; a bigger instance only moves the cliff.
How many users can a vibe-coded app handle?
There is no fixed number, because the ceiling is set by the wiring above, and every app has its own mix. The same default-tier Postgres instance that falls over at roughly 60 concurrent requests on direct connections will carry thousands of light users behind a pooler with indexed queries and paginated lists. The honest unit to count is concurrent requests on your busiest endpoint, not total signups.
Do I need a rewrite, or just the wiring fixes?
Almost always the wiring. Each fix in this post is a connection string, a join, one create index, or a limit; none requires new architecture. A rewrite becomes the answer only when the data model itself fights the workload, which is rare at the hundred-user scale. Run the load drill above before deciding: if response times hold flat once the four fixes are in, the architecture was never your problem. If you built on Lovable and want the same verification mindset applied beyond performance, is Lovable production ready walks the rest of the readiness areas.
Don't guess where you stand.
The free 2-minute Beyond the Demo Scorecard estimates where you stand across 12 readiness areas and shows what to check first.