Configuring Knex Connection Pools in Express
This guide is part of Express.js Connection Pool Middleware. Knex wraps a driver — pg, mysql2, tedious — and manages connections with tarn, its own pool. That means the driver’s pool settings do nothing: max belongs to Knex, not to pg. The default pool is { min: 2, max: 10 }, and when it is exhausted Knex produces a message that is specific enough to be actionable and vague enough to be misread:
Knex: Timeout acquiring a connection. The pool is probably full.
Are you missing a .transacting(trx) call?
TimeoutError: Knex: Timeout acquiring a connection. The pool is probably full.
at Client_PG.acquireConnection (/app/node_modules/knex/lib/client.js:312:26)
The hint about .transacting(trx) is there because the most common cause is a query inside a transaction that was issued on the base Knex instance instead of the transaction object: it waits for a connection the transaction is holding, and the transaction waits for that query — a self-deadlock that empties the pool.
The objective is a pool sized from real concurrency, an acquisition timeout inside the request deadline, transactions that cannot deadlock against themselves, and a min that does not hold connections the fleet cannot afford.
Rapid incident diagnosis
Read the pool’s live state. Knex exposes tarn’s counters:
app.get("/debug/pool", (_req, res) => {
const p = knex.client.pool;
res.json({
used: p.numUsed(), free: p.numFree(),
pendingAcquires: p.numPendingAcquires(), pendingCreates: p.numPendingCreates(),
min: p.min, max: p.max,
});
});
numUsed() at max with numPendingAcquires() climbing is saturation. numPendingCreates() persistently above zero means connections are slow to establish rather than busy.
Look for the self-deadlock first. It produces the same timeout but at low traffic, often on one endpoint:
grep -rn "\.transaction(" --include=*.js --include=*.ts src/ | head
grep -rn "knex(" --include=*.js --include=*.ts src/ | grep -v "trx(" | head
Inside a knex.transaction(async trx => { ... }) block, every query must use trx, or .transacting(trx). A query on the outer knex instance inside that block borrows a second connection, and with max: 1 it deadlocks immediately, with max: 10 it deadlocks once ten requests are in flight.
Check the database’s view. With application_name set in the connection, the server shows whether the connections are working or idle:
SELECT state, count(*) FROM pg_stat_activity WHERE application_name = 'orders-api' GROUP BY state;
Sessions idle in transaction point at transactions held open across non-database work.
Confirm which settings are in effect. Knex merges its defaults with the configuration, and driver-level pool options in connection are silently ignored:
console.log(knex.client.config.pool); // { min, max, acquireTimeoutMillis, ... }
Mathematical sizing or parameter formula
Size max from queries in flight, as with any pool:
max = ceil( requests_per_second × queries_per_request × mean_query_seconds ÷ 0.7 )
At 250 requests per second with 3 queries of 6 ms: 250 × 3 × 0.006 = 4.5 in flight; max = 7.
Add the transaction term. A transaction holds its connection for the whole block, not for each query:
transaction_connections = transactions_per_second × mean_transaction_seconds
max = ceil( (query_in_flight + transaction_connections) ÷ 0.7 )
Twenty transactions per second averaging 80 ms hold 20 × 0.08 = 1.6 connections continuously, so max = ceil((4.5 + 1.6) ÷ 0.7) = 9.
min is a fleet-wide cost. Knex opens min connections eagerly and keeps them:
idle_floor = replicas × workers × min
With the default min: 2, twelve replicas of four workers hold 96 connections permanently. Setting min: 0 removes the floor at the cost of connection latency after idle periods; min: 1 is a reasonable compromise.
Acquisition timeout. Knex’s default acquireTimeoutMillis is 60,000 — far longer than any request deadline:
acquireTimeoutMillis ≤ request_deadline − p99_query_time − application_work − margin
With a 3-second deadline: 2,000 ms.
Reaping and lifetimes. reapIntervalMillis controls how often idle connections are considered for closing, and idleTimeoutMillis how long they may sit idle. Both must sit inside the shortest network idle timeout in the path.
Exact remediation & configuration
A complete Knex configuration:
// knexfile.js
module.exports = {
client: "pg",
connection: {
connectionString: process.env.DATABASE_URL,
application_name: "orders-api",
statement_timeout: 2000,
keepAlive: true,
},
pool: {
min: 1,
max: 9,
acquireTimeoutMillis: 2000, // fail fast when saturated
createTimeoutMillis: 5000, // bound connection establishment
idleTimeoutMillis: 120000, // below the network idle timeout
reapIntervalMillis: 30000,
createRetryIntervalMillis: 200,
propagateCreateError: false, // retry creation instead of failing the first waiter
},
acquireConnectionTimeout: 2000, // Knex-level guard, same order as the pool's
};
propagateCreateError: false matters during a database restart: with the default, the first waiter receives the connection error immediately and the pool gives up; with it disabled, Knex retries creation for the duration of acquireTimeoutMillis, which rides out a brief outage.
Make transactions impossible to get wrong. Pass trx explicitly, and have the helper enforce it:
export async function withTransaction(fn) {
return knex.transaction(async (trx) => {
// Every query inside must use trx; nothing here closes over the base instance
return fn(trx);
});
}
// usage
await withTransaction(async (trx) => {
const [order] = await trx("orders").where({ id }).forUpdate();
await trx("order_items").insert(items.map((i) => ({ ...i, order_id: order.id })));
await trx("outbox").insert({ type: "order_created", payload: { id: order.id } });
});
For query builders constructed elsewhere, .transacting(trx) attaches them:
const q = knex("orders").where({ status: "open" });
const rows = await q.clone().transacting(trx);
Keep external calls outside the transaction:
const charge = await gateway.charge(orderId, { idempotencyKey: orderId }); // no connection held
await withTransaction(async (trx) => {
await trx("orders").where({ id: orderId, paid_at: null })
.update({ paid_at: trx.fn.now(), receipt: charge.id });
});
Destroy the pool on shutdown:
process.on("SIGTERM", async () => {
server.close();
await knex.destroy(); // waits for in-flight queries, then closes connections
process.exit(0);
});
Migrations use the same pool by default. Running knex migrate:latest from the application process borrows from the request pool and holds an advisory lock; run migrations from CI with their own short-lived Knex instance instead.
Watching the pool in production
Knex’s counters are plain numbers, so exporting them is a few lines and makes the difference between reading an error and seeing it coming.
import { Gauge } from "prom-client";
const used = new Gauge({ name: "knex_pool_used", help: "Connections in use" });
const free = new Gauge({ name: "knex_pool_free", help: "Idle connections" });
const pendingAcquires = new Gauge({ name: "knex_pool_pending_acquires", help: "Requests waiting" });
const pendingCreates = new Gauge({ name: "knex_pool_pending_creates", help: "Connections being created" });
setInterval(() => {
const p = knex.client.pool;
used.set(p.numUsed());
free.set(p.numFree());
pendingAcquires.set(p.numPendingAcquires());
pendingCreates.set(p.numPendingCreates());
}, 10_000).unref();
Alert on waiting, not on utilisation. A pool at max is working; a pool with waiters is about to fail requests:
- alert: KnexPoolQueueing
expr: max_over_time(knex_pool_pending_acquires[5m]) > 0
for: 10m
labels: { severity: warning }
- alert: KnexAcquireTimeouts
expr: increase(app_errors_total{code="KnexTimeoutError"}[5m]) > 0
for: 2m
labels: { severity: critical }
Log slow acquisitions with their call site. Knex emits query events with a unique identifier, which can be paired to measure per-query duration and attribute waits:
const started = new Map();
knex.on("query", (q) => started.set(q.__knexQueryUid, process.hrtime.bigint()));
knex.on("query-response", (_res, q) => {
const t0 = started.get(q.__knexQueryUid);
if (t0) {
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
if (ms > 500) logger.warn({ sql: q.sql, ms }, "slow query");
started.delete(q.__knexQueryUid);
}
});
knex.on("query-error", (_err, q) => started.delete(q.__knexQueryUid));
Keep an eye on numPendingCreates. A steady non-zero value means connection establishment is the bottleneck — TLS, DNS or authentication — and no amount of pool size helps.
| Counter | Healthy | Meaning when it rises |
|---|---|---|
numUsed |
below max most of the time |
Load approaching capacity |
numPendingAcquires |
zero | Requests are queueing |
numPendingCreates |
zero or brief | Connection establishment is slow |
numFree |
small and stable | Idle connections held |
Validation & verification
No self-deadlock. Run the transaction-heavy endpoints with pool: { min: 1, max: 1 } in a test environment. Any endpoint that deadlocks has a query on the base instance inside a transaction; with correct code, everything still works, just serially.
Acquisition fails fast. With the pool capped and a slow query holding it, concurrent requests must fail in about acquireTimeoutMillis with KnexTimeoutError, not hang for a minute.
Idle floor matches min. After traffic stops, the database’s session count for the service should fall to replicas × workers × min within idleTimeoutMillis plus a reap interval.
Shutdown drains. After SIGTERM, sessions should disappear promptly, proving knex.destroy() ran.
Database restart is survivable. Restart the database in staging under light load. With propagateCreateError: false, requests should recover within seconds rather than failing until the process restarts.
Frequently Asked Questions
Do the driver’s pool options work?
pg’s max inside connection are ignored, because Knex creates raw driver connections and pools them itself with tarn. Everything pool-related belongs under pool.What does acquireConnectionTimeout do compared with acquireTimeoutMillis?
acquireTimeoutMillis is tarn’s own wait bound; acquireConnectionTimeout is Knex’s guard around acquiring a connection for a query. Set both to the same order of magnitude so the error is predictable.Is min: 0 safe?
How do I use a read replica?
Related
- Express.js Connection Pool Middleware — the parent guide to request-scoped connections in Express.
- Configuring mysql2 Connection Pools in Express — the driver pool when Knex is not in the path.
- Implementing Graceful Connection Pool Shutdown in Express — draining on
SIGTERM. - Tuning node-postgres Pool Timeout Settings — the timeout ordering Knex’s settings mirror.