PostgreSQL Query Timeouts in Node.js: Stop Runaway Queries Before They Drain Your Pool
A single slow query can exhaust your entire Postgres connection pool in under a minute, cascade to every endpoint, and take your service down. Here is how to set per-query timeouts with statement_timeout, cancel hung queries with AbortSignal, and handle the 57014 error before it becomes a pager alert.
Your query runs in 40ms during load testing. It runs in 40ms during the first month of production. On day 32, a new data ingestion job loads 3 million rows into the orders table without updating statistics. The query plan flips from an index scan to a sequential scan. The same query now takes 12 seconds instead of 40ms.
That is bad. What happens next is worse.
Your Node.js service has a pool of 25 database connections. At 12 seconds per query, a modest 3 requests per second to the affected endpoint consumes all 25 connections in about 8 seconds. Every other endpoint that needs the database queues behind the stuck queries. Health checks time out. Load balancers drain the pod. Kubernetes restarts it. The new pod gets the same 12-second query plan and the same fate. What was a bad query plan is now a partial outage.
The fix is not “make the query faster” (though that is the permanent solution). The fix that contains the blast radius in the first 30 seconds is a query timeout: a hard upper bound on how long any single query is allowed to run. If the query cannot finish in 2 seconds, the database kills it, the connection goes back to the pool, and the application returns a fast 503 instead of queuing for 12 seconds.
PostgreSQL calls it statement_timeout. You set it in milliseconds, and it is the single highest-leverage configuration change you can make to prevent a slow query from becoming a platform-wide outage.
The two sides of a query timeout
A query timeout has two halves, and only shipping one of them is a common mistake.
The server side is statement_timeout. You tell Postgres “if this query runs longer than N milliseconds, cancel it.” The database sends back error code 57014 (query_canceled). The connection stays alive, the pool is unharmed, and your application gets a clear signal.
The client side is an AbortSignal passed to the pg.Client query call. If the Node process itself decides the query has taken too long, it signals the client to cancel the in-flight query. The client sends a CancelRequest message to the Postgres backend on a separate connection. The backend terminates the query, and the client reuses the original connection normally.
You want both. statement_timeout is the safety net for queries you forgot to set a timeout on. The client-side AbortSignal lets you coordinate the query timeout with an HTTP request deadline or a downstream cancellation.
Server side: statement_timeout
The simplest approach sets a timeout on the entire pool. Every query that runs through this pool is capped at the same limit.
import pg from 'pg';
const pool = new pg.Pool({
host: process.env.PGHOST,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
max: 25,
// Every connection starts with a 5-second statement timeout
statement_timeout: 5000,
});
The statement_timeout pool option sets the parameter on every new connection as it is created. It is a session-level setting, so it persists for the lifetime of that connection and applies to every query the client runs.
Five seconds is the right default for most web request workloads. It is long enough that legitimate queries finish, short enough that a bad plan does not burn your pool. But you should not use a single timeout for everything. A batch report query that legitimately takes 30 seconds should have a different limit than a user-facing API query that should finish in 200ms.
For per-query timeouts, use SET LOCAL in a transaction or set the parameter just for that query:
async function queryWithTimeout(text, params, timeoutMs = 5000) {
const client = await pool.connect();
try {
await client.query(`SET LOCAL statement_timeout = ${timeoutMs}`);
return await client.query(text, params);
} finally {
client.release();
}
}
SET LOCAL scopes the timeout to the current transaction. Outside a transaction, SET LOCAL has no effect, so wrap the query in an explicit transaction if you need this pattern. A simpler approach that works without a transaction is to set and reset the parameter:
async function queryWithTimeout(text, params, timeoutMs = 5000) {
const client = await pool.connect();
try {
await client.query(`SET statement_timeout = ${timeoutMs}`);
return await client.query(text, params);
} finally {
// Reset to pool default
await client.query(`SET statement_timeout = DEFAULT`);
client.release();
}
}
But this has a subtle problem: if the query itself times out, the finally block runs, and the SET statement_timeout = DEFAULT query runs on the same connection. That reset query usually succeeds, but you are issuing an extra round trip on every call.
A better pattern for mixed workloads is to use separate pools with different defaults:
const apiPool = new pg.Pool({
...baseConfig,
max: 20,
statement_timeout: 2000, // 2s for user-facing queries
});
const batchPool = new pg.Pool({
...baseConfig,
max: 5,
statement_timeout: 60000, // 60s for background jobs
});
Separate pools prevent a batch query from starving the API pool, and each pool has a timeout that matches its workload.
Client side: AbortSignal cancellation
Server-side timeouts are reliable, but they do not cover every scenario. If the network between your Node process and the database is partitioned, the statement_timeout setting cannot help because the cancel signal from Postgres has the same network path. Your query hangs indefinitely because the client never receives the cancellation error.
Client-side cancellation uses pg.Client’s cancel method, triggered by an AbortSignal. This sends a CancelRequest on a fresh TCP connection to the Postgres backend, bypassing the stalled connection.
import pg from 'pg';
const pool = new pg.Pool({
host: process.env.PGHOST,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
});
async function queryWithAbort(text, params, signal) {
const client = await pool.connect();
try {
// Set a server-side timeout as a backup
await client.query('SET statement_timeout = 10000');
const result = await client.query({
text,
values: params,
signal, // Pass the AbortSignal to pg
});
return result;
} catch (err) {
if (err.code === '57014' || signal?.aborted) {
// Query was cancelled - do not retry
throw err;
}
// Re-throw other errors
throw err;
} finally {
client.release();
}
}
// Usage with a 3-second timeout
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
try {
const result = await queryWithAbort(
'SELECT * FROM orders WHERE status = $1',
['pending'],
controller.signal,
);
console.log(result.rows);
} catch (err) {
if (err.code === '57014') {
console.error('Query was cancelled due to timeout');
} else {
console.error('Query failed:', err);
}
}
When AbortSignal triggers, pg sends a CancelRequest to the backend. The backend kills the running query, and the client receives back the connection (clean and reusable) to return to the pool.
The combination matters. statement_timeout is your backup for in-flight queries. The AbortSignal is your backup for network partitions. Use both.
Handling the 57014 error
When Postgres cancels a query, it returns error code 57014 with a message like canceling statement due to statementTimeout. Your application code must handle this error explicitly.
The wrong thing to do is to retry the query automatically:
// Bad: retrying a timed-out query
async function fetchOrders(retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await queryWithTimeout('SELECT * FROM orders');
} catch (err) {
if (err.code === '57014') {
// Retrying will just time out again - the query plan is the same
continue;
}
throw err;
}
}
}
A timed-out query times out for a reason. The query plan is bad, the table is locked, or the data volume is unexpected. Retrying with the same parameters and the same timeout produces the same result. You are just burning connections and latency.
The correct handling depends on the context:
- User-facing API: Return a 503 or 504 immediately. The client can decide whether to retry. Log the query text, the parameters, and the duration for debugging.
- Background job: Log the failure, increment a timeout counter, and let the job scheduler retry on the next cycle. If timeouts exceed a threshold, alert.
- Cascading check: If this is one of several queries in a request, short-circuit the remaining queries and return a partial error.
class QueryTimeoutError extends Error {
constructor(query, timeoutMs) {
super(`Query timed out after ${timeoutMs}ms`);
this.name = 'QueryTimeoutError';
this.query = query;
this.timeoutMs = timeoutMs;
this.code = '57014';
}
}
function handleQueryError(err, context) {
if (err.code === '57014') {
const timeoutErr = new QueryTimeoutError(context.query, context.timeoutMs);
logger.warn({
event: 'query_timeout',
query: context.query,
duration: context.duration,
timeout: context.timeoutMs,
poolSize: pool.totalCount,
idleConnections: pool.idleCount,
waitingRequests: pool.waitingCount,
});
if (context.isHttp) {
return new Response(
JSON.stringify({
error: 'service_unavailable',
message: 'The database is temporarily unable to complete this request',
}),
{ status: 503, headers: { 'Content-Type': 'application/json' } },
);
}
throw timeoutErr;
}
// Non-timeout errors get different treatment
throw err;
}
Beyond statement_timeout
PostgreSQL has three timeout settings that work together. Most teams set only statement_timeout, but the other two close gaps in coverage.
lock_timeout controls how long a query waits to acquire a lock. A query that completes in 50ms can wait 30 seconds for a lock held by a long-running transaction. lock_timeout prevents that wait from burning a connection.
SET lock_timeout = '2000'; -- Give up on a lock after 2 seconds
idle_in_transaction_session_timeout kills a session that has started a transaction but has not committed or rolled back for a specified time. An application bug that forgets to commit a transaction holds locks and blocks other queries. This timeout is the safety net.
SET idle_in_transaction_session_timeout = '10000'; -- 10 seconds
Set all three in your pool configuration:
const pool = new pg.Pool({
...baseConfig,
statement_timeout: 5000,
lock_timeout: 2000,
idle_in_transaction_session_timeout: 10000,
});
These three parameters together prevent three different failure modes: slow queries, lock contention, and abandoned transactions.
A production-ready wrapper
Putting everything together into a single query function that handles timeouts, cancellation, logging, and connection hygiene:
import pg from 'pg';
import { setTimeout } from 'node:timers/promises';
const pool = new pg.Pool({
host: process.env.PGHOST,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
max: 25,
statement_timeout: 5000,
lock_timeout: 2000,
idle_in_transaction_session_timeout: 10000,
});
export class DatabaseTimeoutError extends Error {
constructor(query, timeoutMs) {
super(`Query timed out after ${timeoutMs}ms`);
this.name = 'DatabaseTimeoutError';
this.query = query;
this.timeoutMs = timeoutMs;
this.code = '57014';
}
}
export async function safeQuery(queryText, params, options = {}) {
const timeoutMs = options.timeoutMs ?? 5000;
const signal = options.signal;
const start = performance.now();
const client = await pool.connect();
const controller = new AbortController();
// Link external signal to our controller
const onAbort = () => controller.abort();
signal?.addEventListener('abort', onAbort, { once: true });
// Client-side timeout
const timeout = setTimeout(() => controller.abort(), timeoutMs);
// Unref so the timeout does not keep the process alive
timeout.unref();
try {
// Set connection-level statement_timeout as backup
await client.query(`SET LOCAL statement_timeout = ${timeoutMs}`);
const result = await client.query({
text: queryText,
values: params,
signal: controller.signal,
});
const duration = performance.now() - start;
logger.debug({
event: 'query_completed',
query: queryText.slice(0, 100),
duration: Math.round(duration),
rows: result.rowCount,
});
return result;
} catch (err) {
const duration = performance.now() - start;
const isTimeout = err.code === '57014'
|| err.name === 'DatabaseTimeoutError'
|| signal?.aborted;
if (isTimeout) {
logger.warn({
event: 'query_timeout',
query: queryText.slice(0, 100),
duration: Math.round(duration),
timeout: timeoutMs,
poolIdle: pool.idleCount,
poolTotal: pool.totalCount,
poolWaiting: pool.waitingCount,
});
throw new DatabaseTimeoutError(queryText, timeoutMs);
}
logger.error({
event: 'query_error',
query: queryText.slice(0, 100),
duration: Math.round(duration),
error: err.message,
code: err.code,
});
throw err;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', onAbort);
client.release();
}
}
This wrapper:
- Combines server-side
statement_timeoutwith client-sideAbortSignal - Links an external signal (like an HTTP request’s
req.signal) to the query timeout - Logs every timeout with pool metrics so you can see exhaustion before it happens
- Never retries a timeout automatically
- Always releases the client in
finally
What to alert on
Query timeouts are a symptom, not the root cause. But they are a symptom worth paging on if they exceed a threshold.
Set up two alerts:
-
Any query timeout on a user-facing endpoint (pager). A timeout on an API query means a connection was consumed for N seconds and returned nothing. Every timeout is a degraded request.
-
Timeout rate above 1% in batch pools (ticket). Batch jobs have higher timeouts and the occasional timeout is normal under load. A rising rate indicates a bad plan or a data volume problem.
Log the query text (truncated to 100 chars), the parameters (sanitized), and the exact timeout value that fired. That data lets you reproduce the problem without guesswork.
The takeaway
Database query timeouts are not an optimization. They are a containment strategy. A single query that runs 100x slower than normal can exhaust a 25-connection pool in under a minute, cascade to every endpoint, and take down a service that has nothing to do with the slow query.
The fix is three Postgres configuration parameters (statement_timeout, lock_timeout, idle_in_transaction_session_timeout) and one Node.js pattern (passing AbortSignal through pg.Client.query). Together they take about 60 lines of production code and one configuration change.
Set them before you need them. The bad query plan is coming. The only question is whether your pool survives it.
A note from Yojji
Building production database layers that survive real-world conditions (bad query plans, connection storms, silent network failures) is the kind of engineering discipline that turns a codebase into a platform. It is not about writing better queries. It is about designing boundaries that prevent a single failure from cascading into a system-wide outage. Yojji builds Node.js and TypeScript backends for clients across finance, logistics, and healthcare, where database reliability is not a nice-to-have but a contractual requirement. Their teams design connection pooling strategies, query timeout hierarchies, and observability pipelines that keep production stable when the data layer misbehaves.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and production-hardened backend architecture.