Why AI-built apps break after the first 100 users
They break because real users hit limits a one-person preview never touches: the default email sender, unindexed queries on growing tables, the 1,000-row API cap, database connections from serverless functions, two people acting at once, and third-party rate limits. None of these show up when you test alone with ten rows of data.
On this page
What changes when real users arrive
While you build, the app has one user: you. You sign up with your own email address, create a few records, click through every screen and it all works. Then you share the link, and three things change at once.
- More rows. Tables go from 20 records you typed to thousands written by other people.
- More at once. Several people hit the same page, the same endpoint and the same row in the same second.
- More strangers. Different email providers, different networks, different devices, and inputs you never thought to try.
Each of those runs into a limit that was always there but never touched. Most of them are not in the code at all. They live in a dashboard setting, a database default or a provider's quota. That is why the app can look perfect in the preview and still fall over in its first busy week.
The 100 in the title is not a magic number. It is roughly the point where an app stops being used by its maker and starts being used by a crowd. Below are the limits I would expect to hit first, with the fix for each. For the wider list of what goes wrong in AI-built apps, start with the guide to fixing vibe-coded apps.
Seven limits that show up first
1. The default email sender
Many AI-built apps use Supabase Auth for sign-up. Supabase ships with a built-in email sender so you can test, and its docs say plainly that it "is not meant for production use". It sends 2 emails per hour across the whole project, and it refuses to deliver to any address that is not on your project's team.
That second rule is why your own sign-up test worked. Your address is on the team. Your first real user's is not, so they never get the confirmation link, and Supabase Auth returns this error code:
Supabase's error code reference explains it: the project is on the default SMTP service, which only sends to members of your Supabase organisation.
The fix is a custom SMTP provider in Authentication settings. Supabase then starts you at 30 emails an hour, which you can raise under Authentication > Rate Limits. The authentication fix guide covers the rest of the sign-up flow.
2. Tables that grow
With 20 rows, every query is instant whether or not it is written well. With 50,000, a query that scans the whole table to find one user's records gets slow, and then times out.
Two defaults cause most of it. Postgres does not create an index on a foreign key column for you: its documentation explains that declaring a foreign key leaves indexing the referencing column up to you. And Row Level Security policies written as auth.uid() = user_id can call the function once per row. Supabase recommends wrapping it so it runs once per statement, and indexing the column the policy filters on.
-- Index the column your policies and queries filter on
create index projects_owner_id_idx on public.projects using btree (owner_id);
-- Replace a per-row policy with one Postgres evaluates once per statement
drop policy if exists "Owners can read projects" on public.projects;
create policy "Owners can read projects"
on public.projects for select
to authenticated
using ( (select auth.uid()) = owner_id );I go deeper on this in why your AI-built app is slow.
3. The 1,000-row cap
Supabase's Data API returns at most 1,000 rows per request by default. The setting is called max_rows, and it exists to stop accidental or malicious requests pulling huge payloads.
It fails quietly. A list stops at 1,000 items with no error. Worse, AI-generated dashboards often fetch every row and add them up in the browser, so a total of 1,000 orders stays at 1,000 forever. Count and sum in the database, and page through lists:
// Count without downloading the rows
const { count } = await supabase
.from("orders")
.select("*", { count: "exact", head: true });
// Page through a list, 50 at a time, in a stable order
const page = 0;
const { data: orders } = await supabase
.from("orders")
.select("id, total_pence, created_at")
.order("created_at", { ascending: false })
.range(page * 50, page * 50 + 49);4. Too many database connections
This one only applies if your app talks to Postgres directly, through Prisma, Drizzle or a Postgres driver, rather than only through the Supabase client. Serverless functions on Vercel or Netlify each open their own connections, and a burst of traffic opens a lot of them.
Supabase's docs point serverless and edge functions at the transaction pooler on port 6543, and note that the shared pooler does not support prepared statements in transaction mode, so you turn them off in the driver:
import postgres from "postgres";
// DATABASE_URL is the transaction pooler string (port 6543)
// from your project's Connect dialog. Server-side only.
const sql = postgres(process.env.DATABASE_URL!, { prepare: false });For Prisma, the equivalent is adding pgbouncer=true to the connection string.
5. Two people at once
AI-generated code tends to check, then write: "is this slot free? Then book it." With one user, that works. With two people clicking in the same second, both checks pass and both bookings are saved. The same pattern creates duplicate profiles, double-claimed usernames and stock that goes negative.
The fix is to let the database enforce the rule, because the database handles both requests in order:
alter table public.bookings
add constraint bookings_slot_id_key unique (slot_id);Then handle the clash in the app. Postgres reports it as SQLSTATE 23505 (unique_violation), which Supabase passes through as error.code, so you can show "Sorry, that slot has just gone" instead of a crash.
Payments have the same problem from the other side. Stripe’s docs warn that the same webhook event can arrive more than once, and that events are not guaranteed to arrive in order. Store each event ID you process and skip ones you have seen.
6. Third-party rate limits
If the app calls an AI model, a maps service or an email API, that provider has quotas. OpenAI, for example, limits requests and tokens per minute and per day, returns a 429 error when you exceed them, and raises your limits as your account moves up usage tiers. On a new account, a handful of active users can be enough.
OpenAI's guidance is to retry with exponential backoff plus random jitter, and to respect the Retry-After header. Retrying instantly makes it worse, because failed requests still count.
async function withBackoff<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err: any) {
if (err?.status !== 429 || i >= attempts - 1) throw err;
const wait = 2 ** i * 500 + Math.random() * 250; // backoff plus jitter
await new Promise((r) => setTimeout(r, wait));
}
}
}7. Everyone signing up from the same wifi
Supabase rate-limits sign-up and sign-in endpoints per IP address: 30 requests per 5 minutes by default. The token endpoint, which handles session refreshes, allows 150 requests per 5 minutes per IP.
That is plenty for people signing up from home. It is not plenty if you launch at a workshop, a coworking space or a school, where everyone shares one IP address. The 31st person in the room gets an error. If you are planning that kind of launch, raise the limits under Authentication > Rate Limits beforehand.
The limits at a glance
| What breaks | When it shows up | What users see | The fix |
|---|---|---|---|
| Default email sender | First user who is not on your team | No confirmation email | Custom SMTP, then raise the email limit |
| Missing indexes | Tables in the tens of thousands of rows | Slow pages, then timeouts | Index filtered and foreign key columns |
| Per-row policy checks | Same point, on tables with RLS | Slow lists | Wrap auth.uid() in a select |
| 1,000-row cap | The 1,001st record | Lists and totals stop growing | Count in the database, paginate |
| Database connections | Traffic bursts, if you connect directly | Intermittent 500 errors | Transaction pooler, prepared statements off |
| Race conditions | Two users on the same row | Double bookings, duplicates | Unique constraints, idempotent webhooks |
| Provider quotas | A few active users on a new account | AI features fail with 429 | Backoff, queues, higher tier |
| Auth limits per IP | A launch event on shared wifi | Sign-up errors | Raise limits before the event |
Why the AI tool didn't catch it
None of this is a failing of the tool. Lovable, Bolt and Cursor are very good at building what you describe and showing it working. The trouble is what they can see.
- The limits live outside the code. Email caps, row caps and rate limits are dashboard settings. A model reading your repository cannot know you never configured SMTP.
- The preview has one user and no data. Every limit above needs volume or concurrency to trigger. A preview has neither.
- Prompts describe screens, not load. Nobody writes "make sure two people can't book the same slot at once". So it doesn't get built.
How to find them before your users do
You can test most of this in an afternoon, ideally on a copy of the project rather than production.
Sign up as a stranger
Use an email address that is not on your Supabase team and check the confirmation email actually arrives.
Fill the tables
Generate tens of thousands of realistic rows in a staging project with the SQL below, then use every list and dashboard.
Run the advisors
Open Supabase's Security and Performance Advisors. Lints such as unindexed_foreign_keys and auth_rls_initplan point straight at limits 2 and 3.
Race yourself
Open the app in two browsers, line up the same booking or claim, and click both at once. Check the database for duplicates.
List every quota
For each third-party service, write down its rate limits and what your app does when it returns an error.
-- Staging only. Spreads 50,000 test projects across your existing test users.
with u as (select array_agg(id) as ids from auth.users)
insert into public.projects (owner_id, name)
select u.ids[1 + (n % array_length(u.ids, 1))], 'Test project ' || n
from u, generate_series(1, 50000) as n;If you would rather have someone else look first, the free vibe-code health check gives you a quick read on where the app stands.
What to fix first
Order matters, because some of these stop people using the app at all while others make it slower.
- Email. If confirmation emails don't arrive, nobody gets in. It is a settings change, so do it today.
- Races on anything involving money or limited stock. Double bookings and duplicate charges cost you money and trust.
- Indexes and policies. Cheap to add, and they stop the slow creep as tables grow. If the data model itself is muddled, read how to fix the data model before adding indexes to tables you might restructure.
- Pagination and counts. Anywhere a list or total could pass 1,000.
- Connections and quotas. Pooling, backoff and sensible limits before your next launch push.
If the app is already live and wobbling, this is exactly what a Rescue Sprint is for: I check these limits against your real project, fix the dangerous ones first and leave notes so the next prompt doesn't undo them.
- Supabase docs: Send emails with custom SMTP, checked 10 November 2026
- Supabase docs: Auth rate limits, checked 10 November 2026
- Supabase docs: Auth error codes, checked 10 November 2026
- Supabase docs: Row Level Security, checked 10 November 2026
- Supabase docs: CLI config (api.max_rows), checked 10 November 2026
- Supabase docs: Connect to your database, checked 10 November 2026
- Supabase docs: Database Advisors, checked 10 November 2026
- PostgreSQL 18 docs: Constraints, checked 10 November 2026
- PostgreSQL 18 docs: Error codes, checked 10 November 2026
- Stripe docs: Receive Stripe events in your webhook endpoint, checked 10 November 2026
- OpenAI docs: Rate limits, checked 10 November 2026
The vibe-code rescue checklist, as a PDF
About 40 checks across auth, data, payments, errors, mobile and secrets, on two pages.
Questions founders ask
Is 100 users a hard limit for apps built with Lovable or Bolt?
No. There is no fixed number. The problems start when more than a handful of people use the app at the same time and the tables hold more data than you typed in by hand. For some apps that is 20 users, for others 500.
Will upgrading my Supabase or Vercel plan fix it?
Sometimes for one limit, rarely for the whole problem. A bigger database does not add a missing index, and a paid plan does not stop two users booking the same slot. Most of these fixes are settings and a few lines of SQL.
Can I ask the AI tool to fix these issues?
You can, once you know what to ask for. The tool only sees the code, and several of these limits live in dashboard settings or provider quotas. Name the exact limit and the exact fix, and check the result yourself.

Ant
Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me