AWS Aug 18, 2026 · 5 min read

Lambda vs PostgreSQL: the day you run out of connections

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

The error always shows up at the worst possible time: remaining connection slots are reserved for non-replication superuser connections. The app had been running fine for weeks, nobody touched the database code, and then one traffic spike leaves half your API returning 500s. Everyone reaches for the same fix first: bump max_connections on the instance. It works for a while, and then it happens again. The number was never the problem. The problem is that a connection pool and an ephemeral function are two ideas that contradict each other.

One pool per environment, not one pool per application

A pool exists to amortize something expensive. Opening a PostgreSQL connection costs real work: TCP handshake, TLS, authentication, and the server spins up a dedicated process per connection. In a long-lived backend — a Nest service on Fargate, say — you open ten connections at boot and reuse them for days. You pay that cost once.

Lambda has no "at boot" in the sense you're imagining. It has one boot per execution environment, and AWS creates as many environments as you have concurrent invocations. If your peak is 200 concurrent invocations, you have 200 independent Node processes, each with its own pool. A pool of 10 per environment means 2,000 connections knocking on a database that can handle 100.

The math is brutally simple:

connections = Lambda concurrency × pool size per environment

A db.t4g.medium sits around 220 max_connections. At 50 concurrent invocations with a pool of 5, you're already at the ceiling. And unlike a server, you don't get to choose the concurrency here — traffic chooses it for you.

The mistake that multiplies the damage: creating the pool inside the handler

Before touching any infrastructure, look at where the client gets created. This is the pattern I run into most often, and it turns a scaling problem into an immediate one:

// BAD: a fresh pool on every invocation, and connections left dangling
export const handler = async (event) => {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const { rows } = await pool.query('SELECT id FROM users WHERE email = $1', [event.email]);
  return rows[0];
};

Every invocation opens a brand new pool. And if the end() is missing — it almost always is — the connection stays occupied until PostgreSQL reaps it on timeout. You've just turned every request into a leak.

The fix is to hoist it out of the handler, into module scope. That code runs once per execution environment, and the environment gets reused across invocations for as long as it stays warm:

// The pool lives in the execution environment and survives across invocations
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,                       // one environment serves one invocation at a time
  idleTimeoutMillis: 30000,     // release the connection when the environment goes idle
  connectionTimeoutMillis: 5000,
});

export const handler = async (event) => {
  const { rows } = await pool.query('SELECT id FROM users WHERE email = $1', [event.email]);
  return rows[0];
};

max: 1 looks wrong if you come from servers, but it's exactly right here: a Lambda environment handles one invocation at a time, so a pool of 10 is nine connections that never do any parallel work and absolutely do count against your limit.

Reserved concurrency: the ceiling you actually control

With max: 1, your connection count becomes precisely your concurrency. And concurrency you can cap, function by function:

resource "aws_lambda_function" "api" {
  function_name                  = "api-handler"
  reserved_concurrent_executions = 40   # hard ceiling of 40 connections
  # ...
}

It's an uncomfortable call, because it means accepting throttling: past 40 simultaneous invocations, Lambda starts rejecting. But I'll take a bounded failure in one function over a database that stops accepting connections and takes down the workers, the migrations and the admin panel along with it — none of which had anything to do with the spike. A limit isn't a restriction. It's deciding in advance who suffers first when something breaks.

RDS Proxy: what it solves and what it doesn't

RDS Proxy sits between Lambda and the database and maintains its own set of already-open connections. Your functions talk to the proxy, and the proxy multiplexes: many ephemeral clients over a handful of real, reused connections.

What you genuinely gain:

  • It absorbs spikes. When the database is at its ceiling, the proxy queues instead of rejecting.
  • It removes connection setup cost from every cold start. That shows up in p99 latency more than you'd expect.
  • Cleaner failover. It holds the client connection open while the engine switches over.

What's worth knowing before you adopt it:

  • It isn't free. It bills per vCPU of the database instance, and on small workloads it can cost more than simply sizing the database up.
  • Pinning will ruin the party. Long transactions, session-level prepared statements, SET on session variables or temp tables all make the proxy pin a connection to your session and stop multiplexing. You end up paying for a proxy that reuses nothing. It's visible in CloudWatch as DatabaseConnectionsCurrentlySessionPinned, and it's the first thing I check when someone tells me the proxy "did nothing".
  • It lives inside the VPC. Which means putting your Lambda in private subnets, with everything that drags along in cold starts and networking bills.

What I'd do today

The order matters, and almost nobody follows it:

  1. Hoist the pool out of the handler and set max: 1. Five minutes, zero cost, and it solves most cases outright.
  2. Set reserved_concurrent_executions on the functions that touch the database. Also free, and it turns a total outage into a bounded degradation.
  3. Measure before you buy. Watch DatabaseConnections in CloudWatch during a real spike. If you're nowhere near the ceiling, you don't need a proxy.
  4. Add RDS Proxy when traffic is genuinely spiky and you've confirmed your queries don't trigger pinning.

And one non-technical note: if 90% of your application is CRUD against PostgreSQL with reasonably steady traffic, the right answer probably isn't any of those four. It's a long-lived container with a normal, boring pool. Lambda is superb for bursty work. Against a relational database under sustained load, you're often paying in complexity to solve a problem you never had.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

Frontend

Loading states: that centered spinner is a product decision

AWS

CloudFront in front of your app: why your hit rate is 30%