DEV Community

Cover image for The Scaling Gauntlet, Pt. 2: Connection Pooling and How to Stop DDoSing Yourself
John Munn
John Munn

Posted on

The Scaling Gauntlet, Pt. 2: Connection Pooling and How to Stop DDoSing Yourself

It started, as most database revelations do, with a false sense of victory.

You'd just finished your query optimization sprint. Your queries were optimized. Your indexes were pristine. Your EXPLAIN ANALYZE outputs finally made sense.

Postgres Pete was breathing easier. The dashboard was loading faster. You leaned back in your chair, cracked your knuckles, and thought: "Bring on GigaGym."

Then you decided to run a quick load test.

Just 200 concurrent users. Nothing crazy. A gentle warm-up before the real 100,000-user avalanche next month.

The results came back and your confidence evaporated.

Response times: 30 seconds. Timeouts everywhere. Your beautiful, optimized queries were crawling like they were running through molasses.

You opened your monitoring tab, expecting to see slow queries or missing indexes.

Instead, you saw the real problem:

Connections: 200/200

Your queries were fast. Your database was drowning.

Congratulations. You'd optimized the engine but forgotten about the parking lot.

Chapter 2: The Connection Catastrophe

Every request, every worker, every background job, spawning its own fresh, unpooled connection like it's giving out party favors.

At low load, this goes unnoticed.

At scale? It's chaos.

Postgres isn't designed to juggle hundreds of concurrent connections like a chatty Discord server. It likes reuse. Order. Boundaries.

Without connection pooling, each new request is like hiring a new barista for one coffee. Do that 300 times and your café isn't scaling. It's collapsing.

The Connection Archaeology

You dig into the logs, hoping for clarity but finding only chaos:

FATAL: sorry, too many clients already FATAL: remaining connection slots are reserved for non-replication superuser connections ERROR: could not connect to server: Connection refused 
Enter fullscreen mode Exit fullscreen mode

You check the app config. No pooling. You check staging. Also no pooling. You check the background worker. Still no pooling.

Your app isn't just talking to the database. It's screaming.

And poor Postgres Pete? He's trying to have 200 individual conversations simultaneously while juggling flaming torches.

The Connection Pool Decoder Ring

Before you fix it, you need to understand what you're looking at. Here's how to read the signs of connection chaos:

In pg_stat_activity:

SELECT state, count(*) FROM pg_stat_activity GROUP BY state; 
Enter fullscreen mode Exit fullscreen mode

Good:

 active | 5 idle | 10 idle in transaction | 2 
Enter fullscreen mode Exit fullscreen mode

Bad:

 active | 156 idle | 89 idle in transaction | 47 
Enter fullscreen mode Exit fullscreen mode

Key Warning Signs:

  • High 'idle' count: Connections sitting around doing nothing
  • 'idle in transaction': Connections holding locks while napping
  • Connection churn: New connections constantly appearing/disappearing
  • Query queuing: Fast queries taking forever to start

Think of it like a restaurant where every customer demands their own waiter, then sits at the table for 3 hours reading the menu.

The Pool Fix

Connection pooling is how you tell your app to take a breath.

Instead of opening a fresh connection for every request, you keep a small, reusable set like a rideshare carpool for your queries.

App-Level Pools

Most ORMs support basic pooling:

// Prisma pool_max = 10 pool_timeout = 20 // Sequelize  pool: { max: 10, min: 2, acquire: 30000, idle: 10000 } // Rails pool: 5 timeout: 5000 
Enter fullscreen mode Exit fullscreen mode

Start small. 5-15 connections per app instance is usually plenty.

Infrastructure Pools

For real control, introduce PgBouncer:

[databases] myapp = host=localhost port=5432 dbname=myapp [pgbouncer] pool_mode = transaction listen_port = 6432 default_pool_size = 25 max_client_conn = 200 server_reset_query = DISCARD ALL 
Enter fullscreen mode Exit fullscreen mode

Run it in transaction mode for best efficiency. Use it as the single DB endpoint for all environments.

Connection Pool Anti-Patterns

Here's how teams usually mess this up:

The "More is Better" Fallacy

# This is not a solution, it's panic pool: 1000 max_connections: 2000 
Enter fullscreen mode Exit fullscreen mode

This is like widening a highway by adding lanes that all merge into the same single-lane bridge. You're not solving congestion, you're moving it.

The "Set and Forget" Trap

// Development config that somehow made it to prod pool: { max: 1 } 
Enter fullscreen mode Exit fullscreen mode

One connection for your entire app. That's not pooling, that's creating a bottleneck with a fancy name.

The "Background Job Bomber"

# Every job opens its own connection def process_user_data(): db = Database.connect() # New connection every time  # ... do work  db.close() 
Enter fullscreen mode Exit fullscreen mode

