Budgeting Heroku Postgres Connections: Pool Sizing, Deploys, and When to Add PgBouncer
Your app fails with connection refused errors when total simultaneous connections exceed Heroku Postgres max_connections. Compute the budget upfront, size pools small, and plan for deploy doublings and failovers.
18 Sept 2025, 00:56 UTC

Your web app on Heroku suddenly returns connection refused errors under load. It looks like a database outage, but the root cause is usually arithmetic: the total simultaneous connections from all dynos exceed the plan's max_connections limit. The fix isnt more bandwidth--its intentional connection budgeting. Keep your worst-case total (dynos x processes x pool size) under roughly 80% of the plan limit, account for deploy doublings and non-web processes, and know when to escalate to a transaction pooler.
\nRequirements for connection budgeting
\nThe database must serve every running dyno, background workers, one-off dynos (migrations, consoles, heroku run jobs), and any external tools. The constraint is that simultaneous connections stay under the plan limit with headroom to spare. A second, quieter requirement: the app must survive credential rotation and failovers without manual intervention, because Heroku manages both.
\nThe smallest suitable design
\nStart with a fixed, small per-process pool -- commonly 2 to 5 connections -- configured explicitly in the framework, and keep the worst-case total under roughly 80% of the plan limit. The 20% buffer absorbs migrations, consoles, and the occasional forgotten psql session.
\nFor a Rails app, that is the pool key in config/database.yml:
\npool: 3\nFor Node pg, the max option on Pool:
\nconst { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5, idleTimeoutMillis: 30000 });\nMost web requests hold a connection for milliseconds. A pool of 5 per process is rarely the bottleneck; when checkout wait spikes, investigate slow queries first.
\nTrust and data boundaries
\nDATABASE_URL is provisioned and rotated by Heroku as a config var. The app must read it from the environment at boot -- never hardcode it, never copy it into source control. Rotation happens during failovers, maintenance, and plan changes, and Heroku restarts dynos when config vars change, so a boot-time read is normally sufficient.
\nThe subtler boundary is stale connections. Network layers and the database itself can drop idle connections, and a pool that hands out a dead connection produces intermittent, hard-to-reproduce errors. Enable validation-on-checkout (Rails does this by default in recent versions; in node-postgres, an error handler on the pool plus a short idle timeout covers most cases). After a Postgres failover, expect a burst of simultaneous reconnects; retry with backoff rather than hammering a recovering primary.
\nOperational checks that matter
\n- \n
- Pool checkout wait time. If requests wait on the pool, either the pool is too small for real concurrency or queries are too slow. Check slow queries first. \n
- Postgres-side connection counts. Watch active and idle connections via Heroku Postgres metrics and logs. Sustained counts near the plan limit, or FATAL: sorry, too many clients already errors, mean the budget is blown. \n
Practical verification before scaling: compute the worst-case total against the documented limit for your current plan, then run a load test at expected peak while watching both signals -- and repeat it during a rolling deploy, because that is when demand doubles. Exact plan limits change over time and differ across platform generations, so confirm the number in Heroku's current documentation rather than trusting any article, including this one.
\nFailure modes and mitigations
\n- \n
- Deploy-time exhaustion. Old and new dynos coexist during rolling deploys, roughly doubling web connections. Mitigate by sizing for the deploy case, or using preboot-aware pool sizing. \n
- Stale pool connections. Idle connections dropped by the network surface as random query errors. Mitigate with validation-on-checkout and sane idle timeouts. \n
- Thundering reconnects after failover. Every dyno reconnects at once. Retry with jittered backoff; do not loop tightly. \n
- Credential rotation surprises. Apps that cache the URL or credentials in a file break silently at rotation. Boot-time environment reads avoid this. \n
When the design changes: triggers to re-review the budget
\nRecompute whenever any of these change: Postgres plan (limits differ per plan and per platform generation), dyno count or process model, adding worker dynos that share the database, adopting a transaction-mode pooler, or changing the framework's pool defaults through an upgrade. Connection budgeting is not a one-time setup task; it is a constraint that every scaling decision must re-check. The teams that get paged for too many connections are almost always the ones who scaled dynos without redoing the multiplication.
\nEscalation: transaction-mode pooling
\nWhen you scale dynos beyond what the budget allows -- say 20 dynos x 3 processes x 5 = 300 wanted connections against a 120-connection plan -- the standard move is a transaction-mode pooler such as PgBouncer (available via buildpack) between dynos and Postgres. Dynos connect to the pooler; the pooler multiplexes a small number of real server connections.
\nThis changes semantics. In transaction mode, a server connection is only held for the duration of a transaction, so session-level features break or behave differently: prepared statements, advisory locks, SET commands, temp tables, and LISTEN/NOTIFY are the usual casualties. Before adopting a pooler, run the app's test suite against transaction-mode pooling and grep the codebase for those features. This is a real design change, not a free capacity upgrade.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.