Prisma P2024 Timed Out Fetching a New Connection: When connection_limit Meets Coolify and Serverless
After a Coolify redeploy a Next.js plus Bun worker stack started throwing intermittent Prisma P2024 under modest traffic. Bumping connection_limit or new PrismaClient() per request made it worse. This is the field note for one client per process, PgBouncer transaction versus session mode, pool math across replicas, and a healthcheck that does not leak a connection on every probe.
Prisma P2024 Timed Out Fetching a New Connection: When connection_limit Meets Coolify and Serverless
The most expensive database error is the one that looks like load. We shipped a Coolify redeploy on a quiet afternoon, watched the new web container come up green, and ten minutes later the first PrismaClientKnownRequestError landed in the logs: P2024, Timed out fetching a new connection from the connection pool. Traffic was modest. Postgres CPU was bored. The dashboard charts we actually look at — query time, disk, replication lag — did not move. Then someone bumped connection_limit=10 on DATABASE_URL because the Prisma docs mention the parameter in the same paragraph as the error, and the timeouts got denser. A second pass created a new PrismaClient() inside the request helper "so serverless would not share state." That made it worse again. This is the writeup of Prisma P2024 when connection_limit meets Coolify and serverless — one client per process versus one per invocation, PgBouncer transaction mode versus session mode, pool math across N replicas, and the rolling-deploy window where old and new containers both hold a full pool.
The stack is the one we already split once. A Next.js App Router process and a Bun worker, both talking to one Postgres, wired over pg_notify the way the worker-split note described. We hit the same shape on products like Acurio: a long-lived Coolify pair most of the week, and the occasional Vercel-shaped serverless path that looks identical in the Prisma client and is not. The error string does not tell you which of those you are in. The naive fix does not ask.
The Timeout That Is Not Postgres Saying No
P2024 is a client-side wait. Prisma's query engine keeps its own pool. A query that cannot check out a connection from that pool within pool_timeout (ten seconds unless you changed it) throws P2024. Postgres never saw the query. There is no row lock, no slow sequential scan, no "too many connections" in the Postgres log for that request. The process sat on Prisma's internal queue and gave up.
That is why the dashboards lie. pg_stat_activity can look busy — many idle backends from pools that already opened — and still have room under max_connections. Or it can be one slot short and the app still throws P2024, because Prisma will not open connection number connection_limit + 1 even if Postgres would have accepted it. Two ceilings. One error string. People treat the string as "the database is overloaded" and turn the client ceiling up.
The default connection_limit is num_physical_cpus * 2 + 1. On a Coolify box that reports four CPUs, that is already nine connections per PrismaClient. On a Vercel function the CPU count is a fiction that still produces a real TCP pool. Default pool_timeout of ten seconds is long enough for a user-facing request to feel broken and short enough that a burst trips it before you have a useful pg_stat_activity sample.
A cousin error — P1001 / P1017 / Postgres too many clients already — is the server refusing a new backend. Grep only for P2024 and you miss the hour after a deploy where the two trade places. Log both, plus pg_stat_activity by application_name and state. P2024 alone is not a capacity graph.
The Two Fixes That Multiply the Pool
The first naive fix is a query parameter.
# looks like a throttle. is a floor per process.
DATABASE_URL="postgresql://app:…@db:5432/app?connection_limit=10"
connection_limit is the size of this process's Prisma pool. It is not a cluster-wide cap. One Next.js container plus one Bun worker is two clients. A Coolify rolling deploy that keeps the old container up until the new one passes healthchecks is four. A preview app, a CI prisma migrate, and a laptop on the same instance are more. Ten times four is forty backends before anyone has done anything clever. A small Coolify Postgres — or a hosted plan whose max_connections is 60 or 100, with slots reserved for superuser and autovacuum — does not have forty idle backends to spare. Raising the number because "we timed out fetching a connection" is how you turn a client-queue timeout into too many clients the next time two deploys overlap.
The second naive fix is a constructor.
// request helper, "so we don't share state"
export async function getUser(id: string) {
const prisma = new PrismaClient();
try {
return await prisma.user.findUnique({ where: { id } });
} finally {
// often forgotten; even when present, you paid the handshake
await prisma.$disconnect();
}
}
Each PrismaClient owns a pool. In a long-lived Next.js or Bun process that is not "a connection." It is connection_limit connections, opened lazily and held until $disconnect or process death. Construct on every request and forget $disconnect, and you leak a pool per request until Postgres refuses the next handshake. Disconnect in a finally and you still pay TLS plus auth on the hot path, with N overlapping clients each opening their own small pool. The serverless folklore — "do not share a client across invocations" — was about frozen isolates and leaked listeners, not a new engine on every HTTP hit in a warm Node process.
We have seen both in the same PR: connection_limit=10 "to be safe," and a fresh client in the webhook handler "because Stripe retries." The webhook claim is the right instinct for the Event. It is the wrong instinct for the database handle. Claim the Event once. Claim the client once per process.
One PrismaClient Per Process — and What "Process" Means
The rule that survives is boring: one PrismaClient per Node or Bun process, stored on globalThis so Next.js hot reload does not allocate a second engine in development. Production Coolify does not hot-reload, but the same module is what you ship, and a second import graph that constructs its own client is how you silently run two pools in one container.
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
In production you still want the singleton; you just do not need the globalThis guard for HMR. What you do need is a single import path. instrumentation.ts that constructs a client for migrations, a lib/db.ts that constructs another, and a worker file that constructs a third — in one container — is three pools. Web and worker should each have one client. They should not share a module-level Prisma client with a raw pg LISTEN, and they should not each construct Prisma twice "for isolation."
Serverless changes the noun, not the rule. A warm Vercel isolate should reuse one PrismaClient. A brand-new isolate should construct one and die with it — or, better, not hold a pool of Postgres TCP connections at all. The failure mode is N warm isolates × connection_limit. Twenty concurrent isolates with a default-ish limit of ten is two hundred backends aimed at a database sized for one Coolify box. That is not a product traffic spike. It is a fan-out of pools. The cold-start warmup note already named the other half: a scale-from-zero path that looks healthy because the expensive thing happened on a different isolate than the one you are watching. Here the expensive thing is the pool the isolate opened and will hold until it is frozen or killed.
If the path is truly serverless — short-lived, highly concurrent, no sticky process — the Prisma-shaped answer is a serverless driver or a pooler in front, with connection_limit of 1–3 per isolate, not 10. Copying the Coolify DATABASE_URL onto a Vercel project without changing the limit is how a Friday preview deploy knocks out production Postgres.
PgBouncer Transaction Mode Versus Session Mode
PgBouncer is the other place people paste a URL and think they have solved pooling. They have added a second pool, with a mode that Prisma may not survive.
Session mode assigns a server connection for the life of the client connection. Prisma opens connection_limit client connections and PgBouncer holds connection_limit server connections for them. You have not reduced peak backends. You have added a hop. Prepared statements work. LISTEN / NOTIFY work. The rolling-deploy overlap still doubles the server-side count. Session mode is a proxy, not a multiplexor.
Transaction mode returns the server connection at transaction end. Many Prisma clients can share a much smaller set of Postgres backends. This is the mode you actually wanted when you said "we put PgBouncer in front." It breaks two things Prisma does by default.
Prisma's query engine uses prepared statements. In transaction mode those statements do not survive the return of the server connection; the next checkout may be a different backend that has never seen S_1. The symptom is not always P2024. It is prepared statement "s0" does not exist, intermittent, worse after a pool squeeze. The documented Prisma escape is the URL flag:
# transaction-mode pooler. disables Prisma prepared statements.
DATABASE_URL="postgresql://app:…@pgbouncer:6432/app?pgbouncer=true&connection_limit=5&pool_timeout=10"
pgbouncer=true is not "I have PgBouncer." It is "do not use prepared statements, because the backend will change under me." Pointing Prisma at a transaction-mode pooler without that flag is a second incident, usually opened as "Prisma is flaky after we added the pooler."
The other break is session-level state. The Bun worker's LISTEN acurio_task_kick client from the pg_notify split cannot live on a transaction-mode pool. LISTEN is session-scoped; the next transaction's backend will not be subscribed. That connection must go around PgBouncer, or through a session-mode pool, or be a direct pg Client with its own one-connection budget. Mixing it into the Prisma DATABASE_URL is how notify silently dies after you "fixed pooling."
Coolify makes the URL confusion cheap. The Postgres service URL and the PgBouncer service URL look the same except for host and port. Teams copy connection_limit onto both. Four app processes × five Prisma clients can fit a default_pool_size of 20 — until a fifth process appears during a deploy and PgBouncer starts queueing, which Prisma reports as P2024 because its checkout from the pooler timed out. Same error string. Different queue.
Statement mode is the third PgBouncer mode and the one Prisma should not see. One statement, then the server connection goes back. No multi-statement transaction, no LISTEN. If your pooler is in statement mode, the fix is a different database URL, not a Prisma flag.
The Rolling Deploy That Holds Two Pools
The Coolify case that produced our first dense P2024 burst was not traffic. It was overlap.
A rolling deploy starts the new container, waits for a healthcheck, then stops the old one. During that window both containers are alive. Each has a Prisma singleton. Each singleton has already warmed connection_limit backends — or will, the moment the first request or worker poll runs. For a web-plus-worker pair that is four pools. If you sized connection_limit so two processes sit just under max_connections, the overlap is the overage. We hit P2024 on the new web container while the old worker still held a full idle pool that was not serving anyone.
SIGTERM handling is part of the budget. The worker-split writeup already drains in-flight jobs. The pool must drain too. A process that ignores SIGTERM until the kill timeout keeps its backends until Postgres sees the TCP reset. A process that $disconnect()s the singleton in the signal handler returns them in time for the new container to open its own. Without that, "rolling" is "two full allocations plus a corpse."
function shutdown(signal: string) {
console.info("shutdown", { signal });
void prisma.$disconnect().finally(() => process.exit(0));
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Healthchecks sit in the same window and are the leak that survives a correct singleton. Coolify probes /api/health every few seconds. If that route uses the process Prisma client and runs SELECT 1, it is one more checkout of an existing pool — fine, almost free, and it keeps the pool warm the same way a warmup ping keeps a model loaded. If the healthcheck is a separate command — bun run healthcheck.ts as a container test that does new PrismaClient(), queries, and exits without $disconnect — each probe is a short-lived process that may leave a backend in idle until the server's timeout. A probe every five seconds plus a 60-second backend idle timeout is a rolling leak. We have seen a "healthy" service eat a dozen backends on the probe alone while user requests threw P2024.
The healthcheck we want hits an HTTP route that already shares the singleton, opens pg_isready without Prisma, or constructs a client, queries, and $disconnects in the same process lifetime. What it must not do is open a connection "just to be sure" and assume process exit is an instant backend teardown. The backstop-sweep note already treated a Coolify overlap as two writers on one row. Treat it as two pools on one max_connections as well.
The Checklist We Run After P2024
Five checks, in this order, before anyone is allowed to raise connection_limit.
Count the processes that will hold a PrismaClient. Web replicas, worker replicas, CI migrate jobs, preview apps pointed at the same database, and the extra set that exists for the length of a rolling deploy. Serverless: peak concurrent isolates, not "one function." Write the number down. A LISTEN client and a prisma migrate session are extra backends that do not appear in connection_limit.
Do the arithmetic against the real ceiling.
processes_during_overlap × connection_limit
+ listen_clients
+ migrate_and_admin
+ leaky_healthcheck_budget
≤ usable_postgres_backends
usable is not max_connections. It is max_connections minus superuser reserve minus autovacuum minus pooler overhead. If PgBouncer is in transaction mode, the Postgres-side number is its pool_size, and the Prisma-side number is what you may open toward the pooler. Two inequalities. Both have to hold. We size a Coolify pair at connection_limit=5 on web and worker, assume overlap so ten Prisma backends plus one LISTEN plus two admin, and keep a hosted max_connections of 60 from seeing more than about twenty app backends. If the math does not fit, you cut connection_limit or you add a transaction-mode pooler — you do not raise the client limit because P2024 sounded like starvation.
One singleton per process, one import path. lib/db.ts exports prisma. Nothing else calls new PrismaClient(). The worker imports the same helper in its own process. $disconnect belongs in process shutdown, not in a finally on every query.
Name the PgBouncer mode in the URL you actually use. Direct Postgres: no pgbouncer=true. Transaction-mode pooler: pgbouncer=true, smaller connection_limit, and a separate direct or session-mode URL for LISTEN. Session-mode pooler: treat it as direct for capacity. If you cannot say which mode the host:port is, you are not ready to set connection_limit.
Prove the healthcheck does not own a pool. If it is HTTP against the running process, the route should use the singleton or skip Postgres. If it is a one-shot script, it must $disconnect(), and pg_stat_activity idle must not track probe frequency. SIGTERM must $disconnect the singleton so the overlap is two live pools, not two live plus a dying one. On a serverless path: connection_limit=1 or 2, a transaction pooler, or a serverless driver — not a Coolify-sized pool.
Three Rules That Survive the Next Hosting Panel
Three rules survive this writeup and generalise past Coolify's rolling flag and whatever Vercel calls an isolate this year.
P2024 is Prisma waiting on Prisma, not Postgres refusing you. The ceiling is connection_limit and pool_timeout in this process. Raising connection_limit without recounting processes — including the dead ones still in the deploy — moves the failure to max_connections or the pooler queue, where the string may stay P2024. Read pg_stat_activity by application and state before you edit the URL.
A pool is per process, a process is per container or isolate, and a deploy is two of each. The singleton fixes "new client per request." It is not a cluster-wide cap. Overlap, replicas, and serverless concurrency all multiply the number in DATABASE_URL. Size for the overlap you actually run, or drain so the overlap does not hold a pool.
PgBouncer mode is a contract with Prisma, not a checkbox. Transaction mode needs pgbouncer=true and cannot carry LISTEN. Session mode will not shrink backends. Statement mode is the wrong URL. The worker that subscribes to pg_notify and the request path that runs findUnique are allowed to disagree about the URL. They are not allowed to share the wrong one.
The composition is the note. We treated P2024 as load because the message says "timed out fetching a connection" and the first knob in the same sentence is connection_limit. Production was a rolling pair of containers, each with a default-sized engine pool, plus a healthcheck script that constructed a client of its own. One singleton, a budget written as multiplication, a pooler mode we could name, and a probe that does not leak would have shown the hole in the first minute — the same minute we spent raising the limit.
If you are seeing intermittent P2024 after a Coolify deploy or on a Vercel path that shares the production database — book a free AI Potenzial-Check. The worker-split writeup is the process topology this pool has to fit; the Stripe claim is the reminder that the handler may run twice in that overlap and still must share one client.
acurio · Hallucinated citations? Not in your manuscript.
Citation checker for Zotero. Finds hallucinated or partially supported sources in AI‑written text. Thesis packages from CHF 19, Swiss data processing.