Background jobs are the worst offenders. They spawn, grab a connection, do work, then vanish, leaving connection counts looking like a seismograph during an earthquake.

War Story: The Crawler That Broke Christmas

True story from the trenches: Three days before Christmas launch, we deployed a new feature to staging. Everything looked perfect. Load tests passed. Performance was solid.

Then someone forgot to add basic auth to the staging environment.

Within 6 hours, every search engine crawler on the internet discovered our unprotected staging site. Google, Bing, and a dozen smaller crawlers started indexing every single page, including dynamically generated preview routes.

Each preview route triggered an expensive background job. Each background job opened a fresh database connection. No pooling. No limits. No mercy.

The staging database went from handling 20 comfortable connections to choking on 800+ simultaneous connections. But here's the twisted part: the crawlers didn't care that responses were timing out. They just kept crawling, patiently waiting 30 seconds for each page load.

-- What we saw in pg_stat_activity SELECT count(*), state FROM pg_stat_activity GROUP BY state; count | state -------+------- 47 | active 312 | idle 441 | idle in transaction 
Enter fullscreen mode Exit fullscreen mode

Over 400 connections stuck "idle in transaction", each one holding locks, blocking other queries, creating a cascading failure that looked like a DDoS attack.

The fix? Three lines in robots.txt and enabling connection pooling. The lesson? Your database can't tell the difference between real users and robots, it just counts connections.

What looked like a mysterious traffic surge was actually 800 connections playing musical chairs with 20 seats.

The Scaling Pool Recipe

Getting pool sizes right is more art than science, but here's a recipe that works:

For your PostgreSQL server:

# postgresql.conf max_connections = 200 shared_buffers = 256MB 
Enter fullscreen mode Exit fullscreen mode

Start here and monitor. Don't just crank it to 1000 and hope for the best.

For PgBouncer:

# pgbouncer.ini max_client_conn = 500 # How many clients can connect to bouncer default_pool_size = 25 # Actual connections to PostgreSQL pool_mode = transaction # Release connections after each transaction 
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: CPU cores × 2-4 for the pool size.

For your application:

// Per app instance, not total pool: { max: 10, // Max connections per instance min: 2, // Keep some warm acquire: 30000, // Max wait time idle: 10000 // Close idle connections } 
Enter fullscreen mode Exit fullscreen mode

Enable connection timeouts everywhere:

# PgBouncer server_idle_timeout = 600 # Close idle server connections client_idle_timeout = 0 # Let clients decide # PostgreSQL idle_in_transaction_session_timeout = 300000 # 5 minutes 
Enter fullscreen mode Exit fullscreen mode

The Math:

  • 3 app instances × 10 connections each = 30 connections
  • Background workers: 5 connections
  • Admin/monitoring: 5 connections
  • Total: 40 connections (well under the 200 limit)

Leave room to breathe. Your database will thank you.

Tuning Your Pool: Advanced Patterns

Connection Warming

// Warm up connections on app start pool: { min: 5, // Keep 5 connections always ready max: 15, evictionRunIntervalMillis: 30000 } 
Enter fullscreen mode Exit fullscreen mode

Read/Write Splitting

// Separate pools for different workloads const writePool = { max: 5 }; // Heavy, exclusive operations const readPool = { max: 20 }; // Light, concurrent reads 
Enter fullscreen mode Exit fullscreen mode

Connection Health Checks

# PgBouncer health checking server_check_query = SELECT 1; server_check_delay = 30 
Enter fullscreen mode Exit fullscreen mode

Monitoring What Matters

-- Watch these metrics SELECT numbackends as active_connections, xact_commit + xact_rollback as transactions_per_second FROM pg_stat_database WHERE datname = 'myapp'; 
Enter fullscreen mode Exit fullscreen mode

Signs You Fixed It

  • Your database no longer sounds like a fax machine under pressure
  • Queries execute. Pages load. 500s disappear
  • Your connection graph plateaus instead of climbing toward orbit
  • Your team stops dreading launch announcements

TL;DR: Every Request Doesn't Deserve a Fresh Connection

  • Pool your connections at both the app and infrastructure level
  • Start conservative with pool sizes 5-15 connections per app instance
  • Use PgBouncer for professional-grade connection management
  • Set timeouts to prevent connections from becoming digital zombies
  • Monitor your connection counts like you monitor your response times

Next up: Caching strategies and the paradox of making fast things faster.

But for now, take a breath. You've moved from query chaos into connection clarity.

Your load test is passing. Your connection count is stable. Postgres Pete is no longer hyperventilating.

But don't get too comfortable. Because tomorrow, you're going to run another test with 1,000 concurrent users.

And that's when you'll discover that even perfectly pooled connections can't save you from the next bottleneck in the scaling gauntlet.

Remember: Your app isn't special. It still needs to learn to share.

Top comments (0)