The single most common Postgres incident on Render is “too many connections”. The cause is almost always the same: someone added a new service, scaled up the number of instances, or bumped the pool size - without checking against the database’s hard limit.
Render does not run a connection pooler in front of Render Postgres. That’s deliberate, and it means the math is on you. Once you know it, the rest of this is mechanical.
The hard limit by plan
Maximum concurrent connections scale with the database’s RAM:
| Memory | Max connections |
|---|---|
< 8 GB | 100 |
8 GB <= memory < 16 GB | 200 |
16 GB <= memory < 32 GB | 400 |
>= 32 GB | 500 |
flowchart LR small["< 8 GB → 100"] med["8-16 GB → 200"] large["16-32 GB → 400"] big["32 GB+ → 500"]
Two important notes:
- These are the caps for current-generation plans. Legacy instance types have lower limits (97 / 197 / 397) - check your specific plan in the Render Dashboard.
- The cap is hard. Hitting it doesn’t queue requests - it returns an error. New connections fail with
FATAL: sorry, too many clients already.
A few of the connection slots are reserved for Postgres itself (replication, autovacuum, monitoring). Plan as if you have ~10 fewer than the headline number - call it 90, 190, 390, 490.
The pool sizing math
The number of connections your app uses isn’t the pool size you set. It’s:
total_connections = num_instances × pool_size_per_instanceIf you have 4 web service instances and each one keeps a pool of 25 connections, that’s 100 - already at the cap of an under-8GB plan, before the worker has even said hello.
A useful budget worksheet for a typical app:
Reserved by Postgres ~10 Web service: 4 instances × 10 pool 40 Worker: 2 instances × 10 pool 20 Cron jobs: transient, ~5 max 5 Migrations / shell / `psql` / debug 10 ───────────────────────────────────────── Total 85 Headroom 15The headroom matters. A deploy briefly doubles connections (old instances draining, new ones starting) - without headroom, deploys themselves trigger “too many connections” errors.
Setting pool sizes in your framework
A quick reference for the three frameworks Render users hit most often.
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10, // pool size per process idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000,});
export default pool;Reuse pool across requests. Don’t new pg.Pool() per request - that’s how you discover connection limits the hard way.
DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "CONN_MAX_AGE": 60, # keep connections alive 60s # Django itself doesn't have a true pool; pair with pgbouncer # or use psycopg's pool for more control }}Django’s connection-per-request model means each Gunicorn/Uvicorn worker is the unit you size for. With 4 workers per instance and 4 instances, you’re looking at 16 concurrent connections under load - fine for an under-8GB plan, painful at scale without PgBouncer.
production: adapter: postgresql url: <%= ENV["DATABASE_URL"] %> pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> checkout_timeout: 5Rails’ pool: is per-process. With Puma running 2 workers × 5 threads = 10 connections per instance. Multiply by your instance count.
The general principle: make pool size an env var, set it conservatively, and double-check the math when you scale.
When you need PgBouncer
Framework pools are great for steady, mostly-idle connections. They struggle when:
- You have many short requests that each need a connection briefly. Connection setup itself becomes the bottleneck.
- You autoscale aggressively and individual instance pool sizes don’t divide cleanly.
- You’re running lots of small services (e.g. a fleet of Lambda-style microservices) and each one wants 5-10 connections.
The right tool is PgBouncer - a lightweight Postgres connection pooler that sits between your apps and the database. Apps connect to PgBouncer; PgBouncer multiplexes those connections onto a small, fixed set of upstream connections to Postgres.
flowchart LR
subgraph apps [Your services]
a1["api × 5 instances<br/>10 conn each = 50"]
a2["worker × 3 instances<br/>10 conn each = 30"]
end
bouncer["PgBouncer<br/>(1 instance, transaction pool)"]
pg[("Postgres<br/>max 100 conn")]
apps -->|"~80 connections"| bouncer
bouncer -->|"~30 multiplexed connections"| pg
The win: 80 application-side connections become 30 actual Postgres connections, well under the limit, with no app-side coordination.
Running PgBouncer on Render
There’s no first-class “PgBouncer service” on Render, but it’s straightforward to run one as a private service:
services: - type: pserv name: pgbouncer runtime: docker plan: starter image: url: edoburu/pgbouncer:latest envVars: - key: DB_HOST fromDatabase: name: app-db property: host - key: DB_USER fromDatabase: name: app-db property: user - key: DB_PASSWORD fromDatabase: name: app-db property: password - key: DB_NAME fromDatabase: name: app-db property: database - key: POOL_MODE value: transaction - key: MAX_CLIENT_CONN value: "200" - key: DEFAULT_POOL_SIZE value: "20"
- type: web name: api runtime: node plan: starter envVars: - key: DATABASE_URL value: postgres://${DB_USER}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME} # ... or build the URL in app code from individual propsThe web service connects to PgBouncer over the private network (pserv services are private by default), and PgBouncer holds the connections to the actual Postgres.
A few PgBouncer knobs worth knowing:
POOL_MODE: transactionis what you want for most apps - connections are checked out for one transaction, then returned. The most efficient mode.POOL_MODE: sessionholds the connection for the whole client session. Closer to “what your app expects” but less efficient.MAX_CLIENT_CONNis how many app-side connections PgBouncer accepts.DEFAULT_POOL_SIZEis how many upstream Postgres connections it opens per (database, user) pair.
Monitoring connection use
The fastest sanity check, run in psql:
SELECT state, count(*)FROM pg_stat_activityGROUP BY stateORDER BY count DESC;Healthy patterns:
active- currently running a query. Should be a small number relative to your total pool.idle- connection is open, holding no transaction. Most of the pool, most of the time.idle in transaction- connection is in a transaction but not running a query. A growing count here is a problem - usually a missingCOMMITor a long-running transaction blocking vacuum.
For continuous monitoring, the get_metrics endpoint (or MCP get_metrics) returns active_connections over time:
get_metrics( resourceId: "<postgres-id>", metricTypes: ["active_connections"])Wire that into your existing alerting at, say, 70% of max_connections. Way before you’d panic, but early enough to add capacity calmly.
What you learned
- Connection caps are a hard limit by plan RAM: ~100 (under 8GB), ~200 (8GB), ~300 (16GB), ~500 (32GB+). Reserve ~10 for Postgres itself
- Connection budget: `instances × pool_size_per_instance + reserved + headroom`. Default pool sizes are sized for one process, not horizontally-scaled fleets
- Set pool sizes via env vars (`DATABASE_POOL_SIZE`, `RAILS_MAX_THREADS`, etc.) so they're easy to tune as you scale
- Reach for PgBouncer when many small services or short transactions outgrow framework pools. `transaction` mode is the most efficient - but breaks LISTEN/NOTIFY and prepared statements
- Monitor `pg_stat_activity` (live) and `get_metrics` with `active_connections` (continuous). Alert at ~70% of the cap