ZevCloud ZevCloud Blog

Blog

Your app does not need 100 database connections

Why "too many clients already" has almost nothing to do with your traffic, how to work out the pool size your app actually needs, and the settings that fix it in Prisma, Django, Laravel, Rails and plain Node.

DAN DAN updated September 19, 2026 6 min read
Your app does not need 100 database connections

The first time an app falls over with this, it never looks like a configuration problem:

FATAL: sorry, too many clients already

MySQL words it differently and means the same thing:

ERROR 1040 (HY000): Too many connections

The instinct is to read it as a traffic problem. We are getting popular, the database is maxed out, time to pay for a bigger plan. Then you look at the dashboard and see forty requests a minute against a database that is bored, and none of it adds up.

It does not add up because a connection is not a request. That one distinction explains most of what follows.

A connection is a seat, not a customer

When your app opens a connection, PostgreSQL forks a process for it. That process gets its own memory for sorting, caching and query planning, and it lives until the connection closes. MySQL does the same thing with a thread. The database is holding a seat for you whether or not you are using it.

A request is a customer walking in, ordering, eating and leaving. A connection is a table reserved under your name for the night. Ten reserved tables and two customers is not a busy restaurant. It is a badly run one.

This is why the limit on a database plan looks small next to your traffic numbers. A hundred connections sounds miserly until you notice each one costs real memory before it does any work.

The math nobody does

Here is where the number actually comes from. Every process that talks to your database keeps its own pool, and those pools add up:

total connections = instances x pool size per instance

A modest setup:

Nobody chose 51. Each part chose a sensible-looking number on its own, and the database got the sum. Most frameworks default to a pool of 5 or 10 per process, so this arithmetic runs away quietly as you scale out.

Now the part that surprises people: those 51 connections are mostly idle. A pool of 10 does not mean 10 queries at once. It means 10 seats held open so that a query never has to wait for a handshake. Your app is probably running one or two queries at a time and holding fifty seats.

Look before you change anything

Guessing at pool sizes is how you end up back here in a month. Measure first.

PostgreSQL, counting only real client connections and excluding the one you are typing into:

SELECT count(*) FROM pg_stat_activity
WHERE backend_type = 'client backend' AND pid <> pg_backend_pid();

Then see who is holding them, and what they are doing:

SELECT state, count(*) FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state ORDER BY count DESC;

If most rows say idle, your pools are too big. If most say idle in transaction, you have a worse problem: code that opens a transaction and then goes off to call an API or process a file before committing. Those hold a seat and hold locks.

MySQL and MariaDB:

SHOW STATUS LIKE 'Threads_connected';
SELECT user, count(*) FROM information_schema.processlist GROUP BY user;

Run these while the app is under normal load, not at 2am. The number you care about is the steady state plus headroom for a spike, not the worst case you can imagine.

The settings that actually change the number

Pool size is set in your app, not on the database. The name changes per stack, the idea does not.

Prisma takes it in the connection string, which catches people out because nothing in schema.prisma mentions it:

DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=5&pool_timeout=20"

Prisma's default is num_cpus * 2 + 1, computed on the machine the app runs on. On a shared host that reports a big CPU count, that default can be much larger than you expect.

node-postgres, where the default max is 10:

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 })

Django does not pool by default. Each worker holds one connection, reused for CONN_MAX_AGE seconds:

DATABASES = {
    "default": {
        # ...
        "CONN_MAX_AGE": 60,
    }
}

With Gunicorn, your connection count is roughly workers x threads. Four workers with four threads each is sixteen, from one deployment.

Laravel is the same shape. PHP-FPM gives each worker process one connection, so pm.max_children in your FPM pool config is your connection count. Setting it to 50 on a plan with 25 connections is the whole bug.

Rails should match the pool to the threads that can actually use it:

production:
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

Serverless and edge functions are the sharp edge. Every cold instance opens its own connections, and you do not control how many instances exist. Keep the pool at 1 or 2, and if the platform spins up instances aggressively, put a pooler such as PgBouncer in transaction mode in front of the database so hundreds of short-lived clients share a handful of real connections.

What good looks like

A small production app with four instances usually wants a pool of 3 to 5 per instance, not 10. Start from what you measured:

  1. Take the peak concurrent queries you observed, not requests per second.

  2. Divide by the number of instances.

  3. Add one or two for headroom.

  4. Leave a few connections spare for migrations, a backup job and yourself.

A pool that is slightly too small shows up as requests waiting a few milliseconds for a free connection. A pool that is too big shows up as your database refusing connections at the worst possible moment. The first is a graph. The second is an outage.

Fail like an adult when you do hit the limit

Even sized correctly, something will eventually try to connect when there is no room. What your app does next decides whether users notice.

Set a short connection timeout so a request fails in two seconds instead of hanging for thirty. Retry once with a small delay, then give up and return an error, because a retry storm against an exhausted database is how a small incident becomes a long one. Keep a health check that does not open a new connection on every poll, since a ten-second health check with a fresh connection each time is its own slow leak.

And close what you open. The classic leak is a script that opens a connection per loop iteration, or a serverless handler that creates a client outside a try/finally and never closes it on the error path.

How we handle this on ZevCloud

Every ZevCloud database plan includes a set number of simultaneous connections, and that number is on the plan when you create the database. Two things we do deliberately around it.

The database page shows open connections against your plan's limit, so you can see the steady state instead of guessing. If usage sits high for a while, the team owner gets an email before anything is refused, with the same pooling advice as above, because in most cases the fix is a pool setting rather than a bigger plan.

And when a limit change needs a brief restart, we schedule it, tell you the time, and give you a button to take it sooner if the timing suits you better. A database that restarts without warning is not something we are willing to ship, even when the change is in the customer's favour.

If you want the specifics for your engine, the databases guide has the per-engine detail.

← All posts