Skip to content
Infrastructure

What ten thousand users actually breaks, in the order it breaks

Ten thousand users is small for a database and large for an app nobody measured. The four bottlenecks in the order they arrive, and what each costs.

13 min readKato Chojiro

"Can it handle ten thousand users?" is the question every founder asks and nobody can answer, because it is not actually a question about capacity. Ten thousand monthly users is roughly a few hundred people a day, a few dozen at once, and a peak of maybe twenty to fifty requests a second. A $15 managed Postgres does not notice that. Your laptop does not notice that.

What breaks at ten thousand users is never the hardware. It is four specific things, they arrive in a predictable order, and every one of them is cheap to fix before it happens and expensive during.

First: queries with no ceiling

This is the earliest and the most common. A list view that selects an entire table with no index, no limit and no pagination. It is instant at two hundred rows. At two hundred thousand it is a timeout, and the crossover happens in the same week your launch goes well.

Its sibling is the N+1: fetch a list of fifty orders, then fetch the customer for each one inside a loop. Fifty-one queries where two would do. Generated data-fetching code produces this constantly, because it is the most readable way to write it.

sql
-- Postgres tells you exactly where this is. Run it against production.
select
  round(mean_exec_time::numeric, 1) as avg_ms,
  calls,
  round((total_exec_time / 1000)::numeric, 1) as total_seconds,
  left(query, 90) as query
from pg_stat_statements
order by total_exec_time desc
limit 15;

Sort by total time, not by average. A 40ms query called two hundred thousand times a day costs you far more than a 3-second report somebody runs on Mondays, and it is the one nobody looks at because it does not feel slow.

The fix is boring: read the query plan, add the index it asks for, paginate anything unbounded, and batch the N+1 into a single query with a join or an IN clause. A day of work, typically. Do it before you need it and it is invisible; do it during an incident and it is the incident.

Second: connection exhaustion

This one is specific to how modern apps deploy, and it takes people completely by surprise because the symptom does not look like a database problem.

Postgres allows around a hundred concurrent connections by default. A serverless deployment creates an instance per concurrent request, and each instance opens its own connection. Fifty concurrent users can therefore open more connections than the database will accept, and the fifty-first request fails with a connection error - while the database itself is almost entirely idle.

The fix is a connection pooler between your app and the database - PgBouncer, or your provider's built-in pooled endpoint. Supabase, Neon and RDS all offer one and the change is usually a different connection string. Use transaction-mode pooling for serverless, and be aware that it does not support session-level features like prepared statements in every client, which is the one gotcha worth knowing before you switch.

Deployment styleConnections per instanceWhat you need
Long-running server, 2 instancesA pool of 10–20 eachNothing. This is already fine.
Serverless functions1 per concurrent invocationA pooler, in transaction mode, from day one.
Edge runtime1 per invocation, globallyAn HTTP-based driver or a pooler. Never a raw socket.
Background workersA small dedicated poolSeparate credentials so a runaway job cannot starve the web app.

Third: work happening inside the request

The user submits a form. Your handler writes a row, sends a confirmation email, calls a payment provider, posts to a webhook, generates a PDF, and then returns a response. Every one of those is a network call to a service you do not control, and the user is waiting for all of them.

At low traffic this is merely slow. Under load it is a cascade: each request holds a worker and a database connection for two seconds instead of fifty milliseconds, so you need forty times the capacity for the same throughput. And when the email provider has a bad minute, your signup form is down.

  • Anything that talks to a third party belongs in a background job, not in the request.
  • Anything the user does not need to see the result of belongs in a background job.
  • The request should do the write, enqueue the rest, and return. Under a hundred milliseconds is a reasonable target for a typical write.
  • Jobs need retries with backoff, and a dead-letter queue so a permanently failing job is visible rather than silently gone.

This is usually the largest single latency win available, and it is the one founders resist most, because moving work out of the request feels like it makes the code more complicated. It does. It also stops your signup page depending on the uptime of five other companies.

Fourth: no caching, anywhere

By the time the first three are fixed you are comfortably past ten thousand users, and the next ceiling is that every request recomputes everything. The dashboard aggregates the same numbers for every viewer. The homepage queries the database to render content that changed last Tuesday. Images are served from the application rather than from a CDN.

  1. Static assets and images to a CDN. This is usually a configuration change and it removes a surprising share of your traffic.
  2. Cache expensive reads that tolerate being a few minutes old, and show the freshness in the interface rather than pretending it is live.
  3. Pre-aggregate anything a dashboard computes on every load. An hourly rollup is cheaper than a query per viewer, by a lot.
  4. Set cache headers on API responses that are genuinely public. Most apps set none at all.

Finding your own ceiling this afternoon

You do not need a load-testing platform. This is enough to find the first bottleneck on nearly every app we audit:

bash
# Install k6, point it at the path your users actually hit, ramp to 50 VUs.
# The goal is not a pass mark - it is to find which thing bends first.
cat > load.js <<'JS'
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 10 },
    { duration: '2m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: { http_req_duration: ['p(95)<400'] },
};

export default function () {
  const res = http.get(__ENV.TARGET);
  check(res, { 'status 200': (r) => r.status === 200 });
}
JS

TARGET=https://staging.yourapp.com/dashboard k6 run load.js

Run it against staging, not production, and watch three things while it runs: p95 latency, the error rate, and database connections in use. Whichever moves first is your bottleneck. Nine times out of ten it is one of the four above, and you now know which one.

What it costs

Vendor list prices at the time of writing, for a well-built app at each size. Not a quote - the point is the shape.

Users / monthInfrastructureTypical cost
Under 1,000One small instance, one managed Postgres, backups on$25 – $50 / mo
10,000Two instances, pooler, CDN, background worker$120 – $300 / mo
100,000Autoscaling, read replica, cache, queue, monitoring$400 – $1,200 / mo
1,000,000The above plus regional deploys and a real cost modelDepends entirely on the workload

The row that matters is the second one. Getting from a prototype to something that comfortably serves ten thousand people costs a few hundred dollars a month and roughly a week of engineering. The reason most apps do not have it is not the price. Nobody has told the founder which four things to buy, and that list is the first page of a production readiness audit.

KC

Written by Kato Chojiro

Takes AI-built MVPs to production: security, data, and the infrastructure underneath.

Connect on LinkedIn
Book a Free Call

Want a second pair of eyes on your app?

The Production Check is free, and the written report is yours to keep.

Book a Free Call