PostgreSQL connection pooling in Node.js and Python showing bounded pools, autoscaling connection limits, reusable database connections, and PgBouncer.

PostgreSQL Connection Pooling in Node.js & Python: Preventing Database Connection Exhaustion

A PostgreSQL connection problem rarely starts with an error.

It usually starts with an application that works perfectly.

One API instance connects to PostgreSQL. Requests are fast. CPU usage is low. Nothing looks unusual.

Then traffic grows.

The application scales from one instance to five.

A background worker is added.

Someone increases the connection pool because requests occasionally wait for a database connection.

Another service starts using the same PostgreSQL instance.

Eventually the database begins returning errors such as:

FATAL: remaining connection slots are reserved
for roles with privileges of the
"pg_use_reserved_connections" role

or:

FATAL: sorry, too many clients already

Meanwhile the application may show:

Connection timeout

or requests may simply start waiting because every connection in the local pool is busy.

The surprising part is that the application itself may not be receiving much traffic.

The real problem is often this multiplication:

Application Instances
        ×
Pool Size Per Instance
        =
Potential Database Connections

For example:

20 Cloud Run instances
×
10 connections per instance
=
up to 200 application connections

A PostgreSQL server cannot accept an unlimited number of client connections.

And increasing max_connections indefinitely is not a free solution either.

This guide explains how PostgreSQL connection pooling works, how to implement it correctly in Node.js with node-postgres and Python with Psycopg 3, how connection leaks happen, how to size pools across autoscaling infrastructure, how to monitor saturation, and when an external pooler such as PgBouncer becomes useful.


What Happens When an Application Connects to PostgreSQL?

A database connection is not simply a string pointing at a database.

When an application establishes a PostgreSQL connection, work happens on both sides.

Conceptually:

Application
    ↓
TCP connection
    ↓
TLS negotiation, if configured
    ↓
PostgreSQL authentication
    ↓
Session established
    ↓
Database queries

PostgreSQL maintains a backend process for client connections.

That connection consumes resources while it exists.

If an application repeatedly does this:

Open connection
↓
Execute query
↓
Close connection

for every individual request, it repeatedly pays the cost of establishing database sessions.

At the other extreme, if every application instance opens too many persistent connections, PostgreSQL may eventually run out of available connection slots.

Connection pooling exists between those two extremes.


What Is a Database Connection Pool?

A connection pool maintains a limited collection of reusable database connections.

Instead of creating a completely new PostgreSQL connection for every query:

HTTP Request
    ↓
Open PostgreSQL Connection
    ↓
Query
    ↓
Close Connection

the application does:

HTTP Request
    ↓
Borrow Connection
    ↓
Query
    ↓
Return Connection
    ↓
Connection remains available in pool

Several requests can therefore reuse the same set of connections.

A simplified pool might look like:

Application Instance

┌──────────────────────────┐
│ Connection Pool          │
│                          │
│ Connection 1  → BUSY     │
│ Connection 2  → IDLE     │
│ Connection 3  → BUSY     │
│ Connection 4  → IDLE     │
│ Connection 5  → IDLE     │
└──────────────┬───────────┘
               │
               ▼
          PostgreSQL

When another request arrives, the pool can give it an idle connection rather than performing another full database connection setup.

That improves efficiency while also placing a boundary around the number of connections one application process can create.


Why PostgreSQL Connections Need a Limit

PostgreSQL exposes:

max_connections

which defines the maximum number of concurrent connections the server accepts.

In PostgreSQL 18, the default is typically:

100

although actual values vary by installation and managed database service.

PostgreSQL also reserves connection capacity for privileged access.

The important point is not the exact default.

The important point is:

Database connections are finite.

PostgreSQL also sizes some resources based on max_connections, including shared-memory requirements. Increasing the setting therefore has a resource cost and should not be treated as an unlimited scaling mechanism.


What Connection Exhaustion Looks Like

Assume PostgreSQL can safely allocate 80 connections to your application workloads.

You deploy:

API Service
Maximum instances: 10
Pool max: 10

Potential connections:

10 × 10 = 100

You have already exceeded your intended 80-connection application budget.

Now add:

Worker Service
5 instances
Pool max: 5

Additional connections:

5 × 5 = 25

Potential total:

100 + 25 = 125

And we have not yet counted:

administrative connections
database migrations
monitoring systems
BI tools
scheduled jobs
developer sessions
other applications

This is why pool sizing must happen at the system level, not one application at a time.


A Simple Connection Budget Formula

A useful starting point is:

Per-Instance Pool Maximum
=
Application Connection Budget
÷
Maximum Application Instances

