Tuning node-postgres Pool Timeout Settings
This guide is part of Node.js Async Connection Limits. node-postgres exposes five separate timeouts across the pool, the client, and the server. They bound different things, they fail in different ways, and a service with only some of them set will hang somewhere the others would have caught.
The failure that brings most people here is a request that never returns. Not an error — a hang. Somewhere between pool.connect() and the response, a promise is waiting on something with no deadline, and the only evidence is a rising count of open handles. Every one of the timeouts below closes one of those gaps.
The five timeouts and what each bounds
| Setting | Where it lives | Bounds | Default |
|---|---|---|---|
connectionTimeoutMillis |
pool | waiting for a connection from the pool, including establishing a new one | 0 (wait forever) |
idleTimeoutMillis |
pool | how long an unused connection stays in the pool | 10000 |
connect_timeout |
client / connection string | the TCP and authentication handshake, enforced by libpq semantics | none |
query_timeout |
client | how long the client waits for a query result before erroring | none |
statement_timeout |
client, applied server-side | how long PostgreSQL runs the statement before cancelling it | 0 (unlimited) |
Two of these look interchangeable and are not. query_timeout is a client-side timer: when it fires, node-postgres rejects the promise and destroys the connection, but the query keeps running on the server until it finishes. statement_timeout is sent to the server as a session parameter, so PostgreSQL itself aborts the statement and releases its locks and CPU.
Setting only query_timeout gives you a fast error and an orphaned server-side query that still holds its locks. Setting only statement_timeout covers the query but not a server that has stopped responding entirely. Production configurations should set both, with query_timeout slightly above statement_timeout so the server-side cancellation happens first and the client sees the proper canceling statement due to statement timeout error rather than a generic timeout.
A complete pool configuration
import pg from 'pg';
export const pool = new pg.Pool({
host: process.env.PGHOST,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
// Ceiling: this process's share of the database connection budget.
max: 12,
min: 2,
// Never wait forever for a connection. This is the single most
// important change from the defaults.
connectionTimeoutMillis: 3_000,
// Release unused connections after a quiet minute.
idleTimeoutMillis: 60_000,
// Apply a server-side statement timeout to every connection in the
// pool, sent in the startup packet so it costs no extra round trip.
options: '-c statement_timeout=8000',
// Client-side backstop, deliberately above statement_timeout.
query_timeout: 10_000,
// Cap total connection age below the load balancer idle timer.
maxLifetimeSeconds: 280,
allowExitOnIdle: false,
});
Two notes on that block. options: '-c statement_timeout=8000' is the most reliable way to apply a server-side statement timeout to every connection in the pool: it is sent as a startup parameter, so it applies from the connection’s first query without a round trip. The alternative — running SET statement_timeout in a pool.on('connect') handler — also works but adds a round trip per connection and can be skipped if a connection is created during an error path.
maxLifetimeSeconds was added in pg 8.10 and is the node-postgres equivalent of the age cap discussed in tuning ConnMaxLifetime in Go. Without it, a pool behind a load balancer with a 350-second idle timeout will eventually hand out a severed socket.
For connect_timeout, which node-postgres reads from the connection string rather than the config object:
const pool = new pg.Pool({
connectionString: `${process.env.DATABASE_URL}?connect_timeout=5`,
max: 12,
connectionTimeoutMillis: 3_000,
});
Note the unit difference — connect_timeout is in seconds (libpq convention) while every *Millis setting is in milliseconds. Setting connect_timeout=5000 intending five seconds gives you a timeout of eighty-three minutes.
Ordering the values
The timeouts must be ordered, or the outer ones fire first and mask the inner ones, giving you an unhelpful error message for every failure mode:
connect_timeout < connectionTimeoutMillis < statement_timeout < query_timeout < request deadline
5 s (n/a — different segment) 8 s 10 s 15 s
More precisely: connectionTimeoutMillis must exceed connect_timeout, because acquiring a connection from an empty pool includes performing the handshake. If connectionTimeoutMillis is 3 seconds and connect_timeout is 5, a slow handshake always surfaces as a pool timeout and you never learn that the handshake was the problem.
And query_timeout must exceed statement_timeout by enough to cover a round trip, so that PostgreSQL’s own cancellation lands first. Two seconds of margin is ample on a private network.
Finally, the whole chain must fit inside whatever deadline the caller has. If your HTTP framework gives up after 15 seconds, a 3-second pool wait plus a 10-second query timeout fits; adding a retry does not.
Common failure patterns and remediation
| Symptom | Underlying cause | Remediation |
|---|---|---|
| Requests hang indefinitely under load | connectionTimeoutMillis at its 0 default |
Set it to 2–5 s; every pool wait must be bounded |
timeout exceeded when trying to connect under normal load |
Pool max too small, or connections leaking |
Check pool.waitingCount; audit for missing client.release() |
| Query errors quickly but the database stays busy | Only query_timeout set; server-side query orphaned |
Add statement_timeout via the options startup parameter |
canceling statement due to statement timeout on a legitimate report |
One global statement timeout applied to mixed workloads | Use a second pool for reports with its own longer timeout |
| Connection errors after idle periods | No maxLifetimeSeconds; an upstream device reaped the socket |
Set maxLifetimeSeconds below the shortest external idle timer |
| Process will not exit in tests or scripts | Idle connections keeping the event loop alive | Set allowExitOnIdle: true for short-lived processes, or await pool.end() |
| Handshake failures reported as pool timeouts | connect_timeout longer than connectionTimeoutMillis |
Make the pool timeout the larger of the two |
| Timeout appears to be 83 minutes | connect_timeout given in milliseconds |
It is in seconds — use connect_timeout=5 |
Instrumenting the pool
node-postgres exposes three counters synchronously on the pool object, and they are the fastest way to tell a slow database from a small pool:
setInterval(() => {
logger.info({
total: pool.totalCount, // connections that exist
idle: pool.idleCount, // of those, currently unused
waiting: pool.waitingCount, // callers queued for a connection
}, 'pg pool');
}, 10_000);
waitingCount above zero for sustained periods is the unambiguous saturation signal — it means requests are queuing, not merely that the pool is busy. totalCount at max with waitingCount at zero is a fully utilised pool that is coping, which is not an incident. That distinction, and the alerting that follows from it, is the subject of Detecting Connection Pool Saturation.
The pool also emits events worth wiring to logs:
pool.on('error', (err, client) => {
// Fired for errors on IDLE clients. Without this handler the
// process crashes on an unhandled 'error' event.
logger.error({ err }, 'idle client error');
});
pool.on('connect', (client) => {
client.on('error', (err) => logger.error({ err }, 'client error'));
});
The error handler on the pool is not optional. Node’s EventEmitter throws if an error event has no listener, so a network blip on an idle connection will take the process down without it.
Operational boundary
These settings bound waiting. They do not create capacity. If waitingCount is persistently above zero with connectionTimeoutMillis set, you have converted an unbounded hang into a fast, honest failure — which is progress, but the pool is still too small or the queries are still too slow. The sizing arithmetic is in Connection Pool Sizing Formulas, and if you are running many Node processes against one database, the per-process ceiling matters far more than the timeouts do.
One case genuinely needs a different shape entirely: serverless. A pool with min: 2 and a 60-second idle timeout inside a function that freezes between invocations does not behave like a pool at all. See Serverless Function Connection Management before applying any of the values above to Lambda or Cloud Run.
Frequently Asked Questions
What is the single most important setting to change from the defaults?
connectionTimeoutMillis. Its default of zero means a caller waits forever for a connection, which converts a capacity problem into an unbounded hang with no error to alert on.Why set both query_timeout and statement_timeout?
query_timeout protects the Node process; statement_timeout protects the database. A client-side timeout alone leaves the query running on the server, still holding locks. Set statement_timeout lower so the server cancels first.Should I use options or a connect handler to set statement_timeout?
options: '-c statement_timeout=8000'. It travels in the startup packet, applies from the first query, and costs no round trip. A connect handler works but is skippable on error paths.Why is connect_timeout in seconds when everything else is milliseconds?
Does releasing a client back to the pool reset statement_timeout?
statement_timeout mid-request with a SET, the next borrower inherits it. Use SET LOCAL inside a transaction, or reset explicitly before release.How does this change behind PgBouncer in transaction mode?
options may not survive, because the proxy multiplexes server connections. Prefer SET LOCAL statement_timeout inside each transaction. The constraints are covered in PgBouncer Transaction vs Statement Pooling.What should min be?
Related
- Node.js Async Connection Limits — the parent guide on event-loop concurrency and pool ceilings.
- Connection Acquisition Timeout Strategies — the general model these settings implement.
- Express.js Connection Pool Middleware — wiring the pool into a request lifecycle correctly.
- Detecting Connection Pool Saturation — turning
waitingCountinto an alert. - Serverless Function Connection Management — why these defaults do not transfer to Lambda.