Your connection budget is three equations, and most "too many connections" surprises come from one of them being a multiplication you did as an addition. I rebuilt a Nano-sized Supabase limit set on my laptop, pushed past every limit, and wrote down what failed, in what order, and with which error text. Some checks ran on a hosted Free project at tiny scale, and a few that only a paid plan exposes ran on a throwaway Pro project I created and deleted the same day. Scripts, numbers and a calculator are in the repo. Everything is from my runs on 2026-11-03 and 2026-11-04; prices and versions change, so numbers carry dates.
The most serious finding is not about capacity: on the transaction pooler, a setting left behind by one client leaked rows (through an RLS policy) and an admin role's privileges to the next client (section 10).
How this was made: I wrote this with Claude's help: it drafted, and I ran every step, edited the text, and checked the results. The mistakes are mine.
The non-technical version (database "seats"): https://medium.com/p/a7f0cb1df4d4
The code: https://github.com/tomozilla/connection-budget-en-demo
Before the numbers, one map. Every term in this article is somewhere in this picture: where a request enters, which pool it lands in, and what else is already holding connections.
The bottom band is the part I kept underestimating: even with zero traffic of your own, the Data API, Auth, Realtime, Storage and the health checker each take connections out of the same total. Section 5 has the measurements.
Why I measured this
I started with a question I couldn't answer: "pool size 15, but 30 backends?" Listing library defaults wouldn't explain it, so I rebuilt the limits locally, where going past a limit hurts nobody, and ran one experiment per term of the budget. Where a claim needed a hosted check, I kept it tiny.
What I did and did not do
-
Local lab (most of it): a local Supabase CLI stack with Auth, Storage, Realtime, Studio and PostgREST running (no Edge Runtime or logs),
max_connections90, pooler pool size 15, 200 max clients. Cost: $0. Supavisor 2.9.12, PostgREST 16.2, Postgres 17. Scripts re-run from a clean copy of the repo. - Hosted, at tiny scale: on the Free demo project I set the pooler pool size to 3 (Database settings), used a handful of clients for seconds to a minute with a throwaway role, then dropped the role and put the pool size back to 15. I also read the idle connection list once, with no load. Hosted results are marked "hosted" below.
- A throwaway Pro project (Micro), created and deleted on 2026-11-04. Some questions only a paid plan can answer: what Micro's limits actually are, how many connections Auth and Storage hold under load, and what the dedicated pooler's settings page says. I created one project, ran small read-only probes and two 40-request bursts, then deleted it. Cost: about $0.02 (Micro is $0.01344 per hour per the docs, 2026-11-04). Creating a Pro project requires the organization to be on a paid plan ($25/month).
- Not verified: Rails, Edge Functions with postgres.js, Npgsql / pgx / HikariCP defaults, hosted PostgREST pool settings, the platform's request limits. I could not connect to the hosted dedicated pooler either: it is IPv6-only by default and my network is IPv4, which would need the IPv4 add-on ($4/month), so I only read its settings page. I also did not fill a hosted database — that is shared infrastructure — so every "what fails first" result below is from the lab. Each experiment ran a few times at most; the latency table is one 6-second run per cell.
1. The budget, as three equations
backends in use = direct connections
+ sum over every (role, database, mode) pool of min(clients of that pool, pool size)
(a transaction-mode pool holds pool size + 1)
+ the dedicated pooler's backend connections, if you run both poolers
(the pool size setting applies to each pooler independently)
+ the Data API's own pool (10, plus one LISTEN connection = 11)
+ Auth, Storage, Realtime and the health checker, each with a pool of its own
must stay <= max_connections - superuser_reserved_connections - reserved_connections
(Nano / Micro: 60 - 3 - 0 = 57; reserved_connections is PostgreSQL 17, default 0, it was 0 on hosted Nano)
clients = running copies of the app x the library's pool size
must stay <= the pooler's client cap (Nano / Micro: 200), counted per pool (role x mode) -- measured on hosted
busy backends = queries per second x seconds per query (Little's law)
That last line is the one I used to wave at. The Pooling and limits page lists the same sharers explicitly: "Supabase services hold their own connections, including Auth, Storage, PostgREST, and the health checker, and those come out of the same total." And those are not fixed numbers — some of them grow when used and then keep what they took (section 5).
The docs' rule of thumb (Connection management): pool size under about 40% of max_connections with heavy Data API use, up to about 80% without. That is 24 or 48 on Nano. Reading it as a budget for all pools together is not my inference — the Supavisor FAQ says it directly: "if you're using the DB REST API or multiple app-based 'user+db+mode' combinations, try to keep the pooler's usage under 40% of available connections." With two roles and two modes you have four pools.
2. The lab
supabase init
supabase start -x edge-runtime,logflare,vector,imgproxy,mailpit
[db.settings]
max_connections = 90
[db.pooler]
enabled = true
pool_mode = "transaction"
default_pool_size = 15
max_client_conn = 200
Three things tripped me:
-
max_connectionsbelongs under[db.settings], not[db]. My first try under[db]did nothing; I then usedalter system, and that value, stored inpostgresql.auto.conf, overrode[db.settings]later and hid it.alter system reset max_connectionsand a restart made 90 show up (checksourceinpg_settings). - Sizing: the local pooler keeps 29 metadata connections of its own inside the database (
supavisor_meta27 plus 2 cluster); on a hosted project they live elsewhere. So I compared free slots. Hosted (Free / Nano, no load, read once with the SQL editor on 2026-11-03):max_connections60,superuser_reserved_connections3, 4 connections in use (authenticator1, which is PostgREST'sLISTEN;pgbouncer1;supabase_admin2) = 53 free of 57. Hosted, Pro / Micro (brand new project, nobody using it, 2026-11-04):max_connections60,superuser_reserved_connections3,reserved_connections0, 3 connections in use. Lab (full stack, idle): 36 in use (Supavisor 29 + 7) of 87 = 51 free. Close enough. - That Micro reading is the single most useful thing the paid project told me: Micro has the same 60
max_connectionsas Nano. Moving to a paid plan does not buy you connections. Compute size does (Small is 90). I had half-believed the opposite until I read it on a project I had paid for. - All of these are quiet moments. Under load they grow — see section 5.
- I count backends from a separate direct session as
supabase_admin(it has reserved slots, so it still works when the database is full), grouped by role andapplication_name.
select usename, state, count(*) from pg_stat_activity
where backend_type = 'client backend' and pid <> pg_backend_pid() group by 1, 2;
Idle footprint in the lab, full stack: Supavisor 29, Realtime 5 to 7, PostgREST 1 (LISTEN "pgrst"), Storage 1 to 2, Auth 0. On hosted (both Nano and Micro), grouping by usename showed no supabase_auth_admin row and no supabase_storage_admin row either.
Stopping there was my mistake in the previous round. "Zero while idle" is not "zero". Both services open connections when used, and one of them keeps them. Section 5 has the numbers.
3. The client cap is exact, and per role in my lab
201 node-postgres clients on the pooler:
connected 200
client 201: (EMAXCONN) max client connections reached, limit: 200 (0.5 s)
No waiting. With node-postgres' default Pool of 10, 21 serverless copies reach it. Is the 200 shared across roles? The FAQ only says the limit comes from the tenant's default_max_clients or a per-user max_clients. So I connected 150 clients each on r1 and r2:
2 roles x 150 clients: connected 300, refused none
3 roles x 70 clients: connected 210, refused none
In my lab the cap applied per role. I didn't connect hundreds of clients to a hosted project; I read the source instead (Supavisor main on 2026-11-03; I did not diff it against the lab's 2.9.12). Manager.check_client_limit looks up the pool by its id (tenant, user, mode, database and so on), takes the size of that pool's client table, and compares it with max_clients (the user's setting or the tenant's default_max_clients) in transaction mode, or with pool_size in session mode. So the counting unit is the pool (role x mode), which matches the lab.
The docs read the other way, which is why I went and measured it. The Pooling and limits page describes client connections as "how many clients can connect to a pooler at the same time", set by "your compute size's max pooler clients limit" — no mention of roles. The Max client connections reached error is described as exceeding "the allowed limit of your compute add-on". The Pro project's settings page says: "This value is fixed at 200 based on your compute size of Micro and cannot be changed." All project-shaped.
So I tested it on a hosted project (throwaway Pro/Micro, 2026-11-04, idle connections only — not a single query):
| attempt | result |
|---|---|
| 215 clients on one role |
exactly 200 connected; the rest got (EMAXCONN) max client connections reached, limit: 200
|
| 150 clients on each of two roles (300 total) | all 300 connected, zero refusals |
The cap is per role on hosted too, matching the lab. The settings page's "fixed at 200" is the value of one pool's cap, not a project-wide total.
Don't celebrate too early, though. While those 300 clients were connected, the database held 2 backend connections — one per role — because nothing was running a query:
300 clients (2 roles) -> 2 backends
Adding a role buys client slots. It buys zero backend slots, and equation 1 (57 usable on Nano/Micro) is the one that actually bites. Splitting across roles is a legitimate fix for EMAXCONN, but it moves the queue rather than removing it: if those 300 clients all start querying, they are contending for the same 57.
4. Pools are per (role, mode), and transaction mode holds one extra
The docs changed under me while I was writing this, in late October 2026, and the change is in my favour. The sentence I used to quote — that the pool size is shared between ports 5432 and 6543 — is no longer there. What the Connection management page says now:
This pool size applies per database user (role), not as a single total for the whole project. Each distinct role that connects through a pooler gets its own pool of up to this many direct Postgres connections, so the total connections poolers can open scale with the number of roles in use.
The "shared" axis moved from between modes to between poolers: "The pool size applies to both the shared pooler (Supavisor) and the dedicated pooler (PgBouncer)", and they "read the same setting but apply it independently, so raising it raises the ceiling for both." If you run both, add the second pooler's backends to equation 1.
Three granularities still coexist, which is worth knowing when you size: Connection management says per role, the Supavisor FAQ says per user, database and mode combination, and the dashboard's own help text says "per user+db combination". Everything I measured says mode counts too, so size with the largest reading (user x database x mode).
I put one role on both ports at once, 20 clients each, held for 8 seconds:
r1 backends: 31 (transaction port 16 + session port 15)
errors: session: (EMAXCONNSESSION) x 5
Separate pools per mode. On the hosted Free project with pool size 3, 5 clients per port:
conn_probe backends: 7 (transaction port 4 + session port 3)
errors: session: (EMAXCONNSESSION) x 2
Same shape. Then roles, with room (the first time the database filled before I could see this):
| roles | backends per role | total |
|---|---|---|
| 1 | r1: 16 | 53 to 63 (incl. the lab's internal 29; depends on whether the Data API pool was already warm) |
| 2 | r1: 16, r2: 16 | 70 to 80 of 87, fits |
| 3 | r1: 15, r2: 16, r3: 7 to 16 | 87 of 87, full (the third role gets whatever slots are left) |
Note the 16. A pool size of 15 held 16 backends in transaction mode and exactly 15 in session mode, and the hosted pooler at pool size 3 held 4 on the transaction port. For comparison, PgBouncer 1.26.1 in front of the same database, default_pool_size 15, opened exactly 15.
| door | backends at pool size 15 (lab) |
|---|---|
| Supavisor, transaction | 16 |
| Supavisor, session | 15 |
| PgBouncer 1.26.1, transaction | 15 |
The docs read as if connections strictly follow the configured pool size, and there is still no "+1" in them. I can't explain it. With three roles it is three connections, which is the difference between "just fits" and "overflows". The reproduction is in the repo (results/upstream-issue-drafts.md).
There is one line in the Supavisor FAQ that might be the same mechanism: "If a client is connected to the pooler, then at least 1 hot connection will be sustained, even if no client needs it." I tested that on the Pro project by connecting one transaction-mode client and leaving it idle: exactly one backend stayed, still there at 137 seconds — well past the roughly 2 minutes at which unused backends get closed (section 12). So the "hot connection" claim holds. Whether it is the same thing as the +1, I don't know.
5. A full database stops whatever needs a new connection first
Three roles at full pool, 87 of 87 used. I probed the other services with the lab's publishable key only:
| while full | result |
|---|---|
| new direct connection |
FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute (53300) |
| new login through the pooler |
(EAUTHQUERY) authentication query failed: connection to database not available (the pooler needs a connection to check your login) |
| Auth sign-in / sign-up |
500: Database error querying schema, Database error finding user
|
Data API (rpc/ping) |
200 (its pool already held open connections) |
| Storage upload and list |
200 (same reason) |
The rule is whatever needs a new connection fails first. In my lab Auth held no idle connection, so its sign-in broke first; the Data API and Storage kept working because the connections they already held were enough. That is not a guaranteed order:
- In a separate run that filled the database with two roles only, a Data API call that needed a new connection got
503 PGRST000. So neither "full means 503" nor "full means Auth first" is a rule. -
This round I measured the hosted side, and it changes the reading. On the fresh Pro / Micro project, Auth and Storage both hold zero connections until something uses them. So I sent 40 concurrent sign-in attempts, and separately 40 concurrent public-object requests, and counted by
usename:
| service | idle | right after the burst | later |
|---|---|---|---|
Auth (supabase_auth_admin) |
0 | 9 to 10 | back to 0 within about a minute (10 at t+46 s, 0 at t+61 s) |
Storage (supabase_storage_admin) |
0 | 8 | still 8 fourteen minutes later |
So hosted Auth keeps a warm pool of about ten once it has been used, while my lab's Auth held none. Sign-in was first to break in my lab, for that reason. On a hosted project Auth has about ten connections in hand and should ride out the moment the database fills. I did not fill a hosted database, so that last part is inference, not measurement — the point is that you cannot carry my lab's ordering over to production.
Storage matters differently: it took 8 and kept them. That is 8 of the 57 usable connections on Nano or Micro, permanently, once you serve a file. Put it in equation 1.
One caveat on Storage: the Supavisor FAQ says "the Storage server also uses Supavisor as a unique 'user+db+mode' combination, so the pool size you set for your general application will apply to it, too." On the project I measured, those connections had application_name of Supabase Storage API, not Supavisor — they looked direct, not pooled. One project, one observation; I am not generalising from it.
After the heavy clients stopped, sign-in stayed at 500 for about a minute (it came back at t+62 s, when the pooler released one backend).
One role using too much can take out other roles, the pooler's logins and Auth. I did not probe Studio.
6. Waiting, refusing, and the timeout ladder
Past the pool, one role, pool 15:
-
Transaction mode: 18 clients holding 30 s each: 16 ran, 2 waited, all done at 60 s. 17 clients holding 65 s: the waiting one fails at exactly 60 s with
(ECHECKOUTTIMEOUT) unable to check out connection from the pool after 60000ms in Transaction mode. Hosted, pool size 3, 5 clients holding 65 s: 4 ran (3 + 1), the fifth failed at 61 s with the same error. -
Session mode: 18 clients: 3 refused at once,
(EMAXCONNSESSION) max clients reached in session mode - max clients are limited to pool_size. The FAQ says session mode "can queue clients for up to a minute"; in my lab (and on the hosted pooler) it refused instead.
A prediction, tested. A closed-loop client (finishes, then sends the next) waits about (clients - backends) / backends x query time. With 199 clients and 16 backends, queries averaging over roughly 5 seconds should push the wait to the 60-second limit. Role r1, transaction mode, 100 seconds per run:
| query time | predicted wait (time in the queue) | measured p50 (wait + run time, successful requests only) | outcome |
|---|---|---|---|
| 4.5 s | 51.5 s | 54.1 s (about 49.6 s wait + 4.5 s) | 551 succeeded, no errors |
| 5.5 s | 62.9 s | 60.6 s | 464 succeeded, 46 failed with ECHECKOUTTIMEOUT (46 of 510 requests, about 9%) |
The p50 includes the query's own run time, so the 4.5 s row compares 51.5 s of predicted wait plus 4.5 s of work (about 56 s) with the measured 54.1 s. At 5.5 s the predicted wait is past 60 s, and the clients that waited that long were refused at 60 s; the ones that succeeded got their turn just before it. The prediction that the edge sits near 5 seconds held.
A client that runs BEGIN and stalls holds its backend as idle in transaction. With idle_in_transaction_session_timeout = '5s' on the role, Postgres ended it after 5 seconds (terminating connection due to idle-in-transaction timeout).
Open loop. A closed loop politely waits and hides overload; real traffic keeps arriving. New connection per request (serverless style), 100 ms queries:
100 per second: p50 105 ms, no errors
200 per second (capacity about 150):
sec 0: p50 232 ms sec 1: 494 sec 2: 767 sec 3: 1030
sec 4 on: p50 about 1260 ms, and about a quarter of arrivals refused with (EMAXCONN)
After 4 seconds 200 client connections were in flight, so the cap started refusing; the p50 of the accepted ones plateaued near 200 / 16 x 0.1 s.
Many waits stack. The shortest one fires first:
What each library does while it waits for a connection (direct, role r1):
| library | default wait | what I saw |
|---|---|---|
node-postgres Pool
|
none (connectionTimeoutMillis: 0) |
max 1: q2 at 6.0 s, q3 at 9.0 s. With 2000: timeout exceeded when trying to connect at 2.0 s |
| Prisma 6 |
pool_timeout 10 s |
connection_limit=1, five 3 s queries: the 4th failed at 10.0 s, P2024 Timed out fetching a new connection from the connection pool
|
Prisma 7 + @prisma/adapter-pg |
none (it hands the pool to node-postgres) | pool of 1, five 3 s queries: all five waited and succeeded, the last at 15.1 s. With connectionTimeoutMillis: 2000: timeout exceeded when trying to connect at 2.0 s |
| psycopg_pool | 30 s | with timeout=4: PoolTimeout: couldn't get a connection after 4.00 sec
|
| SQLAlchemy |
pool_timeout 30 s |
with pool_timeout=2: QueuePool limit of size 1 overflow 0 reached, connection timed out
|
| postgres.js |
connect_timeout 30 s, max_pipeline 100 |
no wait limit found |
Django 5.2 pool=True
|
psycopg_pool defaults (4 connections, 30 s) | threads that close at the end of a request: 20 threads x 2 s finish in 10.1 s, no errors. Threads that never close hold the pool: 4 ok, 16 failed |
The role defaults are rungs on the same ladder. Per the docs: anon 3 s, authenticated 8 s, service_role none (falls back to authenticator's 8 s), and postgres has no role timeout, "capped by default global timeout to be 2min". Reading pg_settings on the Pro project confirmed it: statement_timeout was 120000. If your admin scripts or batch jobs connect as postgres, the bottom rung of the ladder is two minutes.
If you are upgrading from Prisma 6, this is the row to read. The 10-second safety net (pool_timeout) is gone by default with the pg adapter, so a saturated pool waits forever. Pass connectionTimeoutMillis to PrismaPg.
The Django last row came from a bug in my own test: my first script never called connection.close() and everything but four threads timed out. A web request closes at its end, so this bites long-lived threads (background workers).
7. The Data API has its own pool
50 concurrent calls of a 2.5-second RPC through PostgREST:
200 x 40 latency 2.5 to 10.1 s
504 PGRST003 x 10 exactly 10 s
- Peak
authenticatorbackends: 11 = a pool of 10 plus oneLISTEN "pgrst"connection (I read it frompg_stat_activity.query). That is PostgREST's default and the CLI's setting; a hosted project can change the pool size (I didn't read it). - When the pool is exhausted, requests wait and fail after 10 s with
PGRST003. -
anonhasstatement_timeout = 3s(authenticated: 8 s). A 4-second call returned500with57014.
A slow function exposed to anon lets outsiders hold that pool. My test slow() is deliberately callable; don't copy that to a real project.
8. Library defaults, run not remembered
| library (version) | default pool | backends seen |
|---|---|---|
node-postgres 8.23.1 Pool
|
max 10 | 10 |
| postgres.js 4.0.0 | max 10 | 10 |
| Drizzle | uses the driver's pool | (pg: 10) |
| Prisma 6.19.3 | physical cores x 2 + 1 | 21 (10-core Mac) |
Prisma 7.10.1 + @prisma/adapter-pg
|
the pg driver (max 10) | 10 |
| SQLAlchemy 2.1.6 | 5 + 10 overflow | 15 (5 kept afterwards) |
| psycopg_pool 3.3.3 | min_size 4, max_size = min_size | 4 |
| Django 5.2.18 | no pool, one per busy thread | 20 (20 threads) |
Django 5.2.18 OPTIONS pool=True
|
psycopg_pool defaults | 4 |
Prisma went from 21 to 10 between majors, and its wait default changed too (previous table). A number from a blog post has a version attached.
Multiplication: node-postgres Pool(max 10) in 1, 3 and 5 processes held 10, 30 and 50 direct backends (I raised max_connections to 150 for that run). Through the pooler the same 50 clients used 16 backends. Gunicorn workers, node cluster or hot reload create the same multiplication without you noticing.
9. What breaks on the transaction pooler
I rewrote this section, because the docs caught up. What I used to present as an undocumented trap is now spelled out on the Connecting to Postgres page:
Query pipelining isn't supported: transaction mode returns a connection to the pool as soon as it sees one reply finish, even if the client already queued more queries on it.
Caution:
postgres.jspipelines queries by default, so this combination can hang queries or return mismatched rows.
Read that before my experiments. What I hit was the hang.
postgres.js 4.0.0 hung with 20 concurrent queries. Setting prepare: false and max_pipeline: 1 together fixes it, so I tried them one at a time:
| setting | lab, transaction | hosted, transaction, pool 3 | direct |
|---|---|---|---|
| default | hang | hang | ok |
prepare: false only |
hang | hang | |
max_pipeline: 1 only |
ok (1.0 s) | ok | |
| both | ok | ok |
The cause is pipelining (several queries queued on one connection; postgres.js defaults to 100), not prepared statements. "Transaction mode has no prepared statements" is true, and the docs tell you to turn them off for your library, and prepare: false is the usual postgres.js fix. It didn't fix this hang in my runs.
Does that mean prepare can stay on with max_pipeline: 1? Prepared-statement bugs only show on repetition (see psycopg below), so I repeated the same query: 200 and 3000 queries over 10 connections.
max_pipeline: 1 only (prepare on): no errors, 3000 queries x 2 runs
default, or prepare: false only: hang
session port, direct: ok
No prepared-statement failure with postgres.js here.
One suggestion was that postgres.js might be retrying silently: its source (GitHub main) does catch the "prepared statement does not exist" error (26000), re-prepare and retry. So I counted what the client sends (25-postgresjs-retry.mjs counts Parse messages and errors). Same 3000 queries, 10 connections:
| setting | result | Parse messages sent | errors |
|---|---|---|---|
| direct | ok | 10 | none |
pooler, max_pipeline: 1 only (prepare on) |
ok (0.2 s) | 10 | none |
pooler, prepare: false and max_pipeline: 1
|
ok (0.3 s) | 3000 | none |
| pooler, default | hang | (10 sent, then stuck) | |
pooler, prepare: false only |
hang | (about 1400 sent, then stuck) |
With only max_pipeline: 1, Parse went out once per connection (10), not more; a retry path would have added more. The Postgres log and the pooler log had no prepared statement or 26000 lines either. So here it is not a hidden retry; the error does not occur. I don't know why (the pooler may handle it on its side; I didn't check). Through the same pooler, psycopg and Prisma did fail.
Do the mismatched rows show up?
The docs warn about two failures, and I had only chased the hang. So I went after the other one: every query sends a unique marker and must get that marker back.
| setting (pooler, transaction) | 300 queries / 10 connections | mismatched rows |
|---|---|---|
| default | hang (69 of 300 finished in that run; where it stops varies) | — |
prepare: false only |
hang (144 of 300 in that run) | — |
max_pipeline: 1 only |
ok (58 ms) | 0 |
prepare: false and max_pipeline: 1
|
ok (38 ms) | 0 |
| (control) direct, default | ok (15 ms) | 0 |
I then ran 1000 queries over 20 connections three more times with max_pipeline: 1: not one mismatched row. In my environment it hangs before it can mismatch. "I couldn't reproduce it" is not "it doesn't happen" — trust the docs' warning over my runs here.
Does max_pipeline: 1 break sql.begin()?
This is the question I should have answered before recommending the setting. The Postgres.js page says:
postgres.jshas no working option to turn pipelining off directly:max_pipeline: 0breakssql.begin()transactions instead, a known upstream bug.
It talks about 0. I recommend 1, which is a different thing, so I tested it with real transactions: two queries inside each sql.begin(), checking (a) that each query gets its own marker back and (b) that both ran on the same backend (pg_backend_pid()), i.e. the transaction never moved to another connection mid-flight.
| setting (pooler, transaction mode) | result | wrong values | split transactions |
|---|---|---|---|
| default | ok (16 ms) | 0 | 0 |
max_pipeline: 1 |
ok (18 ms) | 0 | 0 |
prepare: false and max_pipeline: 1
|
ok (17 ms) | 0 | 0 |
max_pipeline: 0 |
error: 42P05 prepared statement "..." already exists
|
— | — |
prepare: false and max_pipeline: 0
|
ok, but 2029 ms | 0 | 0 |
max_pipeline: 1 ran transactions cleanly, repeated three times at 8 to 30 concurrent transactions.
max_pipeline: 0 behaved differently from the docs' description. The upstream issues that match that claim were closed as completed on 2026-10-29, and postgres.js 4.0.0 was published the same day. On 4.0.0 I did not get UNSAFE_TRANSACTION; I got a prepared-statement collision (42P05), which prepare: false clears — at the price of being more than 100x slower (19 ms to 2029 ms), because switching pipelining fully off serialises every query.
So in my environment max_pipeline: 1 (plus prepare: false if you want it) was the clean option. It is not the documented fix, so if you adopt it, run this same check against your own queries and transactions.
The same checks against PgBouncer 1.26.1 in transaction mode (max_prepared_statements 200 by default; not necessarily the hosted dedicated pooler's configuration):
| check | Supavisor, transaction | PgBouncer 1.26.1 |
|---|---|---|
| postgres.js default | hang | ok |
| psycopg 3 default (auto-prepare after 5 runs) | 8 to 9 of 10 connections failed | no errors |
Prisma 6 without ?pgbouncer=true
|
22 of 30 failed: prepared statement "sN" does not exist
|
no errors |
| backends at pool 15 | 16 | 15 |
SET leak (section 10) |
8 of 8 | 8 of 8 |
LISTEN / NOTIFY
|
no notification | no notification |
And the ones that really are prepared-statement bugs on Supavisor: Prisma 6 without ?pgbouncer=true (22 of 30 queries; with it, 0) and psycopg 3 (the same query run more than 5 times is auto-prepared; prepare_threshold=None fixes it; a one-shot test won't show it). Prisma 7 with the pg adapter and node-postgres were fine.
10. Session state leaks between clients, and a leaked setting becomes a leaked row
This is a safety issue, not a capacity one, and it is the part of this article that goes furthest past what the docs say.
The docs describe transaction-mode session state like this:
Session-level state is lost between transactions. This covers
setandreset, session-level advisory locks,listenandnotify, and temporary tables.
"Lost" sounds like an availability problem: your setting disappears, your code breaks, you fix it. What I measured is the other failure mode — the state is not lost, it stays on the backend and the next client inherits it. That turns it from an availability bug into a disclosure bug.
Same role, clients one after another:
| action | pooler (transaction) | direct |
|---|---|---|
set_config('app.user','alice', false) (session-wide) |
leaked into the next client, 8 of 8 | 0 of 8 |
set_config(..., true) (transaction-local) |
did not leak | did not leak |
SET statement_timeout = 0 |
the next client also had 0 (a guardrail silently removed) | did not leak |
| session advisory lock | still held after the client left | released |
| named prepared statement | prepared statement "q1" already exists |
ok |
LISTEN / NOTIFY
|
no notification | works |
A setting is only a setting until an RLS policy trusts it. Role r_app, policy owner = current_setting('app.user', true), and r_app allowed to SET ROLE r_admin (which bypasses RLS):
transaction pooler:
A (alice, set_config(..., true)) sees: alice
B afterwards sees: (none) <- safe
A (alice, set_config(..., false)) sees: alice
B (forgot to set the user) sees: alice <- leaked row
A (admin task: SET ROLE r_admin) sees: alice, bob
B (ordinary request) runs as r_admin, sees: alice, bob <- leaked privilege
direct: B sees nothing in all three cases
For contrast, the Data API: PostgREST applies per-request settings inside the request's transaction. With two users (alice, bob) from the local Auth (publishable key only) and 40 mixed concurrent requests, nobody received someone else's data: 0 of 40, and an anonymous call right after a user's call saw nothing.
Fix: on transaction pooling, don't use session-level SET; use set_config(..., true) inside a transaction you open per request.
11. Latency, not just peaks
Closed loop, one role, transaction mode, 100 ms queries, 6 s per cell:
| clients | queries/s | p50 | p95 |
|---|---|---|---|
| 5 | 47 | 104 ms | 112 ms |
| 16 | 152 | 105 ms | 108 ms |
| 40 | 154 | 218 ms | 312 ms |
| 100 | 153 | 620 ms | 724 ms |
Past 16 backends, throughput stops (about 150/s); more clients only queue. With 1-second queries, 100 clients had p50 6 s (15 queries/s). Little's law (busy = qps x seconds per query) gave 15.3 to 15.4 busy backends at 100 ms and 14.9 to 15.9 at 1 s: the pool. No errors anywhere, so error counts alone won't warn you. Watch p95.
12. Failure and lingering
Killing 5 idle pooled connections with pg_terminate_backend:
- node-postgres
Pool, direct, noerrorhandler: the process crashed (Unhandled 'error' event). With a handler: 5 error events, next 6 queries fine. - Through the pooler: no event, queries fine (the pooler replaced the backend).
- SQLAlchemy: first query after the kill failed without
pool_pre_ping, worked with it.
The Supavisor FAQ makes three specific claims about how long backends stick around:
In transaction mode, once a direct connection is established, the pooler will keep it available for reuse for another client. However, if the connection remains unused for 5 minutes, the pooler will close it to free up database resources. In session mode, the connection will be immediately closed.
If a client is connected to the pooler, then at least 1 hot connection will be sustained, even if no client needs it.
I tested all three.
| claim | what I measured |
|---|---|
| transaction mode closes unused backends after 5 minutes | Lab: about 2 minutes (present at 105 s, gone at 120 s; two runs). Hosted Pro: 6 backends at 106 s, 0 at 122 s. Both about 2 minutes, not 5 |
| at least 1 hot connection while a client is connected | Hosted Pro, one idle transaction-mode client: exactly 1 backend held, still there at 137 s — past the ~2 minute close. Confirmed |
| session mode closes immediately | Hosted Pro, one session-mode client connected then disconnected: the backend stayed over 3 minutes and was reused by a later client. It went away around 4 to 5 minutes idle. Not "immediately" |
My measurement is shorter than the documented 5 minutes, so size with 5 minutes, the larger number: I measured one project a handful of times. Either way, run short tests back to back and you will see slots occupied with nobody using them.
13. Guardrails: one role should not starve everyone
Local lab:
-
Per-role connection limit.
alter role r1 connection limit 10: r1 held 10 backends, 10 of 20 queries failed at once withtoo many connections for role "r1", and r2 was unaffected. It fails fast, so plan for retries. For a role that goes through the pooler, set the limit to pool size + 1 or more. Transaction mode, pool 15, 40 clients running 5 s queries at once:
| connection limit | r1 backends | failed queries |
|---|---|---|
| 15 (equal to the pool size) | 15 | 25 of 40 (immediately) |
| 16 (pool size + 1) | 16 | 0 (the rest wait) |
A limit equal to the pool size refuses the 16th backend and turns clients that could have waited into errors.
-
Per-role timeouts.
alter role r1 set statement_timeout = '2s'cancelled a 4-second query at 2.0 s. But any role may change its ownstatement_timeout, so the client could runSET statement_timeout = 0, and through the transaction pooler the next client then saw 0. Permissions don't stop it. The backstop has to live outside the session. One example is a scheduled job: apg_cronjob every 5 seconds that callspg_cancel_backendon r1's active queries older than 6 seconds. A client that had runSET statement_timeout = 0and started a 30-second query was cancelled after 10 to 11 seconds (6 s plus the job interval) withcanceling statement due to user request(18-guardrails.mjs cron). -
A monitoring role. With PostgreSQL 17's
reserved_connections = 3, a normal role was refused when full (remaining connection slots are reserved for roles with privileges of the "pg_use_reserved_connections" role) while a role grantedpg_use_reserved_connectionsconnected. If you use it, subtract it in equation 1. I haven't tried that on a hosted project (supabase_adminisn't mine to use); the hosted Nano value was 0.
14. Costs (docs, 2026-11-04)
- My experiments cost about $0.02 in total. Nearly all of it was local Docker ($0) plus a Free project for the tiny hosted checks; the throwaway Pro project (Micro) ran for roughly 1.5 hours at $0.01344 per hour, and I deleted it afterwards.
- Nano: free. Smallest paid compute is Micro, about $10 a month, with the same limits as Nano (60 direct, 200 pooler clients, pool 15). I verified that on the paid project:
max_connections60, and the settings page saying the client limit is "fixed at 200". Small about $15 (90 / 400), Medium about $60 (120 / 600), XL about $210 (240 / 1000). - Large is listed as about $110 on the Compute and disk page and about $111 on the Manage your usage / Compute page. Both quote $0.1517 per hour, so it is a rounding difference (0.1517 x 730 = $110.74). Cite whichever page you are reading.
- The Pro plan is $25 a month and includes $10 of compute credit per organization, which covers one Micro project. Upgrading the plan alone buys no connection headroom; a bigger compute size does.
- Compute is billed by the hour and is not covered by the Spend Cap (docs).
- Reaching the dedicated pooler needs IPv6. From an IPv4-only network you need the IPv4 add-on at $4 a month, which I did not buy — hence no dedicated-pooler measurements here.
15. What I would do next (untested)
- Budget from the equation: (roles x modes) x (pool + 1), plus Data API and other services, under max_connections minus reserved.
- Compare app copies x library pool with the per-role client cap. Serverless especially.
- Line up the waits: Supavisor 60 s, PostgREST 10 s,
anon3 s, Prisma 6 10 s, libraries 30 s, with your own timeouts. Prisma 7 with the pg adapter and node-postgres wait forever by default: setconnectionTimeoutMillis. - Per-role
connection limit(pool size + 1 or more),statement_timeout,idle_in_transaction_session_timeout; a scheduled job as the backstop a client can't undo;set_config(..., true)only; check each library's prepared-statement setting. - Make sure a monitoring role can log in when the database is full.
- On a throwaway project: the client-connection chart, the PostgREST pool setting, and whether the "+1" holds in other regions.
Takeaways
- Three equations: backends <=
max_connectionsminus reserved, clients <= the pooler cap, busy = qps x seconds per query. - Pools are per (role, database, mode), and transaction mode holds pool size + 1: multiply. The 200-client cap is also per role — measured on hosted: 200 on one role, 300 across two. But 300 idle clients held only 2 backends, so extra roles buy client slots and no backend slots.
- Equation 1 has to include Auth, Storage, Realtime, the health checker, and the dedicated pooler's backends if you run both. Measured on hosted Pro: Auth takes about 10 once used and releases them in about a minute; Storage takes 8 and keeps them.
- A full database stops whatever needs a new connection first. Auth sign-in went first in my lab because its Auth held nothing — hosted Auth keeps about ten warm, so don't assume the same order.
- Waits have limits (60 s pooler, 10 s PostgREST, 2 min for
postgres) and slow can look like "no errors": watch p95. The arithmetic predicted the 60 s failure. Prisma 7's pg adapter has no default wait limit. - Library defaults change with versions; run them and count.
- On transaction pooling, session state does worse than the documented "lost": it stays for the next client, turning a setting into a leaked row or privilege. Use
set_config(..., true). - The postgres.js hang is pipelining — now documented, along with mismatched rows.
max_pipeline: 1fixed it and did not breaksql.begin()in my tests; I never reproduced a mismatched row. - Backends lingered about 2 minutes in both the lab and hosted, but the docs say up to 5: budget for 5. Session mode did not close "immediately" either.
- A role's
connection limitmust be at least pool size + 1;statement_timeoutcan be undone by the client, so add a backstop outside the session. -
Micro has the same 60
max_connectionsas Nano — a paid plan alone buys no connections (verified on a paid project).
The repo has the lab scripts, the results table and the calculator: https://github.com/tomozilla/connection-budget-en-demo









Top comments (0)