Post-Mortem: Race Conditions in PostgreSQL Pools (And the Guard)
A technical post-mortem on transaction corruption in Node.js. Learn the static analysis standard for safe transaction management on pooled clients.

Managing transactions on a shared connection pool is an architectural minefield. Here is the technical post-mortem on race conditions, and the static analysis standard for safe PostgreSQL transaction management.
This code looks correct. It passes all tests. It works in development.
In production with 100 concurrent users, it corrupts data.
The Bug
// โ Dangerous: Transaction on pool
async function transferFunds(from, to, amount) {
await pool.query("BEGIN");
await pool.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [
amount,
from,
]);
await pool.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [
amount,
to,
]);
await pool.query("COMMIT");
}
Why It Fails
A PostgreSQL pool is a set of client connections. Each pool.query() can use a different client.
Request 1: pool.query('BEGIN') โ Client A
Request 1: pool.query('UPDATE...') โ Client B (different!)
Request 2: pool.query('BEGIN') โ Client A (reused!)
Your transaction is now spread across multiple clients. Your data is now inconsistent.
The Correct Pattern
// โ
Safe: Get dedicated client, use it for entire transaction
async function transferFunds(from, to, amount) {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, from],
);
await client.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, to],
);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
Same client for BEGIN, all queries, and COMMIT. Transaction integrity guaranteed.
The Rule
// โ pool.query('BEGIN') โ Error
// โ pool.query('COMMIT') โ Error
// โ pool.query('ROLLBACK') โ Error
// โ pool.query('SAVEPOINT') โ Error
// โ
client.query('BEGIN') โ OK
// โ
pool.query('SELECT...') โ OK (no transaction)
Let ESLint Catch This
npm install --save-dev eslint-plugin-pg
import pg from "eslint-plugin-pg";
export default [pg.configs.recommended];
The no-transaction-on-pool rule catches every case:
src/transfer.ts
3:9 error ๐ CWE-362 | Transaction command on pool - use pool.connect() for transactions
Fix: const client = await pool.connect(); client.query('BEGIN');
Helper Function Pattern
// โ
Reusable transaction wrapper
async function withTransaction(callback) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await callback(client);
await client.query("COMMIT");
return result;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// Usage
await withTransaction(async (client) => {
await client.query("UPDATE accounts SET...", [amount, from]);
await client.query("UPDATE accounts SET...", [amount, to]);
});
When To Use What
| Scenario | Use |
|---|---|
| Single query | pool.query() |
| Multiple independent queries | pool.query() |
| Transaction (BEGIN/COMMIT) | pool.connect() โ client.query() |
| Long-running session | pool.connect() โ client.query() |
Quick Install
npm install --save-dev eslint-plugin-pg
import pg from "eslint-plugin-pg";
export default [pg.configs.recommended];
Don't let race conditions corrupt your data.
๐ฆ npm: eslint-plugin-pg ๐ Rule docs: no-transaction-on-pool
The Interlace ESLint Ecosystem Interlace is a high-fidelity suite of static code analyzers designed to automate security, performance, and reliability for the modern Node.js stack. With over 330 rules across 18 specialized plugins, it provides 100% coverage for OWASP Top 10, LLM Security, and Database Hardening.
Explore the full Documentation
ยฉ 2026 Ofri Peretz. All rights reserved.
Build Securely. I'm Ofri Peretz, a Security Engineering Leader and the architect of the Interlace Ecosystem. I build static analysis standards that automate security and performance for Node.js fleets at scale.