Connection pool exhaustion: the resilience bug that only shows up under load
By Seaworthy · 4 August 2026
Everything works in development. One browser tab, one developer, requests arrive one at a time with seconds of think-time between them. Then it ships, real traffic hits it, and within a few minutes every request starts hanging, not erroring, not crashing, just sitting there until something times out thirty seconds later. Nothing in the logs points at the actual cause, because the actual cause isn't a bug in any single request. It's that the app ran out of database connections, and every request after that is queued behind ones that will never finish.
The pattern that causes it
Database clients, pg.Pool, Prisma, Mongoose, whatever the stack, are meant to be created once and reused for the lifetime of the process. A pool holds a fixed number of open connections and hands them out to queries as needed, returning them to the pool when a query finishes. That's the entire point of a pool: opening a TCP connection and completing a database handshake is expensive, so you do it a handful of times and reuse the result, not once per request.
The bug shows up when a client gets instantiated inside the request handler instead of at module scope:
// Bad: a new pool on every single request
app.get("/api/orders", async (req, res) => {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const result = await pool.query("SELECT * FROM orders WHERE user_id = $1", [req.user.id]);
res.json(result.rows);
});
This works perfectly in manual testing, because manual testing rarely has two requests in flight at once. Each request gets its own pool, opens its own connection, runs its query, and the pool is (hopefully) closed or garbage-collected afterward. The bug is invisible until concurrency shows up. Under real traffic, dozens of requests arrive close together, each spinning up its own pool with its own default connection limit, and the actual Postgres server-side cap (max_connections, commonly 100 by default) gets exhausted by connections that individual request handlers opened and never explicitly closed. Once that cap is hit, the next connection attempt, from any request, anywhere in the app, doesn't fail fast. It queues, waiting for a connection to free up, and if nothing frees up, it waits until the request's own timeout finally kills it.
This is an easy pattern for an AI coding assistant to produce, specifically because each generated route handler is usually written and reviewed in isolation. Asked to "add an endpoint that queries orders," the natural, self-contained, obviously-correct-looking answer includes setting up its own database client right there in the function. Nothing about that one handler looks wrong. The bug only exists in aggregate, across every handler that repeats the same pattern.
Serverless makes the failure mode worse, not better
If the app runs on a serverless platform such as Lambda, Vercel functions, or Cloudflare Workers with a non-pooled driver, the problem compounds. Each concurrent invocation can be a separate execution environment, and if each one creates its own pool (even a correctly module-scoped one, reused only within that single invocation's lifetime), traffic that spikes to a hundred concurrent invocations can mean a hundred separate pools each holding their own handful of connections, easily exceeding what the database can accept. This is exactly why managed Postgres providers built for serverless (Neon, Supabase, RDS Proxy, PgBouncer in front of a normal Postgres instance) offer connection pooling as an infrastructure-level feature: the application-level pool pattern that works for a single long-running server process doesn't scale the same way across many short-lived, independently-scaled instances.
The fix
Two separate things need to be true: the client has to be a genuine singleton within its process, and the pool needs an explicit size and timeout instead of silently accepting the library default.
// db.js, created once, imported everywhere
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // explicit ceiling, not a guess
idleTimeoutMillis: 30_000, // release idle connections back
connectionTimeoutMillis: 5_000, // fail fast instead of hanging forever
});
// route handler, imports the singleton, never constructs its own
import { pool } from "../db.js";
app.get("/api/orders", async (req, res) => {
const result = await pool.query("SELECT * FROM orders WHERE user_id = $1", [req.user.id]);
res.json(result.rows);
});
The connectionTimeoutMillis setting matters as much as the singleton fix does. Without it, a pool that's genuinely out of capacity doesn't reject new work, it queues indefinitely, and "the database is temporarily out of connections" turns into "every request hangs until the client or load balancer times out," which is a far worse failure mode for anyone actually using the app than a fast, explicit error would be. Set a connection timeout, and a saturated pool fails loudly and immediately instead of quietly taking the whole request pipeline down with it.
For serverless deployments specifically, the module-scope singleton pattern still applies within a single invocation's lifetime, but pair it with a connection pooler at the infrastructure layer (PgBouncer, RDS Proxy, or your provider's built-in serverless pooling) rather than assuming an application-level pool alone will hold up under horizontally-scaled concurrency.
Seaworthy's resilience checks flag database client construction found inside request handlers rather than module scope, and pool configuration that's missing an explicit size limit or connection timeout, the two gaps that turn a quiet capacity problem into a full outage the first time real traffic shows up.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.