Suppose:

PostgreSQL connection capacity: 100

Operational/admin/other-service reserve: 25

Connection budget for API: 60

Maximum API instances: 10

Then:

60 ÷ 10 = 6

A sensible starting maximum for that application’s pool might therefore be:

6 connections per instance

rather than:

20

just because 20 sounds faster.

The numbers need to reflect your actual infrastructure and workload.


Bigger Pools Do Not Automatically Make Applications Faster

Consider an API receiving 100 simultaneous requests.

If every request needs a database query, it might seem logical to configure:

pool max = 100

But PostgreSQL does not become faster merely because 100 queries execute simultaneously.

Eventually the database may become limited by:

CPU
memory
disk I/O
locks
buffer contention
query complexity
indexes
network

At that point, allowing additional concurrent queries may make overall latency worse.

A connection pool therefore performs another useful function:

backpressure

Instead of allowing unlimited concurrent database work:

500 requests
   ↓
500 PostgreSQL connections

you can allow:

500 requests
   ↓
Application queue
   ↓
20 pooled connections
   ↓
PostgreSQL

That protects the database.

Of course, if requests wait too long for the pool, application latency increases.

Pool sizing is therefore a balance between:

too small
→ unnecessary waiting

too large
→ database saturation

There is no universal number that solves every workload.


Node.js Connection Pooling with node-postgres

One of the most common PostgreSQL libraries for Node.js is:

pg

also known as:

node-postgres

Install it:

npm install pg

Then create a pool.

import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,

  max: 10,

  connectionTimeoutMillis: 5000,

  idleTimeoutMillis: 30000,

  maxLifetimeSeconds: 1800
});

export default pool;

The important configuration values are:

SettingPurpose
maxMaximum clients the pool can contain
connectionTimeoutMillisMaximum wait when creating/acquiring a connection before timing out
idleTimeoutMillisHow long an unused pooled connection can remain idle before being closed
maxLifetimeSecondsMaximum lifetime of a pooled connection

These values should be selected based on the application and database architecture rather than copied blindly.


Create One Pool Per Application Process

This is important.

A common mistake is creating a pool inside every request handler.

Bad:

app.get("/users", async (req, res) => {

  const pool = new Pool({
    connectionString:
      process.env.DATABASE_URL
  });

  const result =
    await pool.query(
      "SELECT id, name FROM users"
    );

  res.json(result.rows);
});

Every request can now create another pool.

That defeats the purpose of pooling.

Instead, create the pool once.

For example:

src/
├── db.js
├── server.js
└── routes/
    └── users.js

db.js:

import { Pool } from "pg";

export const pool = new Pool({
  connectionString:
    process.env.DATABASE_URL,

  max: Number(
    process.env.DB_POOL_MAX || 10
  ),

  connectionTimeoutMillis: 5000,

  idleTimeoutMillis: 30000,

  maxLifetimeSeconds: 1800
});

pool.on("error", (error) => {
  console.error(
    "Unexpected PostgreSQL pool error",
    error
  );
});

Now application modules reuse:

one pool

for that Node.js process.

node-postgres specifically recommends having only a limited number of pools and typically one pool for a normal application process.


Use pool.query() for Simple Queries

If the operation is only one query:

const result = await pool.query(
  `
  SELECT
    id,
    email,
    created_at
  FROM users
  WHERE id = $1
  `,
  [userId]
);

pool.query() automatically:

acquires connection
        ↓
executes query
        ↓
returns connection to pool

You do not manually release anything.

This reduces the chance of connection leaks.

For simple one-query operations, this is usually the preferred node-postgres pattern.


Transactions Need One Checked-Out Client

Transactions are different.

A PostgreSQL transaction belongs to one database connection.

This is incorrect:

await pool.query("BEGIN");

await pool.query(
  "UPDATE accounts SET balance = balance - 100 WHERE id = $1",
  [fromAccount]
);

await pool.query(
  "UPDATE accounts SET balance = balance + 100 WHERE id = $1",
  [toAccount]
);

await pool.query("COMMIT");

Why?

Because each pool.query() can use a different connection.

The correct pattern is:

const client =
  await pool.connect();

try {

  await client.query("BEGIN");

  await client.query(
    `
    UPDATE accounts
    SET balance = balance - $1
    WHERE id = $2
    `,
    [100, fromAccount]
  );

  await client.query(
    `
    UPDATE accounts
    SET balance = balance + $1
    WHERE id = $2
    `,
    [100, toAccount]
  );

  await client.query("COMMIT");

} catch (error) {

  await client.query("ROLLBACK");

  throw error;

} finally {

  client.release();

}

