Skip to main content
Stackbastion

Draft, not yet published

Postmortem: a connection pool exhaustion that looked like a hack

7 July 2026· 6 min read · by Stackbastion

The app went down at 8:47 on a Tuesday morning. First error in the logs: remaining connection slots are reserved for non-replication superuser connections. Every new request hung, then timed out. The dashboard showed a flat line where traffic used to be.

The founder’s first thought was the one everyone has: we’re being attacked.

Before we go further, one honest note. This is a composite of patterns we see across AI-built apps, not one specific client incident. Names and numbers here are illustrative. The failure mode itself is real and common. We’ve walked through some version of this more times than we’d like.

What it looked like from the outside

The symptoms all pointed the wrong way.

Traffic had spiked that morning. A post had done well overnight, and a few hundred people showed up at once. The error mentioned connection slots being “reserved,” which sounds like something is holding the door shut. The database CPU was fine. Memory was fine. Disk was fine. But every request failed.

When your app dies during a traffic spike and the error mentions connections being blocked, “someone is hammering us” is a reasonable guess. It’s just usually wrong.

What actually happened

The app was built on a template that opened a new database connection for each request and never closed it properly. Under light traffic, this is invisible. Connections get cleaned up eventually, and you rarely have more than a handful open at once.

Postgres has a hard limit on how many connections it will accept. The default is 100. Some of those are reserved for admin use, so the real ceiling for the app is lower. Call it 90.

On a normal day this app peaked at maybe 15 open connections. Plenty of headroom. Then the traffic spike hit. Hundreds of requests arrived in a short window, each one grabbing a connection and holding it. The count climbed past 50, past 80, and then hit the ceiling. Once every slot was taken, the next request couldn’t get in. Neither could the one after that. The app didn’t crash. It just stopped being able to talk to its own database.

No attacker. No malicious traffic. Just real users, a leak that had been there since day one, and a limit nobody knew existed.

Why AI-built apps hit this so often

This one shows up again and again in vibe-coded apps, and there’s a reason.

When you build an app by describing features to an AI, you get code that works in the demo. It handles one user clicking through the flow. It does not handle 300 users arriving in 90 seconds. Connection management is exactly the kind of thing that never comes up until it breaks, so it rarely makes it into the generated code.

The app also usually connects straight to the database with no pooler in front of it. Every request is a fresh connection to Postgres. That’s fine at demo scale and a time bomb at launch scale.

The fix

The fix has two parts, and neither is exotic.

First, put a connection pooler in front of the database. A pooler like PgBouncer sits between your app and Postgres. Your app opens connections to the pooler, which keeps a small, steady set of real connections to the database and shares them across all incoming requests. Instead of 300 requests each demanding their own database connection, the pooler serves them from a pool of, say, 20. The database never sees the flood. We wrote up how this works in more detail in our guide to connection pooling with PgBouncer.

Second, fix the leak in the app. Connections should be returned to the pool as soon as the query is done, not held for the life of the request. In most frameworks this is a one-line change once you know to look for it. The problem is knowing to look.

With both in place, the same traffic spike that took the app down became a non-event. The pooler absorbed the burst, the database stayed under 20 connections, and users got their pages.

What we’d tell you before it happens

You don’t need to wait for the 8:47 AM outage to find this.

Three things to check today:

Know your connection ceiling. Run SHOW max_connections; on your database. Whatever the number is, that’s your hard wall. If your app opens a connection per request and never pools, you will hit it. The only question is when.

Put a pooler in the path. If your app connects directly to Postgres, add PgBouncer or your provider’s built-in pooler. This one change removes the entire class of problem.

Watch your open connection count. A single graph of active connections over time tells you if you’re creeping toward the ceiling. When the line trends up and never comes back down, you have a leak, and you want to know that before a traffic spike proves it.

The frustrating part of this incident is how boring the fix is. There was no clever attacker to outsmart. There was a default limit, a leaky template, and a spike that arrived at the worst possible moment. The apps that survive their first real traffic aren’t the ones with the smartest code. They’re the ones where someone checked the connection settings before launch day.

If you’re not sure whether your app has a pooler in front of its database, or what your connection ceiling is, that’s exactly the kind of thing our free audit is built to catch. We look at the boring settings that decide whether your launch day is a win or an outage.

FAQ

What does “connection pool exhaustion” actually mean?

Your database accepts a fixed maximum number of open connections at once. Pool exhaustion means every slot is taken and new requests can’t get in. The app stays up, but it can’t reach its data, so everything fails.

Why did it look like an attack?

The outage happened during a traffic spike, and the error message mentioned connections being reserved or blocked. Both point toward “something external is overwhelming us.” The real cause was internal: the app leaked connections and had no pooler, so ordinary traffic was enough to hit the limit.

Would a bigger database have fixed it?

No. This wasn’t a CPU or memory problem. A bigger server has the same default connection ceiling unless you change it, and raising that ceiling without a pooler just delays the failure and can make it worse. The fix is pooling, not more hardware.

What’s a connection pooler in one sentence?

It’s a small piece of software that sits between your app and your database, keeps a fixed set of real connections open, and shares them across all your requests so the database never sees a flood.

How do I know if my app is at risk?

If it connects directly to Postgres with no pooler, opens a connection per request, and you’ve never checked max_connections, you’re at risk. Most AI-built apps match that description out of the box.