Application Development

Supabase Connection Pool Exhausted Serverless: Why and How to Fix

J
James Eriksson
··14 min read
Why serverless functions leak Postgres connections and how to fix it. Transaction Mode vs Session Mode, ORM config, and when to move to persistent hosting.
TL;DR
  • Serverless functions suspend when idle, causing database connections to leak until timeout (5-15 minutes), exhausting Supabase's connection pool at scale
  • Use Transaction Mode on port 6543 with prepared statements disabled in your ORM; Transaction Mode releases connections after each query while Session Mode holds them for entire sessions
  • Temporary fixes like lower idle timeouts and capped pool sizes buy time but don't solve the structural incompatibility between serverless and persistent connections
  • Moving to persistent compute eliminates connection exhaustion entirely because a long-running process never suspends and reuses the same connection pool continuously

Supabase connection pool exhaustion is the silent killer of serverless applications. When database connections run out, your API endpoints start failing with cryptic timeout errors. This happens on Vercel, Netlify, and AWS Lambda because serverless functions suspend and resume unpredictably, leaking connections to Postgres. Understanding why this happens--and when to move away from serverless--is critical before it burns your production.

What is Supabase Connection Pool Exhaustion?

A Postgres database has a finite number of connections. Supabase's shared tier typically allows 20 connections; professional tiers allow more. When all connections are in use, new requests wait or timeout. Serverless makes this worse because idle functions hold connections open that don't get released until the database times them out--potentially 5 to 15 minutes later.

A Postgres database has a hard limit on concurrent connections. When a serverless function connects to your Postgres database, it opens a connection. Ideally, that connection closes when the function finishes. But here's the leak: when the function completes, the container may stay warm (alive but idle). Your connection pooler or ORM holds that connection open, waiting for the next request. The function suspends. The connection is not explicitly closed. The database doesn't know the connection is abandoned.

It waits for idle timeout--typically 5 to 15 minutes on Supabase--before reclaiming it. Meanwhile, traffic keeps coming. Every invocation opens a new connection. If you get 100 concurrent requests and each suspends for 5 minutes, you'll accumulate 100 idle connections. Your pool of 20 connections is exhausted after just 20 requests finish.

This is fundamentally different from a persistent server. On a traditional server, a process starts once, initializes a pool of (say) 5 connections at boot, and reuses those same connections for every request for hours. Connections never leak because the process never suspends. Connection pooling is designed for that model. Serverless breaks the assumption. Your container wakes, runs a handler, idles. The connection pooler has no way to know: will another request arrive in 100ms, or will this container freeze for 10 minutes? So it keeps the connection open. And the database times it out slowly.

Why Do Serverless Functions Leak Database Connections?

Serverless functions suspend when idle. The idle timer doesn't fire during suspension; connections are only released when the database times them out (5 to 15 minutes later) or the container is destroyed (variable timing). At scale, connections leak faster than they're recycled, exhausting the pool.

When you deploy a function to Vercel, Netlify, or AWS Lambda, the platform creates a container. Your code runs. Your code makes a database call. A connection is opened. Your handler finishes. The platform freezes the container to save memory and cost. Here's the critical issue: the connection is not closed. Your ORM doesn't know the container is suspended. It only cares if there's an error or if your code explicitly calls .close(). Neither happens.

The database sees an open connection that is not sending queries. Most database platforms--including Postgres--implement an idle timeout. If a connection sits idle for 5, 10, or 15 minutes without activity, the database closes it. This is a safety mechanism. But 5 to 15 minutes is a long time at scale. If you get a spike of 50 concurrent requests, each lasting 1 second but each leaving a connection open for 5 minutes, you'll have 50 idle connections held for those 5 minutes. Your pool is exhausted.

