Skip to content
Node.js nd database 5 min read

Database Connection Pooling

Opening a database connection is expensive: it involves a TCP handshake, TLS negotiation, and authentication, all before a single query runs. A connection pool keeps a set of established connections alive and hands them out to requests as needed, returning them to the pool when work is done. This turns a costly per-request setup into a cheap borrow-and-return, which is essential for any Node.js service handling concurrent traffic.

Why pooling matters

Without a pool, every query opens a new connection and tears it down afterward. Under load this adds latency to each request and quickly overwhelms the database, which has a hard cap on simultaneous connections (PostgreSQL defaults to 100, MySQL to 151). Because Node.js processes many requests concurrently on a single thread, a burst of traffic can spawn hundreds of connection attempts at once.

A pool solves this by bounding the number of live connections and reusing them. Requests that arrive when all connections are busy wait briefly in a queue rather than hammering the database. The result is lower latency, predictable resource usage, and protection against connection exhaustion on the server side.

Run one pool per process and share it across your whole application. Creating a new pool per request defeats the purpose entirely and is one of the most common pooling mistakes.

Configuring pool size

Most drivers let you create a pool with a handful of options. The node-postgres (pg) driver is representative:

import pg from "pg";

const { Pool } = pg;

export const pool = new Pool({
  host: process.env.PGHOST,
  database: process.env.PGDATABASE,
  user: process.env.PGUSER,
  password: process.env.PGPASSWORD,
  max: 10,                      // maximum connections in the pool
  min: 0,                       // keep no idle connections when quiet
  idleTimeoutMillis: 30_000,    // close idle clients after 30s
  connectionTimeoutMillis: 5_000, // fail fast if none free in 5s
});

The single most important knob is max. Bigger is not better: if every Node instance opens max connections, the total across instances must stay under the database limit. A practical formula is max = (db_connection_limit - reserved) / number_of_app_instances.

OptionPurposeTypical value
maxUpper bound on live connections5-20 per instance
minConnections kept warm when idle0-2
idleTimeoutMillisWhen to close surplus idle connections10000-30000
connectionTimeoutMillisHow long to wait for a free connection2000-10000
maxUsesRecycle a connection after N queries7500 (optional)

Acquiring and releasing connections

For one-off queries, call pool.query() directly. The pool checks out a connection, runs the query, and returns it automatically. This is the safest pattern because there is nothing to leak.

import { pool } from "./db.js";

const { rows } = await pool.query(
  "SELECT id, email FROM users WHERE active = $1",
  [true],
);
console.log(rows);

When you need several queries on the same connection — for a transaction, or to use session state — check a client out explicitly and always release it in a finally block:

export async function createOrder(userId, total) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const { rows } = await client.query(
      "INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id",
      [userId, total],
    );
    await client.query("COMMIT");
    return rows[0].id;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release(); // return to the pool even on error
  }
}

Forgetting client.release() is the classic cause of pool exhaustion: each leaked client permanently removes one connection from the pool until none remain and every request hangs.

Handling exhaustion and timeouts

When all connections are checked out, new requests wait. If connectionTimeoutMillis elapses first, pool.connect() rejects instead of waiting forever. Catch that error and respond with a 503 rather than letting the request stall:

try {
  const result = await pool.query("SELECT now()");
  return result.rows[0];
} catch (err) {
  if (err.message.includes("timeout")) {
    console.error("Pool exhausted — too many concurrent queries");
    throw new Error("Service temporarily unavailable");
  }
  throw err;
}

You can also observe pool health at runtime through its counters:

console.log({
  total: pool.totalCount,   // all connections
  idle: pool.idleCount,     // available right now
  waiting: pool.waitingCount, // requests queued for a connection
});

Output:

{ total: 10, idle: 0, waiting: 14 }

A persistently high waitingCount means demand exceeds max. The fix is usually faster queries (add indexes, shorten transactions) rather than a larger pool — a bigger pool just moves the bottleneck to the database.

Pooling in serverless environments

Serverless platforms (AWS Lambda, Vercel, Cloudflare) break the one-pool assumption. Each concurrent invocation runs in its own isolated instance, so a pool of max: 10 per function multiplied by hundreds of concurrent invocations can blow past the database limit instantly.

Two strategies work well:

  • Set max: 1 per instance and declare the pool outside the handler so it is reused across warm invocations of the same instance.
  • Put an external pooler in front of the database — PgBouncer or a managed equivalent such as Supabase’s pooler, Neon, or AWS RDS Proxy — and let it multiplex thousands of short-lived client connections onto a small set of real ones.
// Declared at module scope — survives across warm invocations
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });

export async function handler(event) {
  const { rows } = await pool.query("SELECT 1");
  return { statusCode: 200, body: JSON.stringify(rows) };
  // Do NOT call pool.end() — keep it alive for reuse
}

When connecting through a transaction-mode pooler like PgBouncer, disable prepared statements and server-side state that assume a sticky connection.

Best practices

  • Create exactly one pool per process and import it wherever you query.
  • Prefer pool.query() for single statements; only check out a client for transactions.
  • Always release checked-out clients in a finally block.
  • Size max against the database’s total limit divided across all instances, not by guesswork.
  • Set a connectionTimeoutMillis so requests fail fast instead of hanging under load.
  • Monitor waitingCount and query latency; fix slow queries before enlarging the pool.
  • In serverless, use a tiny per-instance pool and front the database with an external pooler.
Last updated June 14, 2026
Was this helpful?