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)