The platform may destroy the container after 15 to 30 minutes, which finally closes the connection. But by then, newer requests have hit the pool limit and started failing. Why doesn't this happen with persistent servers? Because persistent servers do not suspend. A Node.js app running on DigitalOcean or Heroku runs continuously. Its connection pool is initialized once at startup. The same 5 connections are reused for every request for days. There is no leak because there is no suspension.

Transaction Mode vs. Session Mode: Which Should You Use?

Transaction Mode releases a connection after each query completes, making it suitable for serverless. Session Mode holds a connection for the entire user session, which serverless cannot support. For serverless, always use Transaction Mode on Supabase's pooler port (6543).

Supabase offers two pooling modes via Supavisor (its built-in pgbouncer): Transaction Mode and Session Mode. Session Mode keeps a connection open for the entire user session, reusing it across multiple queries. This works fine for a traditional web server: a client connects, runs several queries within a transaction, then disconnects. The pooler holds that connection for the entire time. Serverless breaks this model. A serverless function invocation is short-lived (typically 1 to 30 seconds). You do not want to hold a connection open "for the session" because the session is over when the handler returns. Holding the connection wastes your pool budget.

Transaction Mode is different. The pooler holds a connection only for the duration of a single query or transaction. Once the query is done, the pooler immediately closes the connection and returns it to the pool. On the next query, a different connection is grabbed from the pool. This is perfect for serverless. Each request is a transaction. Each transaction releases the connection. The pooler multiplexes many requests across a small pool of server-side connections.

The tradeoff: Transaction Mode breaks prepared statements. When the pooler switches connections, named prepared statements (which are connection-specific) become invalid. You'll get errors like "prepared statement already exists" or random crashes. This is why Prisma, Drizzle, and other ORMs have settings to disable prepared statements when using pgbouncer. For serverless, prepared statements are a luxury you cannot afford. Use Transaction Mode and disable prepared statements in your ORM.

Configuring Supabase Pooling for Your ORM

Use Supabase's pooler endpoint on port 6543 (the *.pooler.supabase.co host) instead of the direct Postgres port 5432. Disable prepared statements in your ORM. Environment variables should point to the pooler, not the direct database host.

Supabase provides two connection strings in your project settings. One is the direct Postgres connection (port 5432). The other is the pooler connection (port 6543). For serverless, always use port 6543. In your Supabase dashboard, go to Database settings and copy the "Connection string" under "Connection pooling." This uses port 6543 and the *.pooler.supabase.co host. Set this as your DATABASE_URL environment variable in your deployment platform (Vercel, Netlify, etc.).

Do not use the direct connection string. It bypasses the pooler and connects you straight to Postgres. You will exhaust your connection limit immediately in production. The second critical step: disable prepared statements in your ORM. Transaction Mode (which the pooler uses) does not support named prepared statements. When the pooler switches to a different backend connection for the next query, the prepared statement is lost. You'll get errors or crashes.

Most ORMs have a flag for this. Prisma has pgbouncer=true; Drizzle has prepare: false in the client config; the raw pg package requires statement_cache_size: 0. This is a tradeoff: prepared statements are faster because the database doesn't parse the query twice. But in serverless with Transaction Mode, the performance gain is lost because your connection is recycled after each query anyway. Disable them.

How to Diagnose Connection Exhaustion Before It Hits Production

Query SELECT count(*) FROM pg_stat_activity; to see current connections. Check the Supabase dashboard Reports Database for "idle in transaction" connections. Red flags: connection count climbing without dropping, error logs mentioning "too many connections," query timeouts that suddenly appear.

Connection exhaustion is often silent until it explodes. Your app works fine in development and early production, then suddenly times out after traffic spikes. You need monitoring in place before that happens. The easiest check: run a SQL query against your database to see how many connections are open.

