# Postgres connection pooling after sandbox exit so concurrent AI traffic cannot melt the DB

> After leaving Lovable/Bolt, size Postgres pools with checkout timeouts and idle caps, then prove saturation with a canary so agents cannot melt the DB.
> By Dave · 2026-09-21
> Source: https://otf-kit.dev/blog/postgres-connection-pooling-production

After you leave a Lovable or Bolt sandbox and own the backend, every agent turn, tool call, and concurrent request can open a fresh Postgres session. Sandbox hosts often hide that cost behind a shared pool you never sized. On infrastructure you control, unbounded clients hit `max_connections` and the database stops accepting work. Connection pooling is the practice that keeps concurrent AI and human traffic from melting Postgres — not another generic “how Postgres works” tutorial.

This post covers pool sizing, checkout timeouts, idle and max caps, PgBouncer-or-equivalent patterns, and a saturation canary you can prove. Pair it with [structured production logs](/blog/production-structured-logging-for-agents) so pool wait errors are triageable, and with [API timeouts and retries](/blog/api-timeouts-retries-ai-backends) so outbound AI calls do not hold checkouts forever. It is not [audit trail events](/blog/audit-trail-events-saas-ops), not [timed restore drills](/blog/db-backup-restore-drill-production), and not a kit how-to. The claim is narrow: size the pool against real concurrency, fail checkout loudly, and prove saturation with a canary before traffic spikes.

## Why sandbox apps melt Postgres after export