The most important line is:

client.release();

inside:

finally

That guarantees the connection is returned to the pool whether the transaction succeeds or fails.


How a Node.js Connection Leak Happens

Consider:

app.get("/report", async (req, res) => {

  const client =
    await pool.connect();

  const result =
    await client.query(
      "SELECT * FROM reports"
    );

  res.json(result.rows);
});

What is missing?

client.release();

The request finishes.

The connection does not return to the pool.

After enough requests:

Connection 1 → leaked
Connection 2 → leaked
Connection 3 → leaked
...
Connection 10 → leaked

Then:

pool is full
+
every client is checked out

Future calls to:

pool.connect()

must wait.

Eventually the application appears to have a database outage even though PostgreSQL itself may be perfectly healthy.

This is one of the most common connection-pool failures.


Always Use try...finally When Manually Acquiring a Client

The safe pattern is:

const client =
  await pool.connect();

try {

  // use client

} finally {

  client.release();

}

Not:

const client =
  await pool.connect();

await doSomething();

client.release();

because if:

doSomething()

throws an exception, execution may never reach:

client.release()

and the connection leaks.


Monitor the Node.js Pool

node-postgres exposes useful pool counters.

For example:

console.log({
  total: pool.totalCount,
  idle: pool.idleCount,
  waiting: pool.waitingCount
});

These represent:

MetricMeaning
totalCountTotal clients currently managed by the pool
idleCountClients currently available
waitingCountRequests queued waiting for a connection

One particularly useful warning signal is:

waitingCount > 0

for sustained periods.

That means application work is waiting because no pooled connection is currently available.

It does not automatically mean the pool should be increased.

The database may already be overloaded.

Investigate both sides.


Gracefully Close the Node.js Pool

For long-running APIs, create the pool once and keep it for the process lifetime.

Do not run:

await pool.end();

after every HTTP request.

But when the application itself is shutting down, draining the pool is appropriate.

For example:

async function shutdown(signal) {

  console.log(
    `Received ${signal}`
  );

  try {

    await pool.end();

  } finally {

    process.exit(0);

  }
}

process.on(
  "SIGTERM",
  () => shutdown("SIGTERM")
);

process.on(
  "SIGINT",
  () => shutdown("SIGINT")
);

In a production server, graceful shutdown may also include stopping new HTTP requests before closing the database pool.


Python PostgreSQL Pooling with Psycopg 3

Modern Psycopg provides connection pooling through:

psycopg_pool

Install Psycopg and the pool package:

pip install "psycopg[binary]" psycopg_pool

Then create the pool once for the application process.

import os

from psycopg_pool import ConnectionPool


pool = ConnectionPool(
    conninfo=os.environ["DATABASE_URL"],

    min_size=0,
    max_size=10,

    timeout=5,

    max_waiting=50,

    max_idle=60,

    max_lifetime=1800,

    open=True,
)

The important settings include:

SettingPurpose
min_sizeMinimum connections maintained by the pool
max_sizeMaximum connections the pool can hold
timeoutMaximum wait for a connection
max_waitingMaximum number of clients allowed to queue
max_idleHow long excess idle connections remain
max_lifetimeMaximum lifetime of a pooled connection

For serverless applications, a low:

min_size

can be useful because every application instance maintaining several idle minimum connections can create a surprisingly large database baseline.


Borrowing a Psycopg Connection Safely

Use the pool’s context manager.

def get_user(user_id: int):

    with pool.connection() as conn:

        result = conn.execute(
            """
            SELECT
                id,
                email,
                created_at
            FROM users
            WHERE id = %s
            """,
            (user_id,),
        )

        return result.fetchone()

When execution leaves:

with pool.connection() as conn:

the connection is returned to the pool.

This is considerably safer than manually acquiring connections and relying on every code path to return them correctly.


Python Transactions with Psycopg Pool

The same pattern works for transactions.

def transfer_balance(
    from_account: int,
    to_account: int,
    amount: int,
):

    with pool.connection() as conn:

        conn.execute(
            """
            UPDATE accounts
            SET balance = balance - %s
            WHERE id = %s
            """,
            (
                amount,
                from_account,
            ),
        )

        conn.execute(
            """
            UPDATE accounts
            SET balance = balance + %s
            WHERE id = %s
            """,
            (
                amount,
                to_account,
            ),
        )

With the normal Psycopg connection context behavior used by the pool, successful exit commits the transaction while an exception causes rollback before the connection is returned.

