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, ... }
Self-deadlock from a query outside the transaction object Inside a transaction block the transaction holds one connection. A query issued on the base knex instance requests a second connection; when the pool is full it waits, and the transaction cannot commit until that query returns. knex.transaction holds connection 1 knex('orders').select asks for connection 2 Pool full request queues the transaction waits for the query; the query waits for the pool; the pool waits for the transaction the fix is to run every query inside the block on trx, never on knex with max: 1 it deadlocks on the first request, which is why it often escapes staging
A query issued on the base Knex instance inside a transaction waits for a connection the transaction already holds. The transaction cannot finish until the query does.

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.

Knex acquisition timeout against a request deadline With the default sixty second acquire timeout, a request whose deadline is three seconds is abandoned by its caller long before Knex gives up. Timeouts on one request 0 s 60 s request deadline 3 s acquireTimeoutMillis default 60 s set it to 2 s: the pool fails before the caller gives up a long acquire timeout does not add capacity; it only delays the failure
The default acquisition timeout is twenty times a typical request deadline, so a saturated pool produces hung requests rather than errors.

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
Knex pool counters during saturation Used connections reach the maximum first, pending acquires rise next, and acquisition timeout errors appear last, several minutes after the first queueing. numUsed numPendingAcquires timeout errors the gap between the amber and red lines is the warning time the alert buys
Pending acquires rise before any timeout error appears, which makes them the signal worth alerting on.

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?
No. Options such as 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?
Yes, and it is often right for fleets with many processes. The trade is that the first query after an idle period pays connection establishment, which behind TLS can be tens of milliseconds.
How do I use a read replica?
Create a second Knex instance with its own connection and pool, sized against the replica. Knex has no built-in read/write splitting, so the application chooses the instance — the same pattern as Configuring Spring Boot Read Replica DataSource Routing in another stack.