0x55aa
Back to Blog

🏊 Connection Pool Exhaustion: The Traffic Jam Your Database Never Warned You About

|
5 min read

Picture a coffee shop with one barista and five tables, but the door lets in fifty customers at once. Nobody's coffee machine is broken. Nobody's out of beans. People are just standing around waiting for a table to free up. That's connection pool exhaustion, and it's one of the most misdiagnosed outages in backend engineering — because every symptom points at the database, and the database is usually innocent.

The setup that lies to you

Here's a totally reasonable-looking pool config:

const { Pool } = require('pg');

const pool = new Pool({
  host: 'db.internal',
  max: 10,          // "10 connections should be plenty, right?"
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

app.get('/orders/:id', async (req, res) => {
  const client = await pool.connect();
  try {
    const result = await client.query(
      'SELECT * FROM orders WHERE id = $1',
      [req.params.id]
    );
    res.json(result.rows[0]);
  } finally {
    client.release();
  }
});

This works great in dev. It works great in staging with two people clicking around. Then it ships, traffic spikes, and suddenly every request is timing out with Error: timeout exceeded when trying to connect. CPU on the database is at 12%. Query latency, when queries actually run, is 8ms. Everyone stares at the DB dashboard looking for a villain that isn't there.

The villain is upstream: 10 connections, but 40 concurrent requests each wanting one. The 11th request doesn't get an error immediately — it queues, politely, waiting its turn. If it waits longer than connectionTimeoutMillis, it dies with a timeout that has nothing to do with query performance and everything to do with queueing theory.

The bug that's even sneakier: leaked connections

Pool size mistakes are at least visible in your metrics. This one hides for weeks:

// Looks fine. Isn't fine.
app.get('/orders/:id', async (req, res) => {
  const client = await pool.connect();
  const result = await client.query(
    'SELECT * FROM orders WHERE id = $1',
    [req.params.id]
  );
  res.json(result.rows[0]);
  client.release(); // never reached if query() throws
});

If the query throws — bad SQL, a dropped connection, a constraint violation — client.release() never runs, and that connection is gone from the pool forever, silently, one leak at a time. Ten leaks and your effective pool size is zero. This is exactly why the try/finally in the first example isn't cosmetic; it's the whole point.

At Cubet, we had a reporting endpoint that leaked exactly one connection per malformed date-range query — rare enough that it took nearly two weeks of slow bleed before the pool ran dry during a Monday-morning traffic spike, and it looked, at first glance, identical to a database outage. Once we added pool metrics (pool.totalCount, pool.idleCount, pool.waitingCount in node-postgres), the leak was obvious within minutes — the waiting count climbed in a straight line all week and nobody was watching it.

Sizing the pool isn't a vibe

The instinct is to crank max up until the errors stop. Resist it — every connection is a process on the database side with its own memory overhead, and Postgres in particular degrades badly with too many concurrent connections fighting over the same CPU cores. A rough starting formula that works better than guessing:

pool_size = ((core_count * 2) + effective_spindle_count)

That's the classic PgBouncer/HikariCP sizing heuristic — it's not gospel, but it beats "let's just try 100." For most app-server workloads you actually want a smaller pool per instance and more instances, plus a pooler like PgBouncer in transaction mode sitting between your app and Postgres so hundreds of app connections multiplex down to a sane number of real database connections.

// A pool that fails loudly instead of silently queueing forever
const pool = new Pool({
  max: 15,
  connectionTimeoutMillis: 3000,
});

pool.on('error', (err) => {
  console.error('Unexpected pool error', err);
});

setInterval(() => {
  metrics.gauge('db.pool.waiting', pool.waitingCount);
  metrics.gauge('db.pool.idle', pool.idleCount);
  metrics.gauge('db.pool.total', pool.totalCount);
}, 5000);

That last block is the part most teams skip. waitingCount climbing steadily is the earliest, cheapest signal you'll ever get that a traffic spike or a leak is about to take you down — and it's available for free from a library most of us already use without ever calling .on() on it.

The takeaway

Connection pools are the part of the stack that's invisible right up until it's the only thing that matters. If your database looks bored while your app is on fire, stop staring at query plans and go look at pool metrics instead — waitingCount, idleCount, timeout rates. Ninety percent of the time the database was never the bottleneck; the door to it was.

Go check your pool config right now. If you don't know your current max, or you've never once looked at waitingCount in production, that's this week's fifteen-minute fix — and it's a lot cheaper than debugging it at 2am during a traffic spike.

Share:LinkedInXHacker News

Related reading

Thanks for reading!

Back to all posts