This avoids leaving connections stuck in an unfinished transaction.


Why idle in transaction Is Dangerous

One particularly harmful state in PostgreSQL is:

idle in transaction

This means:

Transaction started
      ↓
Some SQL executed
      ↓
Application stopped doing database work
      ↓
Transaction remains open

The connection may appear idle, but the transaction is still alive.

Long-running idle transactions can interfere with:

vacuum cleanup
locking
schema changes
row-version cleanup

and can retain database resources much longer than intended.

Your pool implementation should therefore ensure transactions always reach:

COMMIT

or:

ROLLBACK

before the connection is returned for reuse.


Inspect Connections with pg_stat_activity

PostgreSQL provides:

pg_stat_activity

for monitoring database sessions.

A simple connection count:

SELECT
    COUNT(*) AS connections
FROM pg_stat_activity
WHERE datname = current_database();

Break it down by state:

SELECT
    state,
    COUNT(*) AS connection_count
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state
ORDER BY connection_count DESC;

You may see states such as:

active
idle
idle in transaction
idle in transaction (aborted)

depending on current activity.


Find Long-Lived Idle Transactions

A useful diagnostic query is:

SELECT
    pid,
    usename,
    application_name,
    client_addr,
    xact_start,
    state_change,
    state,
    query
FROM pg_stat_activity
WHERE state IN (
    'idle in transaction',
    'idle in transaction (aborted)'
)
ORDER BY xact_start;

Long-lived results deserve investigation.

The problem may be:

missing commit
missing rollback
exception handling
forgotten connection cleanup
slow application code inside transaction

Access to activity details can vary according to PostgreSQL permissions, so monitoring roles should be configured appropriately.


Set application_name

When several services share the same database, connection monitoring becomes much easier if sessions identify themselves.

For Node.js:

const pool = new Pool({
  connectionString:
    process.env.DATABASE_URL,

  application_name:
    "orders-api",

  max: 8
});

For Psycopg, the PostgreSQL connection string can include:

application_name=orders-worker

Then:

SELECT
    application_name,
    state,
    COUNT(*) AS connections
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY
    application_name,
    state
ORDER BY
    application_name,
    state;

Now you can distinguish:

orders-api
orders-worker
report-service
migration-tool

rather than seeing an unexplained wall of PostgreSQL sessions.


Connection Pooling in Autoscaling Environments

This is where pool sizing becomes particularly important.

Suppose your API runs on Google Cloud Run.

One instance contains:

pool max = 10

At one instance:

10 possible database connections

At five:

50

At 20:

200

At 50:

500

Cloud Run itself may be perfectly capable of creating those instances.

Your PostgreSQL database may not be capable of accepting that many connections.

Autoscaling compute and database connection capacity are separate things.

For the container deployment side of this architecture, see:

Deploying Containerized Python & Express Apps to Google Cloud Run


Cloud Run Concurrency Changes the Calculation

Assume:

Cloud Run concurrency = 40

pool max = 5

One application instance could theoretically serve many simultaneous HTTP requests while allowing only five requests to interact with PostgreSQL concurrently.

Other requests may:

execute application code
wait for external APIs
serve cached responses
wait for pooled database connections

That can be completely reasonable.

You do not need:

40 HTTP requests
=
40 PostgreSQL connections

A database pool is partly a mechanism for decoupling HTTP concurrency from database concurrency.


Cap Application Instances When the Database Is the Bottleneck

If your database can safely give one service:

60 connections

and each application instance has:

pool max = 6

then:

60 ÷ 6 = 10

application instances could theoretically consume that entire connection budget.

Allowing:

maximum instances = 100

while keeping:

pool max = 6

still allows the potential application demand to vastly exceed the intended database capacity.

Connection-pool limits and autoscaling limits should therefore be designed together.


Cloud SQL Adds Another Connection Boundary

When using Cloud Run with Cloud SQL for PostgreSQL, Google also documents a per-instance Cloud SQL connection limit for Cloud Run.

But application pool limits should normally be much smaller than simply trying to consume the platform maximum.

The more important architecture question remains:

How many database connections can the entire database
safely support across every application?

not:

How many connections can this individual container open?

Google’s Cloud SQL documentation recommends connection pooling specifically to control connection counts and reuse database sessions.


Scheduled Jobs Can Also Exhaust Connections

Imagine a scheduled data-processing job that runs every hour.

The application configuration is:

pool max = 20

Normally there is one job.

Then a slow run overlaps with the next scheduled execution.

Now:

Job A → up to 20 connections
Job B → up to 20 connections

A third retry begins:

