NodeJSConnection Pooling

Connection Pooling

Opening a database connection is expensive — a TCP handshake, authentication, and session setup — and databases cap how many connections they'll accept. A connection pool solves both: it maintains a small set of open connections and lends them out to requests, recycling each when the work is done. Every page in this section has invoked the "pool once" rule; this page explains why it matters, how to size a pool, and the failure modes when you get it wrong.

The problem a pool solves

Text
Without a pool — a new connection per request:
  request → [TCP handshake + auth + setup ~10-50ms] → query → close
            ↑ paid on EVERY request; DB connection limit hit under load

With a pool — N connections, created once, reused:
  request → borrow a ready connection → query → return it to the pool
            ↑ handshake paid once; requests share a small, stable set
A pool amortizes connection cost and bounds concurrency
A pool gives you two things at once: it **reuses** connections so you don't pay the handshake per request, and it **caps** how many connections your app opens, protecting the database from being overwhelmed. Requests borrow an idle connection (or wait briefly for one to free up), run their query, and return it. This is the standard, non-negotiable way a web server talks to a database.
Creating a pool — once, at startup

Postgres (pg)

JS
import { Pool } from 'pg'

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,                      // max connections in the pool
  min: 0,                       // keep this many warm (driver-dependent)
  idleTimeoutMillis: 30_000,    // close idle connections after 30s
  connectionTimeoutMillis: 5_000, // fail fast if none free within 5s
})
The whole point is to create the pool ONCE — module scope, not per request
Define the pool at module scope so it's created a single time when the app boots, and import that instance everywhere. Creating a `new Pool()` inside a request handler spawns a fresh pool (and its connections) per request — the exact problem pooling exists to prevent. ORMs ([Prisma](/nodejs/prisma), [Sequelize](/nodejs/sequelize)) and the [Mongo driver](/nodejs/mongodb-driver) all pool internally; the same "instantiate once" rule applies to them.
Borrowing for transactions vs simple queries

JS
// Simple query — pool.query borrows + returns a connection automatically:
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [id])

// Transaction — check out ONE connection and release it in finally:
const client = await pool.connect()
try {
  await client.query('BEGIN')
  /* ...multiple statements on the SAME connection... */
  await client.query('COMMIT')
} catch (e) {
  await client.query('ROLLBACK'); throw e
} finally {
  client.release()             // ← return it, ALWAYS
}
A borrowed connection not released is a leak that exhausts the pool
When you manually `pool.connect()` (required for [transactions](/nodejs/transactions)), you **must** `client.release()` in a `finally` block. Every un-released connection is permanently removed from the pool; leak `max` of them and the pool is empty — new queries hang forever waiting for a connection that never returns, and the app appears frozen. Simple `pool.query()` calls handle release for you; manual checkouts do not.
Sizing the pool

Factor

Guidance

Database max_connections

Pool size × instances must stay UNDER it

Number of app instances

Each instance has its own pool — multiply

Workload

I/O-bound queries need fewer connections than you'd think

Starting point

~10 per instance; tune with metrics, don't guess high

Bigger is NOT better — oversized pools overwhelm the database
The instinct to set `max: 100` backfires. Each app instance opens its own pool, so 5 instances × 100 = 500 connections — likely **over the database's limit**, causing connection refusals. And databases don't process 500 concurrent queries faster; they thrash on context-switching and memory. A modest pool (often ~10–20 per instance) usually *outperforms* a huge one. Compute: `pool_max × instances ≤ db_max_connections` (leaving headroom for migrations, admin tools, etc.).
Serverless changes the math

Text
Traditional server: a few long-lived instances, each with one pool.
Serverless (Lambda/Edge): MANY short-lived instances, each opening
  connections → can blow past the DB limit during a traffic spike.

Fix: an external pooler that fronts the database:
  functions → [ PgBouncer / Prisma Accelerate / RDS Proxy ] → Postgres
Serverless + per-invocation pools can exhaust the DB — use an external pooler
In serverless, each concurrent invocation may be a separate instance opening its own connections; a spike can instantly exceed the database's limit. Mitigations: keep `max` very small (1–3) per function, reuse the client across warm invocations (module scope / `globalThis`), and front the database with an **external connection pooler** — PgBouncer, RDS Proxy, or a provider's pooled connection string — that multiplexes many clients onto few real connections. Standard in-process pooling assumes long-lived processes, which serverless breaks.
Health: monitor and handle failures
  • Listen for pool error events (a backend connection dropped) and log them.

  • Set connectionTimeoutMillis so requests fail fast instead of hanging when the pool is exhausted.

  • Validate/reconnect after the DB restarts; good drivers do this, but verify.

  • Track pool metrics (total/idle/waiting) — a growing waiting count signals an undersized pool or a leak.

  • Drain the pool on shutdown (pool.end()) so connections close cleanly.

A leak and an undersized pool look the same — measure to tell them apart
When requests start hanging on database calls, the cause is almost always "no connections available." Two culprits: a **leak** (connections checked out and never released — count keeps climbing and never recovers) or a genuinely **undersized** pool under real load (waiting count spikes but recovers). Metrics distinguish them: a leak trends to zero idle and stays there; undersizing fluctuates with traffic. Fix leaks first (audit every `pool.connect()` for a `finally release()`), then size.
Next
Evolve your schema safely over time: [Database Migrations](/nodejs/database-migrations).