Using Prisma Driver Adapters with node-postgres Pools
This guide is part of Prisma Connection Pool Management. A driver adapter replaces Prisma’s built-in pool with a JavaScript driver you construct yourself — pg for PostgreSQL, @planetscale/database or mysql2 for MySQL, @libsql/client for SQLite-compatible targets. The pool then belongs to the application, which is exactly what teams want when they already have pool metrics, a tuned configuration and a proxy in the path. It also means the URL parameters that used to control pooling stop doing anything, and a service that switches to an adapter without changing its configuration silently gets the driver’s defaults:
# connection_limit=12 in the URL, but with an adapter the pool is pg's default of 10
PrismaClientKnownRequestError: Timed out fetching a new connection from the connection pool.
The objective is a configuration where the adapter’s pool is sized deliberately, its timeouts are ordered inside the request deadline, its metrics are exported, and its behaviour behind a pooler is understood.
Rapid incident diagnosis
Establish which pool is in use. The client’s construction says it directly:
grep -rn "new PrismaClient(" --include=*.ts --include=*.js src/ | head
grep -rn "PrismaPg\|@prisma/adapter-" --include=*.ts --include=*.js src/ | head
An adapter: argument means the driver’s pool is in charge and URL parameters such as connection_limit and pool_timeout are inert.
Read the driver’s pool state. pg.Pool exposes it synchronously, which makes a debug endpoint trivial:
app.get("/debug/pool", (_req, res) => {
res.json({ total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount, max: pool.options.max });
});
waitingCount above zero means requests are queued for a connection; totalCount at max with idleCount zero means the pool is saturated.
Compare with the database’s view. With application_name set on the pool, the server attributes the sessions:
SELECT state, count(*) FROM pg_stat_activity WHERE application_name = 'orders-api' GROUP BY state;
Sessions well below replicas × max mean the pool is not the constraint; sessions at that number with waitingCount positive mean it is.
Check which errors you are getting. With an adapter, some failures now come from the driver rather than from Prisma:
| Error | Source | Meaning |
|---|---|---|
P2024 |
Prisma | Timed out waiting for a connection from the adapter’s pool |
Connection terminated due to connection timeout |
pg |
connectionTimeoutMillis expired while connecting |
timeout exceeded when trying to connect |
pg |
Same, older message |
Connection terminated unexpectedly |
pg |
The connection died, often an idle-timeout drop |
P1001 |
Prisma | Could not reach the database at all |
Mathematical sizing or parameter formula
The sizing arithmetic does not change; only the setting names do.
max = ceil( requests_per_second × queries_per_request × mean_query_seconds ÷ 0.7 )
fleet = replicas × workers × max ≤ usable_connections_for_this_service
At 400 requests per second per process, 3 queries of 5 ms each: 400 × 3 × 0.005 = 6 in flight, so max = 9.
Mapping the old settings:
| Prisma URL parameter | pg.Pool option |
Note |
|---|---|---|
connection_limit |
max |
Same meaning |
pool_timeout (seconds) |
connectionTimeoutMillis |
Milliseconds; covers waiting for a free connection and connecting |
connect_timeout |
connectionTimeoutMillis |
The driver merges both into one |
| — | idleTimeoutMillis |
No Prisma equivalent; important behind NAT |
| — | maxLifetimeSeconds |
Available in recent pg; useful for rebalancing |
statement_cache_size |
statement_timeout, prepared statement behaviour |
Driver-specific |
Timeout ordering. With one option covering both waiting and connecting, set it from the request deadline and let the connect timeout be implied:
connectionTimeoutMillis ≤ request_deadline − p99_query_time − application_work − margin
idleTimeoutMillis < shortest network idle timeout
With a 3-second deadline, 40 ms of query time and 200 ms of other work: 2,000 ms is a safe wait, and 120,000 ms suits an environment behind a 350-second NAT idle timeout.
Minimum connections. pg.Pool has no minimum; it opens connections on demand and closes them after idleTimeoutMillis. Where cold-start latency matters, warm the pool at startup:
warm_connections ≈ steady-state in-flight count, opened once at boot
Exact remediation & configuration
Construct the pool and the client once:
// src/db.ts
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 9,
idleTimeoutMillis: 120_000,
connectionTimeoutMillis: 2_000,
maxLifetimeSeconds: 1_800,
application_name: "orders-api",
keepAlive: true,
statement_timeout: 2_000, // server-side guard, applied per connection
});
pool.on("error", (err) => {
// An idle client errored; pg removes it from the pool. Unhandled, this crashes the process.
console.error({ err }, "idle client error");
});
const adapter = new PrismaPg(pool);
export const prisma = new PrismaClient({ adapter });
Warm the pool at startup so the first requests do not pay connection establishment:
export async function warm(n = 3) {
const clients = await Promise.all(Array.from({ length: n }, () => pool.connect()));
clients.forEach((c) => c.release());
}
Export the pool’s metrics. This is one of the main benefits of the adapter, because pg.Pool’s counters are plain numbers:
import { Gauge } from "prom-client";
const total = new Gauge({ name: "pg_pool_total", help: "Connections in the pool" });
const idle = new Gauge({ name: "pg_pool_idle", help: "Idle connections" });
const waiting = new Gauge({ name: "pg_pool_waiting", help: "Requests waiting for a connection" });
setInterval(() => {
total.set(pool.totalCount);
idle.set(pool.idleCount);
waiting.set(pool.waitingCount);
}, 10_000).unref();
Shut down in the right order — Prisma first, then the pool:
async function shutdown() {
server.close();
await prisma.$disconnect();
await pool.end();
process.exit(0);
}
process.on("SIGTERM", shutdown);
Behind a transaction-mode pooler, disable prepared statements at the driver level rather than with Prisma’s pgbouncer=true, which does not apply when an adapter owns the connection:
export const pool = new Pool({
connectionString: process.env.DATABASE_URL, // points at PgBouncer
max: 9,
// node-postgres does not use named prepared statements unless the query specifies `name`,
// so no extra setting is needed; avoid passing `name` in raw queries.
});
Prisma’s generated queries through the adapter use parameterised statements without names, which is compatible with transaction pooling; the caution applies to hand-written pool.query({ name: ... }) calls elsewhere in the codebase.
Deciding whether the adapter is worth it
The adapter is not automatically better; it moves responsibility.
Reasons to adopt it. The pool’s counters become available for metrics and alerts. Idle timeouts and lifetimes become configurable, which matters behind NAT gateways and for replica rebalancing. The same pg.Pool can be shared with code that does not use Prisma — a raw query for a report, a LISTEN consumer on its own client — so the service has one pool rather than two. And some deployment targets require it, because they provide their own driver.
Reasons to stay with the built-in pool. One fewer dependency and one fewer object to manage. The engine’s pool is tuned for Prisma’s own access pattern, and connection_limit is a single knob that most services never need to move beyond. Teams without pool dashboards gain little from metrics they will not watch.
Mixed usage is where the adapter earns its place. A service that runs Prisma for most queries and raw SQL for a few — a bulk COPY, a recursive CTE, a LISTEN subscription — otherwise holds two pools with two budgets. Sharing one pg.Pool halves the connection footprint and makes the budget arithmetic honest:
// The same pool serves both APIs
const rows = await pool.query("SELECT * FROM mv_daily_totals WHERE day = $1", [day]);
const order = await prisma.order.findUnique({ where: { id } });
| Consideration | Built-in pool | Driver adapter |
|---|---|---|
| Pool metrics | preview feature only | plain counters, always available |
| Idle timeout and lifetime | not configurable | configurable |
| Sharing with raw SQL | separate pool needed | one pool serves both |
| Configuration surface | one URL | driver options plus URL |
| Dependencies | none extra | pg and the adapter package |
| Behind a transaction pooler | pgbouncer=true flag |
driver defaults are already compatible |
Validation & verification
The configured maximum is in effect. Log pool.options.max at startup and confirm the database’s session count matches replicas × max at peak.
Waiting is visible and bounded. Under a load test that saturates the pool, pool.waitingCount must rise and requests must fail with P2024 within connectionTimeoutMillis, not hang.
Idle connections are released. Stop traffic and confirm the session count falls towards zero within idleTimeoutMillis, which is behaviour the built-in pool does not provide.
Errors do not crash the process. Terminate an idle backend on the server and confirm the error handler logs it and the service continues:
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE application_name = 'orders-api' AND state = 'idle' LIMIT 1;
Shutdown drains. After SIGTERM, the service’s sessions should disappear within a second or two, which proves both $disconnect() and pool.end() ran in order.
Frequently Asked Questions
Do URL parameters still matter with an adapter?
connection_limit, pool_timeout — are ignored. Keep them out of the URL to avoid implying otherwise.Can I use an adapter with Prisma Migrate?
directUrl configured as before.Does the adapter change query behaviour?
Is there a performance difference?
Related
- Prisma Connection Pool Management — the parent guide to Prisma’s pool architecture.
- Tuning Prisma connection_limit and pool_timeout — sizing with the built-in pool.
- Tuning node-postgres Pool Timeout Settings — the driver options in depth.
- Handling node-postgres Pool Errors and Reconnection — why the
errorhandler matters.