Supabase & databases

“Max client connections reached”? Your functions are holding the seats.

Requests start failing under load. A restart clears it. Traffic brings it back. Meanwhile the database itself looks bored — low CPU, fast queries, nothing to see. That combination almost always means the seats in front of Postgres are all taken by serverless invocations that opened a connection and never gave it back. Here is how to prove it in one query, and the change that fixes it.

why it happens

The database isn’t busy. The waiting room is full.

Every Supabase project caps concurrent connections according to its compute size, with Supavisor pooling clients in front of Postgres. Serverless and edge functions break the assumption that pool was designed around: each invocation can open its own connection, and an instance that exits without closing leaves its client parked until the pooler refuses new ones.

This is why the symptom is so misleading. You go looking for a slow query and find none, because the problem was never load on the database — it is that nothing new can get to the database. A leak of idle connections and a genuine capacity problem produce the same error message and need opposite fixes, so the first job is telling them apart.

the checklist

Six ways a connection pool quietly fills up

01

Serverless code is on the session-mode port

Symptom: Errors scale with traffic, and the database's own CPU and query times look completely healthy.

Fix: Move the serverless connection string from port 5432 to the transaction-mode pooler on port 6543. This is the single biggest fix — a connection is then lent per transaction instead of per client.

02

A new client is constructed on every request

Symptom: Connection count climbs roughly in step with invocation count and never comes back down between bursts.

Fix: Create the client once at module scope and reuse it across invocations, and set connection_limit=1 in the URL so each warm instance can only ever hold one seat.

03

Cleanup is skipped on the error path

Symptom: The leak accelerates during an unrelated incident — whenever something else starts throwing, connections vanish faster.

Fix: Release clients in a finally block, not after the last happy-path statement. Early returns and thrown errors are exactly the paths that skip a release written at the end of a function.

04

Something is holding a transaction open

Symptom: pg_stat_activity shows connections parked in 'idle in transaction', sometimes for minutes.

Fix: Find the query that opened a transaction and never committed — usually a long report, a migration, or an interactive session someone left open — and set idle_in_transaction_session_timeout so the database reclaims the seat itself.

05

Admin tools share the same small pool

Symptom: The app breaks while someone is running a migration, a bulk import, or a dashboard query.

Fix: Point one-off and admin work at the direct connection rather than the pool your application traffic uses, so a human at a SQL console cannot take the seats your customers need.

06

The cap was raised instead of the leak fixed

Symptom: You upgraded compute, the errors stopped for a week, and now they are back at a higher traffic level.

Fix: Treat capacity as the last step. Fix pooling and client reuse, confirm the idle connection count stays flat under load, and only then decide whether real concurrency needs a bigger plan.

copyable runbook

Paste this into your incident notes

Supabase connection-limit triage

[ ] Prove where the seats went, before changing anything:
    select application_name, state, count(*)
      from pg_stat_activity
     where datname = current_database()
     group by 1, 2
     order by 3 desc;
    -- mostly 'idle' from one application_name  -> a leak
    -- mostly 'active' across many names        -> genuine capacity

[ ] Find transactions left open:
    select pid, state, now() - state_change as held, query
      from pg_stat_activity
     where state = 'idle in transaction'
     order by held desc;

[ ] Move serverless/edge code to the transaction pooler (port 6543),
    not the session-mode direct connection (port 5432).
[ ] Add connection_limit=1 to each serverless client's URL.
[ ] Reuse a module-scoped client; do not construct one per request.
[ ] Release clients in a finally block so error paths cannot skip it.
[ ] Set idle_in_transaction_session_timeout so the DB reclaims stragglers.
[ ] Point migrations and admin queries at a different connection.
[ ] Re-check pg_stat_activity under load: idle count should stay flat.
[ ] Only then consider a larger compute size.
how to catch it automatically

Watch a route that actually touches the database

An exhausted pool is invisible to a homepage check. Static pages and cached routes keep answering perfectly while every database-backed request fails, which is why this failure so often reaches customers first. Nightlamp runs an api_canary against an endpoint that really queries Postgres and asserts the response contains what it should, so an intermittent 5xx fails the check instead of passing as a suspiciously empty success.

Two more checks close the gap. An http_status monitor tracks the error rate and latency trend on the public surface, so you see the climb before the pool is completely full. And a heartbeat on each background worker catches jobs that die partway through because they could not get a connection — the failure mode that leaves half-written records behind and no error anywhere a human would look.

Database-backed endpoint
Intermittent 5xx rate
Response latency trend
Worker heartbeats
Empty-but-200 responses
Recovery after restart

Want to know before your customers do?

Start a trial and point one check at a route that reads from your database. Nightlamp runs it on a schedule, asserts the response actually contains data, and tells you which endpoint started failing and when — instead of you finding out from a support email after the pool has been full for an hour.

Start 14-day trial · no card

Newsletter

One real no-code incident + the fix, monthly

A post-mortem from a real no-code outage — what broke, how it was found, and the fix — once a month. Confirming also gets you the no-code incident runbook pack.

Double opt-in. One-click unsubscribe. No spam, ever.