My App Crashes When More Than a Few People Use It

Written By
SprintX Team
AI & Product Engineering
August 06, 2026
6 min read

Why an AI-generated app falls over at ten concurrent users instead of ten thousand, how to identify which limit you are hitting, and what to fix first.
It held up fine through the whole build. You clicked through it a hundred times, your co-founder used it, a friend tested it. Then you posted the link, twenty people arrived at once, and it fell over — timeouts, 500s, a spinner that never resolves.
Twenty concurrent users is nothing. Modern infrastructure handles that on the smallest instance available. So the number is not the problem: something in the app has a hard limit far below where it should be, and the traffic just found it.
The good news is that a crash at twenty users is much easier to diagnose than a crash at twenty thousand. The limit is close to the surface, and there are only about five candidates.
Establish what "crashes" actually means
Before touching anything, find the failure mode. These have completely different fixes and people report all of them as "it crashed":
- The server process dies and restarts. Look for out-of-memory kills or unhandled exceptions in your host's logs.
- Requests time out but the process is alive. Something is blocking — usually the database or an external API.
- The database rejects connections. You will see explicit "too many connections" or pool-timeout errors.
- The frontend hangs while the backend is fine. A client-side loop, not a capacity problem.
You need three things open: your host's logs, your database's dashboard, and your browser network tab during a failure. If you have none of those, that is finding number one — an app you cannot observe is an app you cannot fix, and adding observability pays for itself the first time this happens.
Cause 1: database connections, by a mile
This is the most common single reason a small app dies under modest load, and it is almost universal in generated code deployed to serverless hosts.
A managed Postgres instance on a starter plan often allows 20 to 60 concurrent connections. Every serverless function invocation that opens its own client consumes one. Twenty users clicking around simultaneously can easily produce more than sixty in-flight invocations, and the sixty-first request fails.
Signs it is you: errors mentioning connection limits or pool timeouts, failures that clear up on their own after a minute, and a database dashboard showing connection count pinned at the ceiling.
The fixes, in order of effort:
- Use a connection pooler. Supabase's pooling endpoint, PgBouncer, or your provider's equivalent. This is a connection-string change and it resolves most cases outright.
- Reuse the client. Generated code frequently creates a new database client inside every handler. Create it once per module and reuse it.
- Close what you open. Any code path that opens a client in a loop or inside a request handler without releasing it leaks connections until restart.
Cause 2: queries that grow with your data
The query that returns in 8ms against 50 rows takes 4 seconds against 50,000, and under concurrency those 4-second queries stack up until everything queues behind them.
Three patterns account for most of it:
- No index on the column you filter by. Every tenant-scoped query filters on an organization or user ID. Without an index that is a full table scan, on every request, per user.
- Selecting everything. Generated queries commonly fetch all columns and all rows, then filter in application code. That is fine at demo scale and fatal later.
- Query inside a loop. Fetch a list, then one query per item to get its related record. Twenty items becomes twenty-one queries; a hundred users doing it becomes a self-inflicted denial of service.
If your symptoms are more "slow and getting slower" than "hard crash", the deeper treatment is in an app that slows down once there is real data.
Cause 3: state that should not be shared
Concurrency bugs that never appear with one user appear immediately with ten. Look for data stored at module scope in the backend — a variable holding the current user, a cached record, an in-memory session store, a client that carries request state.
With a single user, that variable is always right. With ten, user B's request overwrites the value user A is about to read, and people see each other's data. Worse than a crash, because it looks like it works.
If you have ever had a user report seeing someone else's information, stop reading and check for module-level state and for missing row-level authorization together. Auth bypass patterns in AI-generated apps covers the second half.
Cause 4: memory, files, and the things that never get released
Processes that die and restart under load usually die from memory. In generated apps the recurring causes are handling file uploads entirely in memory, loading a full table into a variable to compute a total, an in-memory cache with no eviction, and event listeners registered per request but never removed.
A quick tell: memory climbs steadily during traffic and never comes back down between bursts. That is a leak, not a capacity issue, and a bigger instance buys you an hour.
Cause 5: no limits anywhere
Nothing in a generated app tells it to stop. No request timeout, no maximum page size, no rate limit, no concurrency cap on outbound calls. One user requesting an unbounded export can consume the whole process, and a bot scraping your public endpoints can do it accidentally.
Set boundaries: a page-size cap on every list endpoint, a timeout on every outbound HTTP call, and rate limiting on your API. These are small changes with disproportionate effect, because they convert a total outage into a single failed request.
Diagnose it in the right order
| Evidence you see | Most likely cause | First thing to check |
|---|---|---|
| Connection or pool errors | Connection exhaustion | Pooler enabled, client reused |
| Slow queries in the DB dashboard | Missing indexes, N+1 | Query plan on the slowest endpoint |
| Memory climbs then process restarts | Leak or in-memory processing | Upload handling, unbounded caches |
| Users see each other's data | Shared module state | Backend variables outside handlers |
| 429s or upstream errors | Third-party limits | Retry logic and outbound concurrency |
| Fine on server, hung in browser | Client-side loop | Network tab request count |
Work top to bottom on the row that matches your evidence rather than fixing everything at once. Load problems are one bottleneck at a time — you fix the connection ceiling and the next constraint appears, which is progress, not failure.
Prove it before your users do
The point of load testing is not a big number; it is knowing where your app breaks so you are not learning it from a launch. Run a small test that ramps to fifty concurrent users on your real endpoints against a staging environment with realistic data volume, and watch the same three dashboards you opened earlier. Load testing before launch walks through doing this in an afternoon.
Frequently asked questions
Will a bigger server fix it? Only if the limit is genuinely CPU or memory, which it usually is not at this scale. Connection exhaustion, missing indexes, and shared state all survive an upgrade — you will pay more and crash at thirty users instead of twenty.
How many concurrent users should a normal app handle? A modest, competently built web app on a single small instance handles hundreds of concurrent users comfortably. If you are struggling in the double digits, you have a specific bug, not a scaling problem.
Is serverless the wrong choice for me? No, but it changes the rules. Serverless multiplies your database connections and gives you strict execution timeouts, so it needs a pooler and bounded work per request. Both are configuration, not architecture.
If your app falls over at a level of traffic that should be routine, there is one specific ceiling doing it and it can be found in a day. SprintX finds the bottleneck, fixes it properly, and load-tests the result so the next launch is boring. Send us your repo and current error logs.


