ZUA.
Home/Blog/Database Connection Bugs
Backend / DatabaseJuly 28, 2026 · 8 min read

Database Connection Bugs That Only Show Up in Production — Node.js

In development you have one user, one request at a time, and a database that resets every deploy. These bugs are invisible there. Under real traffic they quietly accumulate until your API starts timing out — and the logs tell you nothing useful.

Node.jsPostgreSQLDatabaseBackendPerformance

How a Connection Pool Works (and Why It Breaks)

Every time your Node.js app queries the database, it needs a TCP connection. Opening a connection takes 20–100ms. At scale, doing this per-request kills performance.

A connection pool solves this by maintaining a fixed set of open connections and lending them out to requests as needed. When a request finishes, it returns the connection to the pool for the next request.

// pg (node-postgres) pool — the most common setup
import { Pool } from "pg";

const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASS,
  max: 10,           // max open connections — default is 10
  idleTimeoutMillis: 30000,   // close idle connections after 30s
  connectionTimeoutMillis: 2000, // throw if no connection available in 2s
});

The pool has a hard cap — max. If all 10 connections are in use and an 11th request arrives, it waits. If it waits longer than connectionTimeoutMillis, it throws. That's pool exhaustion — and it cascades fast.

Bug #1 — Connection Never Released (The Leak)

The most common cause of pool exhaustion. You acquire a connection manually, the code throws an error mid-query, and the connection is never returned to the pool. Repeat under traffic and you exhaust the pool in minutes.

// BAD — connection leaks on any error
app.get("/user/:id", async (req, res) => {
  const client = await pool.connect(); // acquired from pool

  const user = await client.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
  // ^ if this throws, the line below never runs

  client.release(); // never called on error — connection stuck open forever
  res.json(user.rows[0]);
});
// GOOD — always release in a finally block
app.get("/user/:id", async (req, res) => {
  const client = await pool.connect();
  try {
    const user = await client.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
    res.json(user.rows[0]);
  } catch (err) {
    res.status(500).json({ error: "Database error" });
  } finally {
    client.release(); // always runs — error or not
  }
});
RULE

If you call pool.connect(), you own the release. Always use try/finally. No exceptions.

Prefer pool.query() over pool.connect() when you don't need a transaction — the pool handles acquire and release automatically:

// pool.query() — no manual acquire/release needed
app.get("/user/:id", async (req, res) => {
  const user = await pool.query(
    "SELECT * FROM users WHERE id = $1",
    [req.params.id]
  );
  res.json(user.rows[0]);
  // connection automatically returned to pool
});

Bug #2 — Connection Leaked Inside a Transaction

Transactions must be explicitly committed or rolled back. If an error happens mid-transaction and you don't catch it and roll back, the connection stays open, the transaction stays open, and the rows it touched stay locked. Other queries against those rows will hang waiting for a lock that never releases.

// BAD — transaction never rolled back on error
async function transferFunds(fromId, toId, amount) {
  const client = await pool.connect();
  await client.query("BEGIN");

  await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, fromId]);
  await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, toId]);
  // ^ if this throws: transaction stays open, rows stay locked, client never released

  await client.query("COMMIT");
  client.release();
}
// GOOD — always rollback in catch, always release in finally
async function transferFunds(fromId, toId, amount) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query(
      "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
      [amount, fromId]
    );
    await client.query(
      "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
      [amount, toId]
    );
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK"); // release the lock, undo partial changes
    throw err;
  } finally {
    client.release(); // always return to pool
  }
}

Bug #3 — Pool Size Too Small (or Too Large)

Too small: requests queue up waiting for a connection. API latency spikes. Under burst traffic this cascades — the queue backs up faster than it drains, and requests start timing out.

Too large: you overwhelm the database server. PostgreSQL has its own connection limit (default 100). If 5 Node.js instances each open a pool of 50, you hit 250 connections — Postgres starts rejecting them.

// How to calculate the right pool size
// PostgreSQL rule of thumb: total_connections = num_cores * 2 + num_spindle_disks
// For a 4-core Postgres instance: max ~10 connections per app instance

// If you have 3 Node.js replicas behind a load balancer:
// 10 connections × 3 replicas = 30 total — well within Postgres default of 100

