Prisma Connection Pool Management
This guide is part of Framework Integration & Connection Lifecycle. Prisma is the most widely used ORM in TypeScript back ends, and it hides its connection pool more thoroughly than most: there is no pool object in application code, the pool size is set through query parameters on the database URL, and the defaults depend on the CPU count of the machine the process happens to run on. That makes Prisma services easy to start and surprisingly easy to misconfigure at scale. The same code that runs comfortably on a four-core laptop can open a very different number of connections on a 32-core production node, and a serverless deployment can open hundreds of pools against a database sized for ten.
This page explains where Prisma’s pool lives, how its parameters interact with interactive transactions and request concurrency, how to observe it, and how it behaves behind PgBouncer and other multiplexing proxies.
Key operational takeaways:
- Each
PrismaClientinstance owns its own pool. Creating a client per request, per module or per hot reload multiplies pools, and that is the most common cause of Prisma exhaustingmax_connections. - With the Rust query engine, the pool size defaults to
num_physical_cpus × 2 + 1and is overridden withconnection_limiton the URL. With a driver adapter such as@prisma/adapter-pg, the driver’s own pool settings apply instead and URL pool parameters are ignored. pool_timeout(default 10 seconds) is how long a query waits for a connection before failing withP2024. Interactive transactions add their ownmaxWaitandtimeout, and hold one connection for their entire duration.- Behind PgBouncer in transaction mode on versions before 1.21, add
pgbouncer=trueto disable prepared statements, and point migrations at a direct connection. - In serverless functions, set
connection_limit=1per instance and cap instance concurrency, or the platform’s scaling becomes the pool size.
Foundational mechanics
A PrismaClient is a thin JavaScript layer over an engine that builds SQL, executes it and maps results. In the classic architecture that engine is a Rust library loaded into the Node.js process, and it owns a connection pool implemented in Rust. When a query arrives, the engine asks the pool for a connection; if one is idle it is used immediately, if the pool is below its limit a new connection is opened, and otherwise the request waits up to pool_timeout seconds. The connection is returned as soon as the single query completes. Nothing in JavaScript holds a connection between queries, so a request handler that runs five sequential queries uses a connection five separate times, possibly five different connections.
Driver adapters change who owns the pool. With @prisma/adapter-pg or @prisma/adapter-neon, Prisma generates SQL and passes it to a JavaScript driver you construct — pg.Pool, for instance — and that driver’s pool decides everything: the maximum, idle timeouts and wait behaviour. Newer Prisma releases lean towards this architecture by default. The practical consequence is that the answer to “how big is my Prisma pool?” depends on how the client was constructed, and a connection_limit on the URL silently does nothing when an adapter is in use.
The pool is lazy. PrismaClient does not connect until the first query unless $connect() is called explicitly, and it opens connections one by one as concurrency requires, up to the limit. It does not shrink aggressively; idle connections stay open until the database or network closes them, which is why an idle Prisma service still shows its peak connection count in pg_stat_activity.
Interactive transactions are the exception to per-query borrowing. prisma.$transaction(async (tx) => { ... }) acquires one connection before running the callback and holds it until the callback resolves and the transaction commits. Any await inside the callback — an HTTP call, a cache lookup, a sleep — holds that connection idle. Batch transactions, prisma.$transaction([queryA, queryB]), send all statements together and hold the connection only for their execution.
Operational Boundary: Prisma’s pool governs connections from one process. It does not coordinate across processes, containers or function instances; that coordination belongs to a budget or to a proxy.
Precision sizing & timeout orchestration
Size connection_limit from the number of queries a single process runs at the same moment, not from request rate. Node.js is single-threaded, so concurrency comes from overlapping async work: a process handling 200 concurrent requests, each spending 15 % of its time inside a query, has on average 30 queries in flight. Queries beyond connection_limit queue inside the engine — which is fine if the queue drains well within pool_timeout.
| Parameter | Where it is set | Default | Recommended range | Operational purpose |
|---|---|---|---|---|
connection_limit |
database URL (Rust engine) | physical_cpus × 2 + 1 |
5–20 per long-lived process; 1 per serverless instance | Maximum connections per PrismaClient |
pool_timeout |
database URL | 10 s | 2–10 s, below the request deadline | Wait for a free connection before P2024 |
connect_timeout |
database URL | 5 s | 3–10 s | Time to establish a new database connection |
socket_timeout |
database URL | none | a little above the slowest expected query | Abort a query whose socket stops responding |
statement_cache_size |
database URL | 100 | 0 behind older transaction-mode proxies | Prepared statements cached per connection |
pgbouncer |
database URL | false | true behind PgBouncer before 1.21 |
Disable prepared statements for transaction pooling |
maxWait |
$transaction options |
2,000 ms | 1,000–5,000 ms | Wait to acquire a connection for an interactive transaction |
timeout |
$transaction options |
5,000 ms | longest legitimate transaction plus margin | Maximum lifetime of an interactive transaction |
max |
pg.Pool (driver adapter) |
10 | same reasoning as connection_limit |
Pool size when a driver adapter is used |
Order the deadlines from the inside out. A request with a 5-second upstream deadline should have pool_timeout well below 5 seconds, so a saturated pool fails with a clear P2024 rather than an upstream timeout that hides the cause. Interactive transaction maxWait should be shorter still, because a transaction that cannot start quickly is better retried or rejected than left queuing behind other transactions.
The default pool size is a trap on large machines. On a 48-core Kubernetes node, a container without CPU limits may report all 48 physical cores to the engine, giving connection_limit 97 per process. Always set connection_limit explicitly in production.
Production configuration examples
A single shared client for a long-lived Node.js service, with the pool made explicit in the URL:
# .env — Rust query engine
DATABASE_URL="postgresql://orders_app:${DB_PASSWORD}@orders-db.internal:5432/orders?schema=public&connection_limit=12&pool_timeout=3&connect_timeout=5&application_name=orders-api"
// src/db.ts — the only place a PrismaClient is constructed
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: [{ emit: "event", level: "warn" }, { emit: "event", level: "error" }],
});
// Development servers reload modules; reuse the client instead of opening a new pool each time
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
The same service using the PostgreSQL driver adapter, where the pool is configured on pg.Pool and URL pool parameters no longer apply:
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 12, // replaces connection_limit
idleTimeoutMillis: 60_000, // close idle connections after a minute
connectionTimeoutMillis: 3_000, // replaces pool_timeout for acquiring a connection
application_name: "orders-api",
});
export const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
An interactive transaction with explicit limits, and the external call moved outside it:
const charge = await paymentApi.charge(orderId, { idempotencyKey: orderId }); // no connection held
await prisma.$transaction(
async (tx) => {
const order = await tx.order.findUniqueOrThrow({ where: { id: orderId } });
await tx.order.update({ where: { id: order.id }, data: { status: "PAID", chargeId: charge.id } });
await tx.ledgerEntry.create({ data: { orderId, amount: order.total } });
},
{ maxWait: 2_000, timeout: 5_000, isolationLevel: "ReadCommitted" },
);
Graceful shutdown releases the pool so rolling deploys do not leave connections for the database to time out:
const shutdown = async () => {
server.close(); // stop accepting new requests
await prisma.$disconnect(); // close pool connections after in-flight queries finish
process.exit(0);
};
process.on("SIGTERM", shutdown);
Diagnostics & telemetry
Pool timeouts surface as PrismaClientKnownRequestError with code P2024, whose message includes the effective pool settings — the fastest way to confirm what configuration a running process actually has:
PrismaClientKnownRequestError: Timed out fetching a new connection from the connection pool.
(Current connection pool timeout: 10, connection limit: 97)
code: 'P2024'
A limit of 97 in that message, when you intended 12, means the URL parameter is not being applied — typically because the process reads a different environment variable, or because a driver adapter is in use. Interactive transactions that cannot start produce P2028, with a message that the transaction could not be started in the given time.
Pool metrics. On releases that include the metrics preview feature, the Rust engine exposes pool gauges through prisma.$metrics.json() and prisma.$metrics.prometheus(): prisma_pool_connections_open, prisma_pool_connections_busy, prisma_pool_connections_idle, prisma_client_queries_active, prisma_client_queries_wait, and the histogram prisma_client_queries_wait_histogram_ms. Expose them on the service’s metrics endpoint:
app.get("/metrics", async (_req, res) => {
res.type("text/plain").send(await prisma.$metrics.prometheus());
});
With a driver adapter, read the pool directly from the driver: pool.totalCount, pool.idleCount and pool.waitingCount on pg.Pool.
Database side. Setting application_name on the URL lets PostgreSQL attribute sessions to the service:
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE application_name LIKE 'orders-api%'
GROUP BY application_name, state;
Exhaustion versus starvation. If prisma_pool_connections_busy equals the limit and PostgreSQL shows those sessions active, queries are slow or concurrency is genuinely higher than the pool: exhaustion. If busy equals the limit but PostgreSQL shows the sessions idle in transaction, interactive transactions are holding connections while awaiting non-database work: starvation. The first needs a larger pool or faster queries; the second needs the awaits moved out of the transaction. More sessions in PostgreSQL than replicas × connection_limit means extra PrismaClient instances exist.
Integration & proxy compatibility
PgBouncer in transaction mode. Prisma’s Rust engine uses named prepared statements. PgBouncer before 1.21 cannot track them across server connections, and queries fail with errors such as prepared statement "s0" already exists. Add pgbouncer=true to the URL, which disables prepared statement caching in the engine. With PgBouncer 1.21 or later and max_prepared_statements set above zero, the flag is no longer required. The underlying mechanics are in Prepared Statement Caching Under Transaction Pooling.
Migrations need a direct connection. prisma migrate uses advisory locks and session-level behaviour that transaction pooling breaks. Declare a second URL that bypasses the proxy:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // through PgBouncer, port 6432, ?pgbouncer=true
directUrl = env("DIRECT_URL") // straight to PostgreSQL, used by migrate and introspection
}
Supavisor, Neon and other managed poolers follow the same rule: use the transaction-mode endpoint for the application with prepared statements disabled if the pooler requires it, and the direct or session-mode endpoint for migrations.
RDS Proxy. Prisma works behind RDS Proxy, but prepared statements and session-level SET commands can pin sessions, which erodes multiplexing. Watch DatabaseConnectionsCurrentlySessionPinned after deploying; the causes and remedies are in Resolving RDS Proxy Session Pinning.
Serverless platforms. Every function instance constructs its own client and pool. Set connection_limit=1, construct the client outside the handler so warm invocations reuse it, and bound instance concurrency. The full pattern is in Using Prisma with PgBouncer and Serverless Functions.
| Environment | connection_limit |
Extra URL parameters | Migrations |
|---|---|---|---|
| Long-lived Node.js service, direct | 5–20, from concurrency | pool_timeout, application_name |
same URL |
| Behind PgBouncer < 1.21, transaction mode | 5–20 | pgbouncer=true |
directUrl |
Behind PgBouncer ≥ 1.21 with max_prepared_statements |
5–20 | none required | directUrl |
| AWS Lambda or Cloud Functions | 1 | pool_timeout short, connect_timeout short |
run from CI, direct |
Driver adapter (pg.Pool) |
not used | set max on the pool |
direct URL |
Common failure patterns & remediation
| Symptom | Root cause | Exact fix | Validation command |
|---|---|---|---|
P2024 with “connection limit: 97” in the message |
Default limit derived from a large node’s CPU count | Set connection_limit explicitly in the URL |
SELECT count(*) FROM pg_stat_activity WHERE application_name='orders-api'; |
| Connections grow on every hot reload in development | New PrismaClient per module reload |
Cache the client on globalThis outside production |
Reload twice, re-run the count above |
P2028 during checkout traffic |
External calls awaited inside $transaction |
Move the awaits outside; lower timeout |
SELECT count(*) FROM pg_stat_activity WHERE state='idle in transaction'; |
prepared statement "s0" already exists |
Transaction-mode PgBouncer before 1.21 | Add pgbouncer=true or upgrade and set max_prepared_statements |
SHOW VERSION; on the PgBouncer console |
prisma migrate deploy hangs or fails behind a pooler |
Advisory lock and session state over transaction pooling | Add directUrl pointing at PostgreSQL |
npx prisma migrate status |
too many clients already after a serverless traffic spike |
One pool per function instance at default limit | connection_limit=1, cap concurrency, or use a proxy |
Concurrency metric against max_connections |
Read replicas, multiple clients and connection budgets
Larger Prisma deployments rarely have a single client. Reads go to replicas, analytics use a second schema, and background workers run beside the API. Each of those is a separate pool, and the budget has to account for all of them.
Read replicas. The read-replicas extension routes read queries to replica URLs and keeps writes on the primary. Each replica URL is a separate datasource with its own pool, and connection_limit is read from each URL:
import { PrismaClient } from "@prisma/client";
import { readReplicas } from "@prisma/extension-read-replicas";
export const prisma = new PrismaClient().$extends(
readReplicas({
url: [
process.env.REPLICA_A_URL!, // ...?connection_limit=8&pool_timeout=3
process.env.REPLICA_B_URL!, // ...?connection_limit=8&pool_timeout=3
],
}),
);
// Reads are routed to a replica automatically; force the primary for read-your-writes
const order = await prisma.$primary().order.findUnique({ where: { id } });
A process with a primary limit of 12 and two replicas at 8 holds up to 28 connections: 12 on the primary and 8 on each replica. Budget each database separately; a replica’s max_connections must accommodate every process’s replica pool.
Several clients in one process. A service that talks to two databases, or to one database with two roles, constructs two clients. Both must be singletons, and their limits add up against whichever servers they reach. Name them in application_name so PostgreSQL can tell the pools apart.
Workers beside the API. Background workers — BullMQ processors, cron containers, queue consumers — construct their own clients. Their concurrency is set by the worker framework, not by HTTP traffic, so size their connection_limit from worker concurrency: a BullMQ worker with concurrency: 10 that runs one query at a time per job needs about 10 connections, not the API’s 12.
| Component | Pools per process | Size from | Budget against |
|---|---|---|---|
| API process, primary | 1 | concurrent queries per process | primary max_connections |
| API process, each replica | 1 per replica URL | concurrent reads per process | that replica’s max_connections |
| Worker process | 1 per client | worker concurrency | primary max_connections |
| Serverless function instance | 1 | 1 | proxy client limit or primary |
Fleet total. The number the database sees is the sum over every component of processes × connection_limit. A single spreadsheet row per component, with autoscaling maximums rather than current counts, is enough to see whether a deploy or scale-out can exceed the server’s slots. The general method, including rollout surge, is in Calculating a Connection Budget Across Microservices.
Testing pool behaviour before production
Pool problems in Prisma services are cheap to find in CI and expensive to find in production, because they only appear under concurrency.
Assert the effective limit. An integration test can open more concurrent queries than the configured limit and check that PostgreSQL never sees more sessions than expected:
test("pool never exceeds connection_limit", async () => {
const sleeps = Array.from({ length: 40 }, () => prisma.$queryRaw`SELECT pg_sleep(0.2)`);
const sampler = (async () => {
let max = 0;
for (let i = 0; i < 10; i++) {
const [{ count }] = await admin.$queryRaw<{ count: bigint }[]>`
SELECT count(*) FROM pg_stat_activity WHERE application_name = 'orders-api-test'`;
max = Math.max(max, Number(count));
await new Promise((r) => setTimeout(r, 50));
}
return max;
})();
await Promise.all(sleeps);
expect(await sampler).toBeLessThanOrEqual(12);
});
Assert transactions release connections. Run an interactive transaction that throws inside the callback, then check the pool still serves connection_limit concurrent queries. A failure means an error path is holding a connection.
Assert timeouts fail fast. With connection_limit=1 in a test URL, start one long query and issue a second; the second must reject with P2024 within pool_timeout plus a small margin. This catches deployments where pool_timeout was lost from the URL.
These three tests take seconds against a local PostgreSQL container and cover the misconfigurations that account for most Prisma pool incidents.
Frequently Asked Questions
Should I call $connect() at startup?
Does connection_limit apply per PrismaClient or per database?
max_connections.Why does my service keep its peak connection count when idle?
idleTimeoutMillis, or rely on PgBouncer’s server_idle_timeout between Prisma and PostgreSQL.Is a batch $transaction([...]) better than an interactive one for pool usage?
Can Prisma share a pool between two clients?
pg.Pool, which then bounds their combined connections — useful when a process needs clients with different extensions against the same database.Step-by-Step Guides
Each guide below applies this topic to one concrete incident or configuration task.
- Managing Prisma Connections in Next.js Applications — stop hot reload and per-route clients multiplying Prisma pools in Next.js, size for serverless and long-running deployments, and keep edge routes off direct connections.
- Tuning Prisma connection_limit and pool_timeout — resolve Prisma P2024 pool timeouts: read the effective limits from the error, size connection_limit from in-flight queries, and order pool_timeout below deadlines.
- Using Prisma Driver Adapters with node-postgres Pools — move Prisma’s pool into pg.Pool with a driver adapter, map connection_limit onto max, and keep metrics, timeouts and proxy behaviour under your own control.
- Using Prisma with PgBouncer and Serverless Functions — keep Prisma inside a PostgreSQL connection budget on Lambda and other function platforms: connection_limit=1, pgbouncer=true, directUrl and concurrency caps.
Related
- Framework Integration & Connection Lifecycle — the parent overview of how frameworks map requests onto pooled connections.
- Tuning Prisma connection_limit and pool_timeout — resolving
P2024in a long-lived service. - Using Prisma with PgBouncer and Serverless Functions — pool settings for Lambda, Cloud Functions and transaction-mode proxies.
- Express.js Connection Pool Middleware — the node-postgres pool that a Prisma driver adapter uses.
- Node.js Async Connection Limits — why concurrency in Node.js is unbounded by default and how that meets a fixed pool.