Sandbox platforms multiplex many apps onto managed databases. Export the repo and `$DATABASE_URL` suddenly points at a instance whose `max_connections` you own. PostgreSQL documents that parameter as the hard ceiling on concurrent sessions, with resource cost that grows as you raise it ([Connections and Authentication](https://www.postgresql.org/docs/current/runtime-config-connection.html)). Each idle session still holds memory; each new TCP handshake and auth round-trip costs CPU. Agents that fan out tool calls amplify the pattern: N workers × M in-flight queries looks fine in a sandbox and fatal on a small owned instance.

The failure mode is familiar: `too many connections`, checkout queues that never drain, or single-request latency that jumps when the pool is exhausted. Fixing it after an incident means guessing pool size under pressure. Fixing it before means treating the pool as a product surface with budgets and a canary.

![Direct DB connections per request vs pooled checkout under load](https://cdn.otf-kit.dev/blog/postgres-connection-pooling-production/inbody1-20260921a.png)

## Pool where the sessions actually live

Two layers matter, and they are not interchangeable.

**Application pool.** Your process keeps a fixed set of backend sessions and hands them out for queries. Cap with `$POOL_MAX`. Return idle sessions after `$POOL_IDLE_TIMEOUT_MS`. Fail checkout after a short wait instead of blocking forever. This layer alone is enough for a single long-lived API process with moderate concurrency.

**External pooler.** PgBouncer (or a managed equivalent such as Amazon RDS Proxy) sits between many app clients and fewer Postgres sessions. PgBouncer’s documented modes are session, transaction, and statement pooling: session holds a server connection for the client lifetime; transaction returns the server connection when the transaction ends; statement returns after each query and disallows multi-statement transactions ([PgBouncer features](https://www.pgbouncer.org/features.html)). Transaction mode is the usual fit for request-scoped AI backends that avoid session features like `LISTEN`, session advisory locks, or lasting `SET` state.

Amazon RDS Proxy similarly pools and multiplexes client connections so the database sees fewer sessions, and it can queue or reject surplus clients instead of letting them overwhelm the engine ([Amazon RDS Proxy](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html)). Point `$DATABASE_URL` at the pooler endpoint when you adopt one; keep the app-side `$POOL_MAX` honest so you do not open thousands of client sockets into the pooler by accident.

## Size against concurrency, not folklore

Write four numbers before you tune:

1. **Postgres `max_connections`** — hard ceiling on the instance (document it next to `$DATABASE_URL`).
2. **Server-side pool budget** — how many sessions the pooler (or app pool) may open toward Postgres. PgBouncer’s `default_pool_size` is the per user/database server-connection cap; defaults start around 20 and should stay well below `max_connections` after reserving headroom for admin and migrations ([PgBouncer config](https://www.pgbouncer.org/config.html)).
3. **Client-side `$POOL_MAX`** — max checkouts per app process. Sum across replicas must not exceed the server-side budget.
4. **Checkout timeout** — max wait for a free connection before the request fails. Prefer a loud, short failure over an unbounded queue.

Idle policy matters as much as max. `$POOL_IDLE_TIMEOUT_MS` should release unused sessions so overnight quiet periods do not leave a forest of idle backends. On the pooler, `server_idle_timeout` and related settings close idle server connections; on managed proxies, max idle percent controls how aggressively idle database connections are returned ([RDS Proxy connection considerations](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-connections.html)).

A practical starting budget for a small owned API with a few agent workers: server pool well under half of `max_connections`, `$POOL_MAX` sized so `replicas × POOL_MAX` fits that budget, checkout timeout on the order of a few seconds, idle timeout short enough that a quiet hour shrinks the live session count. Raise only when the canary says you are saturating under intentional load — not because a blog said “set it to 100.”

## Checkout timeouts beat silent queues

When every connection is busy, something has to give. Infinite wait turns a brief spike into a pile of stuck agent turns. A checkout timeout returns a controlled error your structured logs can capture with a correlation id. That error should be distinct from query failures so on-call and agents know the pool — not SQL — is the bottleneck.

Hold checkouts only for the query or transaction you need. Do not checkout, then call an external AI model, then run more SQL on the same connection. That pattern couples pool pressure to model latency. Checkout → query → release → call the model → checkout again if you need another write. Pair this with the outbound timeout discipline in [API timeouts and retries](/blog/api-timeouts-retries-ai-backends).

For PgBouncer transaction mode, avoid session-scoped features that break reuse: session `SET`, `LISTEN`, session advisory locks, and SQL-level `PREPARE` without the pooler’s prepared-statement support ([PgBouncer features](https://www.pgbouncer.org/features.html)). Prefer request-local state in the app.

## Prove saturation with a canary

A pool you never load-tested is a hope. Run a saturation canary against staging (or a dedicated canary environment) that:

1. Opens N concurrent clients using the same `$DATABASE_URL` path as production (pooler included).
2. Holds each checkout for a fixed short query budget, then releases.
3. Raises N until checkout timeouts or pooler queue metrics appear.
4. Records the N where healthy latency breaks — that is your proven ceiling, not a guess.

Keep the canary query cheap and idempotent (a `SELECT 1` or a primary-key read). Tag canary traffic in logs so it never confuses real incident triage. Re-run after you change `$POOL_MAX`, replica count, or pooler `default_pool_size`. Store the last-pass N next to the pool budget in your runbook.

![App clients to pooler to Postgres with saturation canary](https://cdn.otf-kit.dev/blog/postgres-connection-pooling-production/inbody2-20260921a.png)

## Minimal config surface you can own

Keep the movable parts in env, not folklore comments:

```bash
# Point at Postgres or at the pooler endpoint — same variable either way
export DATABASE_URL="$DATABASE_URL"
export POOL_MAX="${POOL_MAX:-10}"
export POOL_IDLE_TIMEOUT_MS="${POOL_IDLE_TIMEOUT_MS:-30000}"
export POOL_CHECKOUT_TIMEOUT_MS="${POOL_CHECKOUT_TIMEOUT_MS:-3000}"
```

```text
# App pool contract (pseudocode)
pool = open_pool(
  url = $DATABASE_URL,
  max = $POOL_MAX,
  idle_timeout_ms = $POOL_IDLE_TIMEOUT_MS,
  checkout_timeout_ms = $POOL_CHECKOUT_TIMEOUT_MS
)

with pool.checkout() as conn:
  run_query(conn, sql)
# release happens on exit — never hold across model calls
```

On the pooler side, document `pool_mode` (usually transaction), `default_pool_size`, `max_client_conn`, and `server_idle_timeout` next to the app envs. When you use RDS Proxy, document max connections percent, max idle connections percent, and connection borrow timeout the same way ([RDS Proxy connection considerations](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-connections.html)). Migrations and break-glass admin sessions should use a separate path that does not compete with the app pool budget.

## Acceptance checks before you call it done

Ship the pooling change only when all of these pass:

1. Sum of app `$POOL_MAX` across replicas is below the pooler’s server budget and well below Postgres `max_connections`.
2. Checkout timeout is finite and logged as a distinct error class.
3. Idle timeout shrinks live sessions after a quiet period (observe with pooler stats or `pg_stat_activity` counts).
4. Saturation canary records an N where checkout failures begin; that N is written into the runbook.
5. A deliberate canary run does not take down production — staging or isolated target only.
6. Structured logs show pool wait / checkout timeout fields agents can filter ([structured production logs](/blog/production-structured-logging-for-agents)).

If any check fails, lower `$POOL_MAX` or raise pooler capacity deliberately — do not open direct per-request connections “just for agents.”

## What this is not

This is not a primer on SQL, indexes, or ORMs. It is not Expo or editor padding. It is not a commercial kit walkthrough. Backup and restore discipline lives in [timed restore drills](/blog/db-backup-restore-drill-production); who-did-what attribution lives in [audit trail events](/blog/audit-trail-events-saas-ops). Pooling is the concurrency budget between clients and Postgres once you own `$DATABASE_URL`.

When you want production-shaped backends without rediscovering pool math on every export from a sandbox, start from the kits at [https://otf-kit.dev/templates](https://otf-kit.dev/templates) and keep the pool contract above as a non-negotiable ops surface.

## Sources

- [PostgreSQL: Connections and Authentication (`max_connections`)](https://www.postgresql.org/docs/current/runtime-config-connection.html)
- [PgBouncer features (session / transaction / statement pooling)](https://www.pgbouncer.org/features.html)
- [PgBouncer configuration (`default_pool_size`, timeouts, pool_mode)](https://www.pgbouncer.org/config.html)
- [Amazon RDS Proxy overview](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html)
- [Amazon RDS Proxy connection considerations](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-connections.html)