Tech
Tech · systems in plain English · ◉ Evergreen

When you run out of connections, you don't run out slowly

by · ·6 min·Working Theory

Your app doesn't talk to the database directly — it borrows from a small, fixed pool of connections and gives them back. When one slow query holds its connection a little too long, that pool empties, every new request lines up behind it, and the retries meant to help become the thing that finishes you off. Connection-pool exhaustion is a cliff, not a slope.

Most apps don’t open a fresh database connection for every request — opening one is slow and the database can only hold so many at once — so they keep a small pool of them, maybe ten or twenty, and hand them out. A request borrows a connection, runs its query, returns it. The pool works beautifully right up until the moment it doesn’t, and the way it fails is worth understanding, because it fails like a cliff and not like a slope. Everything is green, latency is flat, and then a single dependency gets a little slow — a query that usually takes 20 milliseconds starts taking 500 — and within seconds the whole service is throwing errors that have nothing obvious to do with that query. People stare at dashboards asking why a tiny slowdown in one place turned into a total outage everywhere.

The arithmetic is unforgiving, and Little’s Law names it: the number of connections in use at any moment is roughly how many requests arrive per second times how long each one holds its connection. Hold time is the sneaky term. If requests arrive at fifty a second and each holds a connection for 20 milliseconds, you need about one connection in flight — a pool of twenty is luxurious. Let that hold time climb to 500 milliseconds and the same fifty-per-second traffic now wants twenty-five connections at once. Your pool of twenty is already underwater. It didn’t get twenty-five times more traffic; the traffic never changed. The hold time went up, and a fixed pool converts a modest slowdown into a hard wall. Every request that can’t get a connection now waits — and waiting is where the second, nastier problem begins.

incoming waiting queue (growing) connection pool (fixed: 5) slow query — holding its slot waited too long → timeout ✕ retry → more load
The pool is the bottleneck. One slow query holds its slot, the queue behind it grows, requests time out — and the retries meant to recover pour straight back onto the top of the pile. Original diagram · Working Theory

That second problem is the feedback loop, and it’s why exhaustion turns into an outage instead of a blip. When a request waits past its patience and times out, the client very often retries — and a retry is just another request arriving, on top of the ones already queued, for the same starving pool. So the moment you most need load to drop, your own retry logic multiplies it. This is the connection-layer cousin of the thundering herd: not everyone stampeding a cold cache at once, but every timed-out caller re-knocking on a door that’s already jammed. The pool can’t drain because the slow query is still holding a slot, the queue can’t shrink because retries keep refilling it, and a service that was fine ninety seconds ago is now fully wedged — often across every endpoint, including the healthy ones that just had the bad luck to share the same pool.

The build decisions follow from seeing the pool as the real resource. Cap the wait: a request that can’t get a connection quickly should fail fast rather than sit in the queue turning into a timeout-plus-retry — a fast, clean rejection sheds load; a slow one adds to it. Put a ceiling and a timeout on the slow dependency itself so one pathological query can’t hold a slot indefinitely. Make retries polite — backoff plus jitter plus a cap — so recovery doesn’t become a self-inflicted flood, and consider shedding or shortcutting load when the pool is near empty instead of politely queueing everyone. And isolate pools by dependency where you can, so a slow call to one downstream service can’t drain the connections every other part of your app needs — the pattern where one sick dependency takes down the whole process usually traces back to everyone drinking from the same well.

Two honest cautions. First, a bigger pool is not the fix, even though it’s the first reflex. A larger pool buys you a little headroom and then hands the same overload straight to the database, which has its own connection limit and its own cliff — you’ve often just moved the exhaustion one layer down to a place that’s harder to see. The real lever is almost always hold time, not pool size: make the slow thing faster or time it out, and a small pool is plenty. Second, none of this shows up in a normal load test, because normal load tests keep hold times constant. Exhaustion is a tail phenomenon — it’s triggered by the p99 query, the degraded dependency, the retry storm — so the way to find it before your users do is to test the unhappy path on purpose: inject latency into a dependency and watch whether your pool, your timeouts, and your retries fail like a graceful slope or like a cliff.

Sources

  • connection pooling and pool exhaustion (standard database-driver and app-server behavior)
  • Little's Law (queue length ≈ arrival rate × time in system) as the intuition for why a small slowdown drains a fixed pool

Liked this? Get the next one in Working Theory.

Going weekly in August (it's in beta now). One genuinely interesting read on building, the brain, and the science most people missed.

Subscribe →
Got a reaction, a counter-example, or something I missed? Reply by email — I read everything.
◉ join in

Where have you hit this — in a product you use, or one you're building?

Threads open here soon. For now, the conversation lives two clicks away — discuss on GitHub, or just reply by email. I read and answer everything.