Job C → up to 20 connections

Potential total:

60

from one logical workflow.

This is one reason scheduled tasks need:

overlap control
idempotency
retry limits
connection limits

in addition to a cron expression.

For the scheduling layer, see:

CRON vs GitHub Actions vs Google Cloud Scheduler


Webhook Traffic Can Create Connection Spikes

Webhook receivers can have the same issue.

Imagine:

10,000 events

arrive in a short period.

If the application creates a database connection for every event without pooling:

Webhook burst
    ↓
Connection burst
    ↓
PostgreSQL exhaustion

A better architecture is:

Webhook
   ↓
Application
   ↓
Bounded Connection Pool
   ↓
PostgreSQL

or, for larger bursts:

Webhook
   ↓
Queue
   ↓
Workers
   ↓
Bounded Pools
   ↓
PostgreSQL

For secure webhook ingestion and asynchronous processing patterns, see:

Setting Up Webhook Listeners in Express.js: Handling Real-Time Payloads Securely


Connection Timeouts Are Important

Imagine a pool with:

max = 10

All ten connections are busy.

Request 11 arrives.

What should happen?

One option is:

wait forever

That is usually a poor production strategy.

A bounded acquisition timeout is safer.

Node example:

const pool = new Pool({

  connectionString:
    process.env.DATABASE_URL,

  max: 10,

  connectionTimeoutMillis: 5000

});

Psycopg:

pool = ConnectionPool(

    conninfo=
        os.environ["DATABASE_URL"],

    min_size=0,

    max_size=10,

    timeout=5,

    open=True,
)

Now pool exhaustion becomes an observable application failure instead of an unlimited wait.

That gives you something you can:

measure
alert on
fail gracefully
investigate

A Timeout Is Not Automatically a Reason to Increase the Pool

Suppose connection acquisition repeatedly takes longer than five seconds.

Increasing:

max_size

might help.

Or it might make everything worse.

The actual problem could be:

slow SQL
missing indexes
database CPU saturation
lock contention
long transactions
connection leaks
external services holding transactions open

Before increasing the pool, investigate why existing connections remain occupied.


Idle Connections Are Not the Same as Leaked Connections

These two states are easy to confuse.

Idle pooled connection

Connected
+
available for reuse

This is normal.

Leaked connection

Checked out by application
+
never returned to pool

This is a bug.

A healthy pool normally contains some idle connections.

The objective is not:

zero idle connections at all times

It is:

controlled connection count
+
connections returned correctly
+
reasonable utilization

Pool Minimums Can Multiply Too

Suppose a Python pool uses:

min_size = 5

and the platform starts:

30 application instances

The application may eventually maintain a baseline approaching:

30 × 5 = 150

minimum pooled connections, depending on process lifetime and pool behavior.

That is why I normally treat:

min_size

with caution in serverless and aggressively autoscaling environments.

A low or zero minimum can be more appropriate when idle capacity changes rapidly.

Long-lived, predictable servers may benefit from keeping a small warm connection baseline.

Again, workload matters.


Connection Lifetime Helps Refresh Long-Lived Sessions

Database infrastructure changes.

Connections may pass through:

load balancers
proxies
managed database infrastructure
network devices

A connection that lives forever is not necessarily desirable.

Both modern node-postgres and Psycopg pooling provide ways to retire older pooled connections.

Node:

maxLifetimeSeconds: 1800

Psycopg:

max_lifetime=1800

The exact lifetime should reflect your environment.

This is not a replacement for proper error handling; network connections can still fail unexpectedly at any age.


Handle Broken Connections

A pool is not permission to assume every connection will remain healthy forever.

PostgreSQL may restart.

The network may fail.

Managed database infrastructure may terminate a connection.

For Node.js, node-postgres recommends handling pool errors because even idle clients remain connected to a live backend and can receive errors.

pool.on(
  "error",
  (error) => {

    console.error(
      "Unexpected PostgreSQL error",
      error
    );

  }
);

Psycopg pools can also perform connection health checks and replace unusable connections.

Application logic should therefore assume that database operations can fail even with a correctly configured pool.


Do Not Blindly Retry Transactions

Suppose:

transaction begins
      ↓
first UPDATE succeeds
      ↓
connection disappears

The application does not necessarily know enough to simply repeat the last SQL statement.

Transaction retries need to restart the logical transaction safely.

Google’s Cloud SQL documentation makes the same distinction: retrying connection acquisition can make sense, but failures in the middle of a transaction require application-level transaction retry logic.

This is similar to other transient-failure handling.

For broader retry design, see:

