DEV Community

Cover image for Supabase Connection Budgets: Pool Size, Roles, Modes, and Limits
Tomo
Tomo

Posted on AI-assisted

Supabase Connection Budgets: Pool Size, Roles, Modes, and Limits

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.

Copies of your app reach the database through the shared pooler (transaction or session mode), the dedicated pooler, or a direct connection; the poolers keep one pool per role and mode; the Data API, Auth, Realtime, Storage and monitoring each hold their own 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_connections 90, 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

Three equations: backends in use must stay under max_connections minus reserved slots; app copies times library pool size must stay under the pooler's client cap; queries per second times seconds per query gives busy backends.

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[db.settings]
max_connections = 90

[db.pooler]
enabled = true
pool_mode = "transaction"
default_pool_size = 15
max_client_conn = 200
Enter fullscreen mode Exit fullscreen mode

Three things tripped me:

  • max_connections belongs under [db.settings], not [db]. My first try under [db] did nothing; I then used alter system, and that value, stored in postgresql.auto.conf, overrode [db.settings] later and hid it. alter system reset max_connections and a restart made 90 show up (check source in pg_settings).
  • Sizing: the local pooler keeps 29 metadata connections of its own inside the database (supavisor_meta 27 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_connections 60, superuser_reserved_connections 3, 4 connections in use (authenticator 1, which is PostgREST's LISTEN; pgbouncer 1; supabase_admin 2) = 53 free of 57. Hosted, Pro / Micro (brand new project, nobody using it, 2026-11-04): max_connections 60, superuser_reserved_connections 3, reserved_connections 0, 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_connections as 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 and application_name.
select usename, state, count(*) from pg_stat_activity
where backend_type = 'client backend' and pid <> pg_backend_pid() group by 1, 2;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

A bar of 57 usable connections: other services 5, Data API 11, role A 16, role B 16 fit at 48; role C's 16 crosses the line.

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:

When the database is full: new direct connections fail with 53300, new pooler logins fail with EAUTHQUERY, Auth sign-in returns 500; the Data API and Storage keep working while their open connections are enough.

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)
Enter fullscreen mode Exit fullscreen mode

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:

A ladder of waits: anon statement_timeout 3 s, authenticated 8 s, PostgREST pool wait 10 s, Prisma 6 pool_timeout 10 s, 30 s for psycopg_pool, SQLAlchemy and postgres.js connect, Supavisor checkout 60 s, pg Pool no limit.

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
Enter fullscreen mode Exit fullscreen mode
  • Peak authenticator backends: 11 = a pool of 10 plus one LISTEN "pgrst" connection (I read it from pg_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.
  • anon has statement_timeout = 3s (authenticated: 8 s). A 4-second call returned 500 with 57014.

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.js pipelines 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
Enter fullscreen mode Exit fullscreen mode

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.js has no working option to turn pipelining off directly: max_pipeline: 0 breaks sql.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 set and reset, session-level advisory locks, listen and notify, 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):

Request A sets app.user to alice at session level; the pooled connection is reused; request B forgot to set the user and sees Alice's row. Over a direct connection it sees nothing. A transaction-local setting does not stay.

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
Enter fullscreen mode Exit fullscreen mode

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:

Bars of p50 latency: 104 ms at 5 clients, 105 ms at 16, 218 ms at 40, 620 ms at 100; throughput stays near 150 per second.

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, no error handler: 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.

Dashboard: 41 of 60 connections, 0 active queries (hosted run, 2026-11-02).

A line chart: 16 backends stay idle for about 105 seconds after the clients close, then drop to zero.

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 with too 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 own statement_timeout, so the client could run SET 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: a pg_cron job every 5 seconds that calls pg_cancel_backend on r1's active queries older than 6 seconds. A client that had run SET statement_timeout = 0 and started a 30-second query was cancelled after 10 to 11 seconds (6 s plus the job interval) with canceling 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 granted pg_use_reserved_connections connected. If you use it, subtract it in equation 1. I haven't tried that on a hosted project (supabase_admin isn'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_connections 60, 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, anon 3 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: set connectionTimeoutMillis.
  • 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_connections minus 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: 1 fixed it and did not break sql.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 limit must be at least pool size + 1; statement_timeout can be undone by the client, so add a backstop outside the session.
  • Micro has the same 60 max_connections as 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)