Dashboard

AI-Built App Database Connection Limit: Fix It

Hitting an AI-built app database connection limit? Here is why serverless code exhausts Postgres connections and the pooler fix that stops it.

Steve Jefferson
Steve Jefferson
Developer Advocate
9 October 20261 min read

An AI-built app database connection limit error almost never means your app is popular. It means your code opens a new database client on every request and never closes it. Postgres holds those sockets open until they time out, the pool fills, and the next visitor gets remaining connection slots are reserved or sorry, too many clients already. The traffic that triggers it is often under fifty people.

This is one of the most reliably reproducible failures in AI-generated backends, and it has a specific cause.

Why an AI-built app hits the database connection limit so fast

Ask a model for an API route that reads from Postgres and it will give you a self-contained, correct-looking handler. Self-contained is the problem. Each file creates its own client at the top, or worse, inside the function body:

javascript
// What the model gives you, repeated in every route file
export async function GET(request) {
  const client = new Client({ connectionString: process.env.DATABASE_URL })
  await client.connect()
  const { rows } = await client.query('select * from orders limit 20')
  return Response.json(rows)
}

Nothing here is wrong in isolation. The handler works, the tests pass, and a code review sees a tidy function. But on a serverless platform each invocation runs in its own short-lived instance, so each one opens its own connection. Twenty concurrent requests across four routes is eighty sockets. There is no shared pool to join because every instance thinks it is the only one running.

A long-running server hides this. One Node process with a module-level pool reuses connections across thousands of requests. Move the same code to serverless functions or edge runtimes and the pool becomes meaningless, because the thing that was being pooled is now created fresh every time.

Confirm it before you change anything

Run this against your database while the app is under load. It separates connections doing work from connections sitting idle, which is the distinction that matters:

sql
select state, count(*), max(now() - state_change) as oldest
from pg_stat_activity
where datname = current_database()
group by state
order by count desc;

What you see

What it means

Many rows in idle, oldest measured in minutes

Connections opened and abandoned, the classic serverless leak

Many rows in idle in transaction

A query path that opens a transaction and never commits or rolls back

Many active, all slow

A genuine query performance problem, not a connection problem

Total near your plan's cap

You are at the ceiling regardless of cause

If the idle count is high and climbing while actual traffic is flat, you have a leak, not a scale problem. Upgrading your database tier will buy you a few hours and then fail the same way.

The fix: connect through a transaction pooler

The answer is not to open fewer connections. It is to open them against something built to multiplex them. Supabase exposes three connection paths, and the difference is a port number. Per the Supabase connection documentation, a direct connection on port 5432 goes straight to Postgres, the session pooler also uses 5432 and keeps session state, and the transaction pooler uses port 6543 and hands the connection back to the pool after every transaction.

For serverless and edge functions, the transaction pooler is the one you want, precisely because those environments open many short-lived connections.

Two things break if you switch the port and change nothing else. Transaction mode does not support prepared statements, and most drivers enable them by default:

javascript
// Postgres.js and Drizzle
const sql = postgres(process.env.DATABASE_URL, { prepare: false })

For Prisma, append ?pgbouncer=true to the connection string instead. Session-level state also stops persisting across transactions, so set, listen, notify and temporary tables will not survive. If you are using any of those, you need the session pooler, not the transaction pooler.

Stop creating clients per file

The pooler fixes the symptom. The code pattern is still worth correcting, because it will bite again the moment you run anything long-lived. Create the client once in a shared module and import it:

javascript
// lib/db.js
import postgres from 'postgres'
export const sql = postgres(process.env.DATABASE_URL, { prepare: false, max: 5 })

Then every route imports sql from that one file. The max: 5 matters. On serverless, each instance should hold a tiny pool, because you may have dozens of instances. A max: 20 setting copied from a tutorial written for a single long-running server is how you exhaust a pooler that was supposed to save you.

When you ask an AI agent to make this change, give it the constraint explicitly rather than describing the goal. "Move the Postgres client into lib/db.js, export a single instance, and replace every per-file client with an import from it" produces the right diff. "Fix the connection errors" produces a retry wrapper around the same leak.

What to check after the change

Redeploy, put the app under the same load that broke it, and run the pg_stat_activity query again. Idle connections should now sit flat rather than climbing. If they still climb, something is holding a transaction open. Look for a code path that begins a transaction inside a conditional and only commits on the happy path.

Connection exhaustion is one of several limits that arrive together once an app leaves the demo stage. The order they arrive in is covered in what breaks first when an AI-built app gets real traffic, and the hour-by-hour version under a genuine traffic spike is in the anatomy of an app going viral. If you are still choosing where your data lives, picking a database for an AI-built app covers the pooling story per option, and the broader build sequence sits in the guide to building an app with AI.

FAQ

Why does my app work locally but hit connection limits in production?

Locally you run one process with one pool, and the same handler reuses a single connection for every request. In production on serverless, each request can land in a fresh instance that opens its own connection. The code is identical. The runtime is not.

Will upgrading my database plan fix too many clients already?

Only temporarily, and only if the cause is genuine concurrency. If connections are leaking, a higher cap means the failure arrives later with a bigger bill attached. Check whether your idle connection count climbs while traffic is flat before you pay for more.

What is the difference between port 5432 and 6543 on Supabase?

Port 5432 is a direct connection or the session pooler, which keeps session state and supports prepared statements. Port 6543 is the transaction pooler, which returns the connection after each transaction and suits serverless. Transaction mode requires you to disable prepared statements in your driver.

How many connections should max be set to in a serverless app?

Low, usually between 1 and 5 per instance. The total you care about is your pool size multiplied by the number of concurrent instances, not the number on any one of them.

Can I just add a retry when the connection fails?

A retry turns a fast error into a slow one and adds load to a database that is already refusing connections. Fix the pooling first. Retries are reasonable afterwards for genuine transient failures.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.