REST API Rate Limiting and Exponential Backoff in Python


Application Pooling vs. PgBouncer

Eventually, application-side pools may not be enough.

Imagine:

100 application instances

× pool max 5

=
500 possible PostgreSQL client connections

Reducing each application pool further may help.

Another option is placing a dedicated PostgreSQL connection pooler between applications and the database.

One widely used option is:

PgBouncer

Architecture:

Node / Python Apps
        ↓
Application Pools
        ↓
PgBouncer
        ↓
Smaller Number of PostgreSQL Sessions
        ↓
PostgreSQL

PgBouncer presents itself to applications like PostgreSQL while reusing backend database connections.


PgBouncer Pooling Modes

PgBouncer provides three main pooling modes.

ModeBackend Connection Released
SessionWhen the client disconnects
TransactionWhen the transaction ends
StatementWhen each statement ends

Session Pooling

A PostgreSQL server connection remains assigned for the entire client session.

This preserves normal PostgreSQL session behavior most closely.

Transaction Pooling

The backend connection returns to PgBouncer after each transaction.

This can dramatically increase connection reuse.

But session-level PostgreSQL behavior can no longer be assumed between transactions.

Features that depend on persistent session state need careful review.

Statement Pooling

A backend connection becomes available immediately after each statement.

Multi-statement transactions are therefore not supported in this mode.

This is much more restrictive and is not the normal choice for typical transactional applications.


Don’t Follow Outdated PgBouncer Advice Blindly

You may encounter old articles claiming:

Prepared statements never work with
PgBouncer transaction pooling.

That statement is now too broad.

Modern PgBouncer can support protocol-level prepared statements in transaction pooling when:

max_prepared_statements

is configured appropriately.

However, transaction pooling still changes session semantics, and several PostgreSQL features that depend on session-level state remain incompatible or require special handling.

Always check the PgBouncer documentation for the specific features your application uses.


When an External Pooler Makes Sense

Consider PgBouncer or managed pooling when you have:

large numbers of application instances
serverless applications
many microservices
short-lived clients
frequent connection spikes
multiple worker fleets

and PostgreSQL’s connection count is becoming a scaling constraint.

It is not automatically required for every application.

For:

two API instances
+
pool max 5

adding another infrastructure layer may provide little value.

Architecture should solve a real problem, not merely look sophisticated.


Cloud SQL Managed Connection Pooling

Google Cloud SQL for PostgreSQL also provides a Managed Connection Pooling capability.

Instead of every application connection directly mapping to a PostgreSQL backend connection, clients can connect through a managed intermediary pool.

Google specifically identifies serverless and microservice architectures as workloads where frequent or large numbers of connections can create overhead.

That can be particularly relevant when combining:

Cloud Run
+
Cloud SQL
+
autoscaling application instances

Application-side pooling is still useful because it provides local concurrency control and prevents an individual process from generating unlimited connection demand.

Managed/external pooling and application pooling therefore solve different layers of the problem.


SQLAlchemy Users Need the Same Discipline

Many Python applications use SQLAlchemy rather than calling Psycopg directly.

A typical engine might look like:

from sqlalchemy import create_engine

engine = create_engine(
    database_url,

    pool_size=5,

    max_overflow=2,

    pool_timeout=5,

    pool_recycle=1800,

    pool_pre_ping=True,
)

Potential simultaneous connections from that process can reach:

pool_size + max_overflow

In this example:

5 + 2 = 7

If that application scales to 20 instances:

20 × 7 = 140

potential connections.

Changing libraries does not change the underlying capacity problem.


Don’t Hold a Database Connection While Doing Unrelated Work

Consider:

Acquire DB connection
      ↓
Query database
      ↓
Call external API for 8 seconds
      ↓
Process file
      ↓
Query database
      ↓
Release connection

The database connection remains occupied while the application waits for unrelated work.

A better design may be:

Acquire
↓
Query
↓
Release

Call external API

Acquire
↓
Update
↓
Release

subject to the transaction semantics required by the operation.

The shorter the connection checkout time, the more work a small pool can handle.


Keep Transactions Short

A similar principle applies to transactions.

Avoid:

BEGIN
↓
Query
↓
Call external service
↓
Wait 20 seconds
↓
More application logic
↓
UPDATE
↓
COMMIT

unless the transaction genuinely requires that behavior.

Long transactions consume pooled connections and may hold database locks or row versions.

A better pattern is to perform non-database work outside the transaction whenever correctness permits.


Pool Size and Query Performance Are Connected

Suppose every query takes:

50 milliseconds