const pool = new Pool({
  max: 10,                        // per Node.js process
  idleTimeoutMillis: 30_000,      // release idle connections after 30s
  connectionTimeoutMillis: 3_000, // fail fast if pool is exhausted
});

// Check Postgres max_connections
// SELECT current_setting('max_connections');
// SELECT count(*) FROM pg_stat_activity; -- active right now
IMPORTANT

When running multiple Node.js replicas (Kubernetes pods, ECS tasks), multiply pool.maxby your replica count. That's the total connections hitting your database. Size accordingly.

Bug #4 — Creating a New Pool Per Request

This one is surprisingly common in tutorials and serverless examples. A new Pool is created inside a route handler or a function. Every request opens its own set of connections. The pool is never reused across requests. Connections pile up until the database runs out.

// BAD — new pool on every request
app.get("/products", async (req, res) => {
  const pool = new Pool({ ... }); // opens new connections every single request
  const result = await pool.query("SELECT * FROM products");
  res.json(result.rows);
  // pool.end() is never called — connections stay open forever
});
// GOOD — one pool for the entire process lifetime
// db.ts
import { Pool } from "pg";
export const pool = new Pool({ max: 10, ... });

// routes/products.ts
import { pool } from "../db";

app.get("/products", async (req, res) => {
  const result = await pool.query("SELECT * FROM products");
  res.json(result.rows);
  // same 10 connections shared across all requests
});

Bug #5 — Not Handling connectionTimeoutMillis

When the pool is exhausted, the next pool.connect() or pool.query() will wait until connectionTimeoutMillis elapses, then throw. If this error is not caught, it surfaces as an unhandled promise rejection and crashes the request with a 500 — but the real cause (pool exhaustion) is buried.

// What the error looks like
// Error: timeout exceeded when trying to connect
// at Pool._connect (/node_modules/pg-pool/index.js:...)

// How to expose the real cause in your error handler
app.use((err, req, res, next) => {
  if (err.message.includes("timeout exceeded when trying to connect")) {
    console.error("[DB] Connection pool exhausted — check active connections");
    // SELECT count(*), state FROM pg_stat_activity GROUP BY state;
    return res.status(503).json({ error: "Service temporarily unavailable" });
  }
  res.status(500).json({ error: "Internal server error" });
});

How to Monitor the Pool in Production

Don't wait for timeouts to discover the pool is unhealthy. Expose pool metrics on an internal endpoint and alert on them:

// Expose pool stats on an internal health endpoint
app.get("/health/db", (req, res) => {
  res.json({
    total: pool.totalCount,    // total connections in the pool
    idle: pool.idleCount,      // connections waiting for a query
    waiting: pool.waitingCount // requests waiting for a connection
  });
});

// What to alert on:
// waiting > 0 for more than a few seconds → pool under pressure
// idle === 0 && waiting > 0              → pool exhausted
// total < max for extended period        → connections leaking (not being returned)

And at the Postgres level — always watch active connections directly:

-- Active connections right now
SELECT count(*), state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE datname = 'your_db_name'
GROUP BY state, wait_event_type, wait_event;

-- Long-running queries holding connections
SELECT pid, now() - query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle'
  AND now() - query_start > interval '5 seconds'
ORDER BY duration DESC;
QUICK DIAGNOSIS

If your API is timing out: (1) check pool.waitingCount— if it's climbing, the pool is exhausted. (2) Run the pg_stat_activity query — look for connections stuck in 'idle in transaction' state. Those are leaked transactions.

Quick Reference — What Each Symptom Means

Symptom                              Root Cause
──────────────────────────────────────────────────────────────────
API timeouts under load              Pool exhausted — pool.max too small
                                     or connections leaking
"timeout exceeded when connecting"   Pool exhausted — waiting queue full
Rows locked / queries hanging        Transaction not rolled back — lock held
DB shows 200+ connections            New pool created per request
Connections grow over time           client.release() missing on error path
idle in transaction in pg_stat       Transaction started, never committed/rolled back

The One-Line Rule

One pool per process, always finally { client.release() }, always rollback in catch. Everything else in this article is a variation of violating one of these three rules.

Building a Node.js backend that needs to hold up under load?

I build and ship production systems — happy to discuss your requirements.

Book a Call