Timed Out Fetching a New Connection From the Pool: Why Raising the Limit Makes It Worse
Databases · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: Your app throws pool timeout errors under load while Postgres sits at 20% CPU. Learn why the queue is in your app, not the database, and how to fix it.
The Problem
At 200 requests/second your API starts returning 500s and the logs fill with Timed out fetching a new connection from the connection pool (pool_timeout=10, connection_limit=10). Meanwhile Postgres is bored: 12% CPU, 40 of 200 max_connections in use, and the slowest query in pg_stat_statements is 6ms.
So you have a queue somewhere that Postgres knows nothing about. That message does not come from the database — it comes from your driver's client-side pool, and it means: ten seconds passed and not one of my 10 connections came back. With 6ms queries, ten connections should serve ~1,600 queries per second. Something is holding connections while doing something that isn't a query.
Why the Obvious Fix Falls Short
The error text names connection_limit, so the reflex is to raise it. connection_limit=10 becomes 50, and pool_timeout goes from 10s to 30s. It works in staging with one instance. In production it makes things strictly worse, for three compounding reasons.
1. You multiply by replica count. 40 containers × 50 = 2,000 connections at a server with max_connections=200. Your soft client-side queue becomes hard FATAL: sorry, too many clients already errors, and those hit health checks and migrations too.
2. Each Postgres connection is a process. A backend costs ~5–10MB of base memory plus up to work_mem per sort node. Going from 200 to 2,000 backends doesn't add throughput past the point where CPU and disk saturate; it adds context switching and lock contention. Throughput curves over-connection-count are humped, not monotonic — beyond roughly cores × 2 + effective_spindle_count you go backwards.
3. A longer pool_timeout converts fast failures into slow ones. If checkout latency is 9s, a 30s timeout means requests now sit for 30 seconds behind your load balancer's 30s idle timeout, so clients see 504s and retry, which adds load. The queue grows without bound — classic bufferbloat.
The deeper issue: pool exhaustion is almost never a capacity problem. It's a hold-time problem. Little's Law: concurrent connections needed = arrival rate × hold time. You can only fix it by attacking hold time or arrival rate, and raising the limit attacks neither.
How It Actually Works
There are two independent queues, and confusing them is the whole bug.
flowchart TD
R[Incoming request] --> Q{Free connection<br/>in client pool?}
Q -- yes --> C[Checkout connection]
Q -- no --> W[Wait in app-side queue]
W -- pool_timeout expires --> E["Timed out fetching a new<br/>connection from the pool"]
W -- slot frees --> C
C --> T[BEGIN]
T --> S1[SELECT ... 6ms]
S1 --> X["await paymentApi.charge()<br/>800ms — connection still held,<br/>Postgres shows 'idle in transaction'"]
X --> S2[UPDATE ... 4ms]
S2 --> CM[COMMIT + release to pool]
CM --> Q
style X fill:#f9d0d0,stroke:#c00
style E fill:#f9d0d0,stroke:#c00
The metric that matters is checkout duration, not query duration. If queries take 10ms but checkout takes 850ms, 98% of the time a connection is held it is doing nothing. Ten connections then deliver ~12 ops/sec, not 1,600.
Common hold-time inflators, in the order you should hunt for them:
- External I/O inside a transaction — HTTP call, S3 upload, another service,
sleep. - Interactive transactions in ORMs (Prisma's
$transaction(async tx => ...), SQLAlchemy sessions held for a whole request, Railswith_lockblocks doing work). - Blocked event loop (Node/Python async): CPU-bound JSON or crypto work starves the callback that would release the connection.
- Row-lock waits: query is fast in isolation, but under contention it waits on
FOR UPDATE; Postgres showswait_event_type = Lock. - Sequential per-item queries in a loop instead of one
IN (...).
Diagnosis, in order:
-- 1. Are connections held between statements?
SELECT state, count(*), max(now() - xact_start) AS oldest_xact
FROM pg_stat_activity WHERE datname = current_database()
GROUP BY state;
-- Lots of 'idle in transaction' => app-side hold, not DB slowness.
-- 2. Or are they genuinely waiting on locks?
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity WHERE state = 'active' GROUP BY 1,2;
Then add the guardrails: a server-side idle_in_transaction_session_timeout so a leak fails loudly, and a transaction-mode pooler (PgBouncer, RDS Proxy, Supavisor) if you have many short-lived instances. Transaction mode lets 500 client connections share 20 server backends, because a backend is only bound to a client for the duration of a transaction — which only helps if your transactions are short, so hold time still has to be fixed first.
Before and After
// BEFORE — connection held for ~850ms per request
// The HTTP call sits inside the transaction, so the pooled connection
// is 'idle in transaction' for 800 of those 850ms.
await prisma.$transaction(async (tx) => {
const order = await tx.order.findUniqueOrThrow({ where: { id } }); // 6ms
const receipt = await payments.charge(order.total); // 800ms <-- holds connection
await tx.order.update({
where: { id },
data: { status: 'PAID', receiptId: receipt.id }, // 4ms
});
});
// 10 connections / 0.85s hold = ~12 orders/sec before pool timeouts.
// AFTER — connection held ~10ms per request; limit untouched at 10
// 1. Read and charge outside any transaction.
// 2. Transaction wraps only the writes that must be atomic.
// 3. Idempotency key makes the retry-after-charge case safe,
// which is what the long transaction was really protecting against.
const order = await prisma.order.findUniqueOrThrow({ where: { id } });
const receipt = await payments.charge(order.total, {
idempotencyKey: `order-${order.id}-v${order.version}`, // no DB connection held
});
await prisma.$transaction(async (tx) => {
const { count } = await tx.order.updateMany({
where: { id, version: order.version }, // optimistic concurrency
data: { status: 'PAID', receiptId: receipt.id, version: order.version + 1 },
});
if (count === 0) throw new ConflictError('order changed concurrently');
});
// ~10ms hold => the same 10 connections now sustain ~1,000 orders/sec.
Also worth setting, once hold time is fixed:
# Small pool per instance + pooler absorbs the fan-in.
DATABASE_URL="postgresql://...:6432/app?pgbouncer=true&connection_limit=5&pool_timeout=3"
ALTER ROLE app_worker SET idle_in_transaction_session_timeout = '5s';
When NOT to Use This
- Queries genuinely are slow. If checkout duration ≈ query duration and both are 900ms, hold-time surgery won't help — go fix the plan, add an index, or add a read replica. This article is for the case where the DB is idle and the pool is not.
- You're actually under-provisioned. A single instance with
connection_limit=2on a 16-core Postgres serving 3ms queries should be raised. The rule is: raise the limit only when checkout duration is already close to query duration and Postgres has headroom. - Long analytical transactions are the point. A nightly report holding one connection for 20 minutes is fine — give it a separate, dedicated pool rather than trying to shorten it.
- PgBouncer transaction mode with session-scoped features. If you rely on
LISTEN/NOTIFY, advisory locks held across statements, session-levelSET, or server-side cursors, transaction pooling will break them; use session mode (which gives you far less fan-in) or a dedicated direct connection.
Gotchas
- Prepared statements + transaction pooling. Protocol-level prepared statements break under PgBouncer transaction mode with cryptic
prepared statement "s0" already existserrors. Either use PgBouncer ≥1.21 withmax_prepared_statementsset, or disable them in the driver (pgbouncer=true,statement_cache_size=0,prepareThreshold=0depending on stack). - Migrations must bypass the pooler. Advisory-lock-based migration tools (Prisma Migrate, Flyway) need a session connection. Keep a separate
DIRECT_URLon port 5432. - Serverless multiplies silently. Each warm Lambda/Cloud Run instance holds its own pool for minutes after the request ends. Peak connections = peak concurrency × connection_limit, not average. Use
connection_limit=1plus a pooler there. - Health checks share the pool. When it's exhausted, readiness probes time out, the orchestrator kills pods, remaining pods take more traffic, and the whole fleet flaps. Use a separate tiny pool (or no DB at all) for liveness.
pool_timeout=0is not a fix. Disabling the timeout means requests wait forever; you trade a visible error for unbounded latency and memory growth in the waiter queue.- Instrument checkout duration explicitly. Most APM defaults show query time only. Emit a histogram of checkout-to-release per operation — it's the single metric that tells you whether the pool is too small or held too long.
Key takeaway: A pool timeout means connections are held too long, not that the pool is too small — measure checkout duration before you touch the limit.
Real-world challenge
A nightly reconciliation job starts at 02:00. Within a minute, your customer-facing API begins returning 500s with 'Timed out fetching a new connection from the connection pool (pool_timeout=10)'. Postgres CPU is 15%, active backends are 60 out of max_connections=200, and pg_stat_activity shows many rows in state 'idle in transaction' lasting 3–9 seconds. The batch job processes 50k records. How do you diagnose and fix it?
Diagnose
idle in transactionfor seconds is the smoking gun: the batch opened transactions and is doing non-DB work inside them.- Confirm which statement is stuck:
SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY xact_age DESC LIMIT 10;
- If the top rows show a
SELECTthat already returned plus a longxact_age, the app is holding the connection between statements — almost always an external call (payment API, S3, another service) inside the transaction block. - Note that API and batch share the same pool. 60 active backends against max_connections=200 proves Postgres is not the bottleneck; the queue is in the app.
Fix
- Move the external call outside the transaction; only wrap the writes that must be atomic.
- Batch the work: 500 records per transaction instead of one transaction for the run, or one per record with no I/O inside.
- Give the batch job its own pool (separate process/connection string with a small
connection_limit) so it cannot starve request-path traffic. - Add a server-side guard so a future regression fails loudly instead of starving the pool:
SET idle_in_transaction_session_timeout = '3s'for the batch role.
Verify: checkout-duration p99 should drop to roughly query duration, and pool timeouts disappear without changing the pool size.