A five-connection pool can process far more database operations per second than if every query takes:

8 seconds

Before increasing pool capacity, optimize:

slow queries
missing indexes
N+1 queries
unnecessary round trips
large scans
lock contention

Sometimes the best connection-pool optimization is simply:

make each query release its connection sooner

Useful Production Metrics

A connection-pooling dashboard should ideally monitor both the application and PostgreSQL.

Application-side metrics

Track:

pool total connections
pool idle connections
pool busy connections
pool waiting requests
connection acquisition latency
connection timeouts
query latency
transaction duration

PostgreSQL metrics

Track:

total connections
active connections
idle connections
idle-in-transaction connections
long-running queries
lock waits
database CPU
memory
I/O

Looking at only one side can produce misleading conclusions.


A Useful PostgreSQL Connection Dashboard Query

For a quick breakdown:

SELECT
    application_name,
    state,
    COUNT(*) AS connections
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY
    application_name,
    state
ORDER BY
    connections DESC;

That can immediately reveal situations such as:

orders-api      idle      45
orders-worker   active    20
reporting       active    10

which is much easier to reason about than simply knowing:

75 connections exist

Find Long-Running Active Queries

You can also inspect long-running work:

SELECT
    pid,
    application_name,
    usename,
    now() - query_start AS runtime,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
WHERE
    state = 'active'
    AND query_start IS NOT NULL
ORDER BY query_start;

A pool may appear saturated simply because a few expensive queries are holding connections for a long time.

Connection count is therefore only one part of the investigation.


A Practical Pool-Sizing Process

Rather than choosing a number from a blog post, use a repeatable process.

Step 1: Determine the Database Connection Budget

Start with the database’s actual capacity.

Then reserve capacity for:

administration
monitoring
migrations
other applications
emergency access

Suppose the API receives:

60 connections

of the total budget.

Step 2: Determine Maximum Application Instances

Suppose:

maximum instances = 12

Step 3: Calculate a Safe Starting Pool Maximum

60 ÷ 12 = 5

Start around:

max pool size = 5

Step 4: Load Test

Measure:

request latency
pool waiting time
database CPU
query duration
connection utilization

Step 5: Adjust

If:

pool queue high
+
database lightly loaded

a modest pool increase may help.

If:

pool queue high
+
database CPU saturated

increasing the pool may worsen the problem.


Production Example: Node.js API

A practical starting configuration could look like:

import { Pool } from "pg";

const pool = new Pool({

  connectionString:
    process.env.DATABASE_URL,

  application_name:
    process.env.APP_NAME ||
    "orders-api",

  max: Number(
    process.env.DB_POOL_MAX || 5
  ),

  connectionTimeoutMillis:
    Number(
      process.env
        .DB_CONNECTION_TIMEOUT_MS ||
      5000
    ),

  idleTimeoutMillis:
    Number(
      process.env
        .DB_IDLE_TIMEOUT_MS ||
      30000
    ),

  maxLifetimeSeconds:
    Number(
      process.env
        .DB_MAX_LIFETIME_SECONDS ||
      1800
    )
});


pool.on(
  "error",
  (error) => {

    console.error(
      "Unexpected database pool error",
      error
    );

  }
);


export { pool };

The values are intentionally configurable.

Production limits should be deployment configuration, not unexplained magic numbers buried throughout application code.


Production Example: Python API

A comparable Psycopg configuration:

import os

from psycopg_pool import ConnectionPool


pool = ConnectionPool(

    conninfo=
        os.environ["DATABASE_URL"],

    min_size=
        int(
            os.getenv(
                "DB_POOL_MIN",
                "0",
            )
        ),

    max_size=
        int(
            os.getenv(
                "DB_POOL_MAX",
                "5",
            )
        ),

    timeout=
        float(
            os.getenv(
                "DB_POOL_TIMEOUT",
                "5",
            )
        ),

    max_waiting=
        int(
            os.getenv(
                "DB_POOL_MAX_WAITING",
                "50",
            )
        ),

    max_idle=
        float(
            os.getenv(
                "DB_POOL_MAX_IDLE",
                "60",
            )
        ),

    max_lifetime=
        float(
            os.getenv(
                "DB_POOL_MAX_LIFETIME",
                "1800",
            )
        ),

    open=True,
)

Again:

5

is not being presented as a universal optimum.

It is simply a conservative example from which real load testing can begin.


Production Checklist

Before deploying a PostgreSQL-backed Node.js or Python service, I would review at least these items:

AreaCheck
Pool creationOne pool per application process
Pool maximumExplicitly bounded
Global budgetAccounts for all app instances
AutoscalingMaximum instance count considered
Connection timeoutBounded
Idle timeoutConfigured appropriately
Connection lifetimeConsidered
Node clientsAlways released in finally
Python connectionsContext manager used
TransactionsAlways commit or rollback
Transaction durationKept short
QueriesParameterized
MonitoringPool waiting visible
PostgreSQLpg_stat_activity monitored
Application nameSet for identification
ShutdownPools closed gracefully
External poolerConsidered only when needed
SecretsCredentials stored securely
TLS/authenticationProvider-recommended configuration used

Common Mistakes

Creating a new database connection for every request

This repeatedly pays connection setup cost and can cause sudden connection spikes.

Use a bounded pool.

Creating a new pool for every request

This is even worse.

Create the pool once per application process.

Forgetting to release Node.js clients

Eventually:

pool = completely checked out

and requests stop making progress.

Use:

try / finally

Using pool.query() for multi-statement transactions

Individual calls may run on different clients.

Acquire one client for the transaction.

Increasing pool size whenever requests wait

The database may already be saturated.

Measure first.

Setting a high minimum pool size in serverless systems

Minimum connections multiply across instances.

Ignoring application autoscaling

A pool limit applies to one application process.

It is not a global limit.

Increasing PostgreSQL max_connections indefinitely

Every connection consumes resources.

A larger limit can move the bottleneck rather than fix it.

Leaving transactions idle

idle in transaction should not become a normal application state.

Holding connections during slow external API calls

Return database connections as soon as they are no longer needed.


The Architecture I Prefer

For a normal API:

HTTP Requests
      ↓
Application Instance
      ↓
Small Bounded Pool
      ↓
PostgreSQL

For an autoscaling application:

Load Balancer
      ↓
┌─────────────┐
│ App Instance │ → pool max 5
├─────────────┤
│ App Instance │ → pool max 5
├─────────────┤
│ App Instance │ → pool max 5
└─────────────┘
      ↓
PostgreSQL

For much larger serverless environments:

Many Application Instances
        ↓
Small Application Pools
        ↓
Managed Pooler / PgBouncer
        ↓
Controlled PostgreSQL Sessions
        ↓
PostgreSQL

Each layer has a different responsibility.

The application pool controls concurrency inside one application process.

An external pooler controls how many application-side client connections ultimately become PostgreSQL backend sessions.

PostgreSQL still defines the final database capacity.


Final Thoughts

Connection pooling is not primarily about making database connections look cleaner in code.

It is a capacity-management mechanism.

A production PostgreSQL architecture has to reconcile three different numbers:

HTTP concurrency
database query concurrency
database connection capacity

Those numbers do not need to be equal.

A well-designed system might have:

Thousands of HTTP requests
        ↓
Hundreds of application workers
        ↓
Tens of active database connections

and perform better than a design where every request creates its own PostgreSQL connection.

For Node.js, the safe default pattern is:

one pg.Pool per process
+
pool.query() for simple operations
+
pool.connect() for transactions
+
client.release() in finally

For Python with Psycopg:

one ConnectionPool per process
+
with pool.connection()
+
bounded max_size
+
bounded waiting

For autoscaling infrastructure:

maximum instances
×
pool maximum

must be included in the database capacity calculation.

And when application fleets become large enough that even small per-instance pools create too many backend sessions, an external or managed connection pooler becomes the next architectural layer to consider.

The goal is not to maximize the number of PostgreSQL connections.

The goal is to maintain just enough database concurrency to keep the application responsive without allowing the application tier to overwhelm the database.

That is what connection pooling is really for.


Official Documentation and Technical References

The technical behavior described in this article was checked against current PostgreSQL, node-postgres, Psycopg, PgBouncer, and Google Cloud documentation.

PostgreSQL

PostgreSQL 18 Documentation — Connections and Authentication

PostgreSQL 18 — Monitoring Database Activity and pg_stat_activity

Node.js / node-postgres

node-postgres — Connection Pooling

node-postgres — Pool API

node-postgres — Transactions

node-postgres — Pool Sizing

Python / Psycopg

Psycopg 3 — Connection Pools

Psycopg 3 — Connection Pool API

PgBouncer

PgBouncer — Features and Pooling Modes

PgBouncer — Configuration

PgBouncer — Frequently Asked Questions

Google Cloud SQL

Cloud SQL for PostgreSQL — Manage Database Connections

Cloud SQL for PostgreSQL — Connect from Cloud Run

Cloud SQL for PostgreSQL — Managed Connection Pooling

Scroll to Top