One hundred is not a magic number. It is roughly where four separate limits in a default Replit deployment start to overlap: a single-threaded development server, a filesystem that does not survive redeploys, an instance that sleeps, and a connection pool sized for one user. Any one of them alone produces a slow app. Together they produce the launch-day pattern we get called about - the app was fine at breakfast, went on Product Hunt at nine, and by ten it was returning 502s and had lost three hours of signups.
Here is each bottleneck, why it exists, and the specific change that removes it.
1. You are serving production traffic from a development server
Check what actually starts your app. If the command is flask run, python manage.py runserver, npm run dev, next dev, vite, or rails server with no explicit production settings, you are running a tool that was written for one developer on one laptop.
Werkzeug's own documentation says its server is not designed to be particularly efficient, stable, or secure. That is not a caveat, it is a specification. In practice it means:
- One request at a time. A 300ms endpoint caps you at roughly three requests per second regardless of how much CPU the box has.
- Debug mode, if left on, exposes an interactive console. Reachable from the internet, that is arbitrary code execution on your server.
- No request timeouts, so a single slow upstream call can pin the process indefinitely.
- A filesystem watcher for hot reload, burning CPU and occasionally restarting the process mid-request.
- Verbose stack traces returned to the client, which leak file paths, library versions, and sometimes environment values.
The replacement is a real application server with multiple workers and hard timeouts:
# Python: Gunicorn with uvicorn workers for async frameworks
gunicorn app:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 30 \
--graceful-timeout 20 \
--max-requests 1000 \
--max-requests-jitter 100 \
--access-logfile - --error-logfile -
# Node: build once, then run the compiled server
npm ci --omit=dev && npm run build && npm run startWorker count is not a guess: for synchronous workloads start at (2 x CPU cores) + 1 and measure. --max-requests recycles workers periodically, which quietly papers over slow memory leaks - a legitimate production technique, not a hack. --timeout is the one that saves you at 3am, because it converts an indefinite hang into a 502 you can alert on.
2. Your database is a file on a disk that will not exist tomorrow
Container filesystems are ephemeral. Redeploy, scale, or get rescheduled onto a different host, and anything written to the local disk is gone. If your app writes app.db or saves uploads to ./uploads, that is your user data, and it is deleted on a schedule you do not control.
This is the failure that hurts most, because it is silent. Nothing errors. The app comes back up with an empty database, and by the time someone notices, the signups from the last week are unrecoverable. There is no backup, because you cannot back up a disk that has already been replaced.
SQLite has a second problem even when the disk persists: one writer at a time. Under concurrency you get database is locked errors. WAL mode helps with readers, but it does not make SQLite multi-writer, and it does nothing at all once you run two instances, at which point you have two divergent databases and no way to merge them.
Move to managed Postgres and object storage. The migration is usually an afternoon, and the type conversions are where the care is needed - SQLite stores booleans as integers and timestamps as text, so a naive dump produces a schema with no real constraints:
# 1. Dump, convert, load
sqlite3 app.db .dump > sqlite.sql
pgloader sqlite://app.db postgresql://user:pass@host/dbname
# 2. Then fix the types the conversion could not infer
psql "$DATABASE_URL" <<'SQL'
alter table users
alter column is_active type boolean using (is_active::int::boolean),
alter column created_at type timestamptz using (created_at::timestamptz);
alter table orders
add constraint orders_user_fk foreign key (user_id) references users(id);
SQLUploads go to S3, R2, or Supabase Storage, served through a CDN with signed URLs for anything private. Application containers should be able to be destroyed at any moment with zero data consequence. That property is what makes everything else - autoscaling, blue/green deploys, instant rollback - possible.
3. The instance sleeps, and the cold start is your first impression
Free and autoscale tiers scale to zero. The first request after an idle period pays for the container image being pulled, the process booting, the framework importing, the ORM building its metadata, and the first database connection being established with a TLS handshake. On a Python app with a heavy dependency tree that is comfortably eight to fifteen seconds.
The founder never sees it, because the founder hits the app forty times a day and it is always warm. The visitor who clicked your launch link sees a blank page and leaves. Then the next visitor arrives while the instance is still booting, and because there is no readiness gate, the load balancer sends them into a process that is not ready yet.
- For anything customer-facing, run at least one always-on instance. Reserved capacity is the single cheapest performance improvement available to a small app.
- Add a /healthz endpoint that returns 200 without touching the database, and a separate /readyz that does. Health is 'the process is alive'; readiness is 'send me traffic'. Conflating them causes restart loops.
- Trim the image. Multi-stage builds, no dev dependencies, no build toolchain in the runtime layer. Image pull time is a real component of cold start.
- Lazy-import heavy libraries so importing your web module does not drag in a machine-learning stack you use on one route.
- Warm the connection pool at boot rather than on the first request, so user number one is not the one paying for it.
4. Connection math nobody did
Postgres allocates a process per connection and enforces a hard limit. Small managed instances typically allow between 60 and 100, with some reserved for superusers. Now count what your app actually opens: four Gunicorn workers, each with a pool of ten, is forty connections from a single instance. Autoscale to three instances during your launch and you are at one hundred and twenty, against a limit of sixty.
The error is FATAL: remaining connection slots are reserved for non-replication superuser connections, and it arrives exactly when traffic is highest, which is exactly when it is least welcome.
-- What is actually connected right now
select state, count(*)
from pg_stat_activity
where datname = current_database()
group by state
order by count(*) desc;
-- Your ceiling
show max_connections;
-- Connections sitting idle inside a transaction are the worst kind:
-- they hold locks and block vacuum.
select pid, state, now() - state_change as idle_for, query
from pg_stat_activity
where state = 'idle in transaction'
order by state_change;Put a pooler in front of the database. Supabase exposes a transaction-mode pooler on port 6543; elsewhere it is PgBouncer or your provider's equivalent. Then size the application pool small - two to five per worker is plenty when a pooler is doing the multiplexing. One gotcha: transaction-mode pooling does not support session-level features, so prepared statements must be disabled in your driver (?pgbouncer=true for Prisma, statement_cache_size=0 for asyncpg).
5. Everything happens inside the request
The fifth bottleneck is architectural rather than infrastructural, and it is the one that survives all the fixes above. If sending a welcome email, calling an LLM, generating a PDF, or resizing an image happens inline, that request occupies a worker for the entire duration. An eight-second LLM call on four workers means four concurrent users, no matter how big the machine is.
Move slow, non-interactive work to a queue - Celery, RQ, BullMQ, Inngest, or a hosted equivalent - and return a 202 with a job id. The client polls or subscribes. Requests go back to being measured in milliseconds, and a third-party outage degrades one feature instead of taking down the whole app.
Measure before you change anything
All five of these are common, but yours will fail in a specific order, and guessing wastes the budget. Half an hour with a load generator tells you which limit you hit first:
// k6 run --vus 100 --duration 2m load.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "30s", target: 20 },
{ duration: "60s", target: 100 },
{ duration: "30s", target: 0 },
],
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<800"],
},
};
export default function () {
const res = http.get("https://staging.example.com/api/dashboard");
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}Watch three things while it runs: error rate, p95 latency, and database connection count. Whichever moves first is your actual bottleneck. Fix that one, run it again, and repeat. Two or three rounds usually takes an app from falling over at 100 concurrent users to holding steady at several thousand, without a rewrite and without changing a line of product code.
The order we work in
- Get state off the container: managed Postgres, object storage, verified backups. Nothing else matters if the data is disposable.
- Replace the development server with a real one, add timeouts, add graceful shutdown.
- Add health and readiness endpoints, then move to an always-on instance so cold starts stop being a user-facing event.
- Put a pooler in front of the database and size the application pools deliberately.
- Move slow work to a queue.
- Add error tracking, uptime monitoring, and alerting, so the next limit surfaces as a page rather than as a support email.
None of this requires abandoning the tool that built the app. It requires treating the production deployment of a Replit app as a separate artefact from the prototype, which is exactly the step that gets skipped when the prototype already works.
Written by Kato Chojiro
Takes AI-built MVPs to production: security, data, and the infrastructure underneath.
Connect on LinkedIn