Why serverless keeps exhausting your Postgres connection pool

July 7, 20266 min readBy Harman Kamboj
ServerlessPostgresBackendFull-stack

The first time I watched serverless exhaust a Postgres connection pool, everything looked healthy right up until it did not. Traffic climbed, response times were fine, and then a marketing push landed and the database started refusing connections with sorry, too many clients already. Nothing in the app code had changed. The functions had not gotten slower. There were just suddenly too many of them, each holding its own handful of connections, and Postgres ran out of room to say yes. If you have shipped anything on Lambda or Vercel functions in front of a plain Postgres instance, you have probably met this wall, and I want to walk through why serverless keeps exhausting your Postgres connection pool and what I actually do about it.

I have spent a lot of years on backends that had to stay up under real load, from a DeFi exchange to an e-commerce app doing serious traffic on the Play Store. The connection math is the same everywhere, and it bites hardest the moment you move to serverless without changing how you think about the database.

Serverless and a connection pool want opposite things

A connection pool is an old, good idea. You open a small set of database connections once, keep them warm, and hand them out to requests as they come in. It works because a normal server is one long-lived process. Ten pods, a pool of twenty each, and you have a predictable ceiling the database can plan around.

Serverless breaks the assumption that the pool sits still. Each function instance is its own little process with its own pool, and the platform spins up as many instances as it needs to soak up concurrency. Fifty concurrent requests can mean fifty cold processes, each opening connections, and none of them aware the others exist. The pool that was supposed to protect the database is now fifty separate pools all pulling from the same limit at once. Postgres does not care that you meant well. It counts real sockets, and every idle connection still holds a backend process and its slice of memory.

There is a second edge people miss. A connection is not free to make. Postgres forks a backend, does the TLS handshake, sets up session state, and that cost lands on the hot path of a cold function. So you get the worst of it in a burst: more connections than the database can hold, and each one slower to establish exactly when you need speed.

What the fix is not

The reflex is to raise max_connections. I understand the urge and I have done it, and it buys you a little headroom before it makes things worse. Every connection costs memory whether it is doing work or sitting idle, and a Postgres box tuned for a thousand connections spends real RAM on bookkeeping it could have spent on cache. You are not adding capacity so much as moving the cliff a few feet further out and making the fall harder.

The other non-fix is creating the client inside the handler. If you instantiate a new database client per invocation, you never reuse a warm connection and you tear one down and build another on every call. Instantiate the client in module scope, outside the handler, so warm invocations reuse it. That one change stops a lot of the bleeding, but it does not solve the core problem, because even one connection per warm instance still multiplies by however many instances the platform decided to run.

Put a pooler between the functions and the database

The real answer is to stop letting your functions talk to Postgres directly. Sit a connection pooler in the middle, something like PgBouncer or the pooling endpoint your managed Postgres already ships, and point every function at that instead. The pooler keeps a modest set of actual database connections and multiplexes thousands of short client connections across them. Your functions think they each have a connection. The database only ever sees the small warm set the pooler maintains. That is the whole trick, and it is boring in the best way.

Run the pooler in transaction mode for serverless. In transaction mode a client only holds a real backend for the length of a single transaction, then hands it back, which is exactly the short bursty shape serverless traffic has. The catch worth knowing up front is that transaction mode does not support session-level features like prepared statements or SET that outlive a transaction, so some ORMs need a flag flipped or a query mode changed. Read your driver's notes before you flip it, because the failure is quiet and confusing if you do not.

The habits that keep it healthy

A pooler removes the cliff. It does not excuse slow queries. If a query holds a transaction open for two seconds, that is two seconds a pooled backend cannot serve anyone else, and under load the pool backs up behind it. Everything I care about on a normal backend still applies here, only with less slack. Keep transactions short. Do not do slow work while holding one. Move anything that can wait off the request path into a queue, which is the same argument I made in my piece on background jobs and queues. The request should touch the database quickly and let go.

A few things I check before I call it done:

  • One client per instance, in module scope. Never per request, and never a fresh pool inside the handler.
  • Small per-instance pool sizes. With a pooler in front, each function needs very few connections, often just one or two. Big per-function pools defeat the point.
  • A hard statement timeout. So a single stuck query cannot pin a backend forever and drag the pool down with it.
  • Watch active versus idle connections, not just the total. A pile of idle-in-transaction connections is the early warning that something is holding on too long.

None of this is exotic, and that is the point I keep coming back to. Serverless did not invent a new database problem. It took an old one, connection limits, and removed the thing that used to hide it, a stable process count. The choice of runtime matters less than most people think here, and I said as much in Go versus Node for a backend in 2026. What matters is respecting that Postgres counts connections and your platform is happy to make more of them than it can hold. I learned this the durable way, keeping an e-commerce backend up through real spikes, which I wrote about in the Aftertutor case study. Put a pooler in the path, keep the connections few and the transactions short, and the wall stops being a wall. If you want more of how I think about backends that stay up, the rest is in my full-stack notes.

Backend fighting you under load?

I am open to senior full-stack, backend, or Web3 engineering roles, fully remote and any timezone. If the hard part of your product is staying up when traffic spikes, that is the work I like.

Get in touch →