In your Supabase dashboard SQL editor, run: SELECT count(*) FROM pg_stat_activity; If this number climbs and stays high (close to your tier's limit), you have a leak. Check the state of each connection: SELECT usename, state, count(*) FROM pg_stat_activity GROUP BY usename, state; Connections in "idle" state are not actively running a query. If you see many idle connections, your functions are suspending without closing connections.

Supabase also provides a dashboard. Go to Reports Database and look for connection graphs. You should see connection count spike during traffic and drop back down as requests complete. If connections climb and stay elevated, you have a leak. In your application logs, watch for these errors: "connection pool size exceeded," "too many connections," or "FATAL: remaining connection slots are reserved." These are all symptoms of pool exhaustion. Another check: add monitoring to log your connection pool status. Prisma exposes pool metrics; Drizzle and pg package expose pool directly so you can log total and idle counts.

Temporary Fixes--And Why They Do Not Scale

Lower idle timeouts to 30 to 60 seconds to release connections faster. Cap your pool size to 1 per function instance (the pooler handles multiplexing). Use Supabase's Data API for read-heavy workloads to avoid TCP connections entirely. These buy time but do not fix the structural problem.

If you need a quick fix to get your app working again, here are the temporary patches. Lower idle connection timeout: Supabase defaults to 5 to 15 minutes. You can lower this in your database settings. Set it to 60 or 30 seconds. Connections will be released faster, so you'll have fewer idle connections held. The downside: this puts more pressure on the pooler. Connections are recycled constantly. And this doesn't fix the root cause--it just makes the symptom less severe.

Cap pool size per function instance: Many ORMs let you set max pool size. Set it to 1. The Supabase pooler (Supavisor) is the real multiplexer; your function doesn't need its own local pool. Each function instance handles one request at a time anyway. Use the Supabase Data API for reads: Supabase offers a REST API (PostgREST) that doesn't require a persistent database connection. Each HTTP request is stateless. If you can move read-heavy workloads to the Data API, you reduce connection pressure. Example: instead of SELECT * FROM posts via Postgres, call the REST API endpoint. No connection is held after the request. This is a partial solution. Writes and complex transactions still need Postgres. But for analytics dashboards, list endpoints, and reporting, the Data API can significantly reduce connections.

Why these don't scale: these patches work until they don't. As your traffic grows, connections still leak and pool exhaustion will return. None of these fixes address the root cause: serverless plus persistent connections is a mismatch. The patches slow the inevitable.

The Structural Problem: Why Serverless and Connection Pooling Don't Mix

Serverless containers suspend when idle. Connections are only released on database timeout (minutes) or container destruction (variable). At scale, this creates an arithmetic problem: connections leak faster than they are recycled. Poolers patch this but cannot eliminate the leak. Persistent compute has no leak because there is no suspension.

Here is the honest truth: serverless and connection pooling are a structural mismatch. A connection pooler (like pgbouncer or Supavisor) multiplexes many client connections across fewer server connections. It is designed for stable, long-lived client connections. Think: dozens of browsers staying connected to a chat app for 30 minutes, all sharing 5 database connections. Serverless inverts this. You have thousands of short-lived clients (function invocations) that connect briefly then vanish. But they don't actually vanish--they suspend. The connection stays open but idles. The pooler has no way to know the client is gone.

At small scale (tens of requests per minute), pooling works. Timeouts happen quickly enough. At large scale (hundreds of concurrent requests), the math breaks. You accumulate idle connections faster than they time out. Pool exhaustion is inevitable. Vercel Fluid Compute and similar "always-on" serverless offerings try to reduce the suspension frequency, but they still suspend. Pooling still leaks. The leak is slower, but it's there.

Contrast this with a persistent server. A Node.js app on a traditional VPS initializes once. It opens 5 connections to Postgres and reuses them for hours. New requests grab a connection, run a query, release it. The same connections are recycled thousands of times. No leak. No timeout. No exhaustion. Why? No suspension. The process runs continuously. Connections are held by a process that's guaranteed to be alive, so the database doesn't need to time them out. This is not a bug in Supabase. It's a fundamental incompatibility between the serverless execution model and persistent database connections.

Moving to Persistent Compute: The Real Solution

Move to persistent hosting with guaranteed uptime. A single long-running server process holds a stable connection pool to Postgres, eliminating the leak entirely. Costs are predictable (flat monthly fee) rather than variable (per invocation). For most applications, persistent compute is cheaper than serverless once you account for the operational overhead of pooling.

The real fix is not a pooler tweak. It's a change in architecture. If you run your app on a persistent server--whether managed PaaS or self-hosted--you eliminate the suspension problem entirely. Your process runs continuously. It opens a connection pool once at startup. The same connections are reused for thousands of requests. No leak. A simple persistent app might use a small instance: 1 vCPU, 1 GB RAM. This costs $5 to $20 per month depending on the provider. It runs 24/7. Your connection pool is stable. Database exhaustion becomes a non-problem.

Serverless proponents will say "but you pay for always-on even when there are no requests." That is true. On a quiet night, you're paying for capacity you're not using. But consider: the cost difference is small. A $10/month persistent instance versus variable serverless costs. If you have even moderate traffic, the math favors persistent. The operational simplicity is huge. No connection pooling headaches. No debugging "why did my pool exhaust at 2am." The predictability is underrated. You know exactly what your compute bill is. Serverless invites surprise bills when traffic spikes.

For a bootstrapped founder or small team, persistent hosting removes an entire class of bugs. If you want the convenience of managed hosting, Ship offers managed persistent compute with built-in database integration. If you want to minimize hosting cost, Hetzner and Coolify are cheaper on raw compute but require infrastructure management. For most small teams, the decision is: do I want to optimize for cost or for operations simplicity? Either way, moving off serverless solves the connection pooling problem at the root.

Frequently Asked Questions

Should I disable prepared statements if I'm using Session Mode?

No. Session Mode keeps a connection open for the entire session, so prepared statements are reused safely. Disable prepared statements only if you use Transaction Mode.

Can I use Supabase serverless functions with the pooler?

Yes. Supabase edge functions can connect to the pooler on port 6543 the same way any other serverless function can. But they face the same connection leak problem as Vercel Functions or AWS Lambda.

What connection pool size should I use per function instance?

Set your application's max pool size to 1. The Supabase pooler (Supavisor) is the real multiplexer. Your app doesn't need to pool further.

Should I use the Supabase Data API instead of Postgres for all queries?

No. The Data API is good for simple CRUD on single tables. Complex queries, transactions, and writes are better via Postgres. Use the Data API as a complement, not a replacement.

Can I use AWS Lambda or Netlify Functions, or is this a Vercel-only problem?

AWS Lambda, Netlify Functions, and Cloudflare Workers all suspend functions when idle. All face the same connection leak problem. Transaction Mode pooling helps on all platforms.

Do I have to migrate my entire app to persistent compute?

You can use serverless for your app and connect to a persistent database on a managed platform. But this doesn't solve the connection leak--you still have the same number of serverless functions spawning connections. What helps is moving the app to persistent compute so connections are reused.

The Bottom Line

Supabase connection pool exhaustion on serverless is not a configuration problem you can solve forever. It's a structural mismatch between serverless's suspension model and persistent database connections. Pooling (Transaction Mode on port 6543, disabled prepared statements) is necessary and helps, but connections still leak at scale. Temporary fixes buy time but don't fix the root cause.

The real solution is persistent compute. Moving to a long-running process eliminates the suspension leak entirely. Your app will no longer fight database connection limits and your billing becomes predictable. Ready to explore persistent hosting? Ship offers managed, always-on compute with built-in database integration, letting you focus on code instead of pool tuning.

Skip the pooling headaches
Ship manages persistent infrastructure so connection exhaustion never happens. No more guessing on idle timeouts or transaction modes.
Get Started Free

Ready to self-host your own apps?

One server. Multiple apps. No per-app fees.

Get started →