Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Connection Management and Pooling

For .NET engineers who know: ADO.NET connection pooling, SqlConnection, IDbContextFactory<T>, and connection string configuration You’ll learn: How Node.js connection pooling differs from ADO.NET’s automatic model, why serverless deployments break standard pooling, and when PgBouncer is not optional Time: 15-20 min


The .NET Way (What You Already Know)

ADO.NET connection pooling is automatic and transparent. You never think about it. You call new SqlConnection(connectionString), open it, use it, and dispose it. The pool manages everything underneath.

// You write this
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
var result = await connection.QueryAsync<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = id });

// The pool handles:
// - Maintaining a reusable set of open TCP connections
// - Handing a connection to you from the pool (or creating a new one)
// - Returning the connection to the pool on Dispose()
// - Validating the connection is still alive
// - Closing idle connections after timeout

With EF Core, DbContext wraps this further:

// In Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql => {
        npgsql.CommandTimeout(30);
        // Pool size defaults: min 0, max 100 per process
    })
);

// In your service — the DI container manages lifetime (Scoped by default)
public class UserService
{
    private readonly AppDbContext _db;
    public UserService(AppDbContext db) { _db = db; }

    public async Task<User?> GetUser(int id) =>
        await _db.Users.FindAsync(id);
}

With Npgsql (the .NET PostgreSQL driver), the pool behavior is:

ParameterDefaultWhat it means
Minimum Pool Size0Connections to keep alive when idle
Maximum Pool Size100Hard ceiling on concurrent connections
Connection Idle Lifetime300sHow long before an idle connection is closed
Connection Pruning Interval10sHow often idle connections are checked

ASP.NET Core registers DbContext as Scoped — one instance per HTTP request, disposed at request end. The underlying connection returns to the pool on disposal. From a pool perspective, your application uses at most one connection per in-flight request, bounded by MaxPoolSize.


The Node.js Way

Why Explicit Configuration is Required

Node.js has no built-in connection pooling equivalent to ADO.NET. Each PostgreSQL library ships its own pool implementation, and you configure it explicitly. The good news: the defaults are reasonable for long-running servers. The bad news: serverless environments break the entire model.

Prisma Connection Pooling

Prisma embeds the pg driver internally. The connection pool is configured via query parameters on the DATABASE_URL connection string or via PrismaClient options.

// .env
DATABASE_URL="postgresql://user:password@host:5432/mydb?connection_limit=10&pool_timeout=20"

// Or via the generator in schema.prisma (Prisma 5+)
// datasource db {
//   provider = "postgresql"
//   url      = env("DATABASE_URL")
// }

// PrismaClient instantiation — usually one instance per process
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
  // datasources override (rarely needed — prefer DATABASE_URL params)
});

export default prisma;

Singleton pattern is mandatory. Unlike DbContext in .NET (scoped per request), PrismaClient should be a process-level singleton. Each instance opens its own pool. Creating one per request is a severe bug — you will exhaust your database’s connection limit almost immediately.

// WRONG — one PrismaClient per request
app.get('/users', async (req, res) => {
  const prisma = new PrismaClient(); // opens a new pool connection set
  const users = await prisma.user.findMany();
  await prisma.$disconnect(); // wastes time on every request
  res.json(users);
});

// CORRECT — module-level singleton
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ['error'],
  });

// In development, Next.js hot reload creates new module instances.
// The globalThis trick preserves the singleton across hot reloads.
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

Prisma connection pool parameters:

ParameterConnection string keyDefaultRecommendation
Pool sizeconnection_limitnum_cpus * 2 + 1Match to DB max_connections minus headroom
Pool timeoutpool_timeout10sRaise to 20-30s for slow queries
Connection timeoutconnect_timeout5sFine for most setups
Socket timeoutsocket_timeoutNoneSet to 30s in production
# Example for a Render PostgreSQL free tier (max 25 connections)
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=5&pool_timeout=20"

Drizzle Connection Options

Drizzle is a query builder — it does not own the pool. You provide the pool yourself via node-postgres (pg) or postgres.js.

// Using node-postgres (pg)
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,                // Maximum pool size
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
  ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});

export const db = drizzle(pool, { schema });

// Using postgres.js (better performance, native binary support)
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';

const sql = postgres(process.env.DATABASE_URL!, {
  max: 10,
  idle_timeout: 30,
  connect_timeout: 5,
});

export const db = drizzle(sql, { schema });

Pool size comparison:

// node-postgres (pg) Pool options
const pool = new Pool({
  max: 10,                    // Max connections (default: 10)
  min: 2,                     // Minimum idle connections (default: 0)
  idleTimeoutMillis: 30_000,  // Remove idle connections after 30s
  connectionTimeoutMillis: 2_000, // Throw if can't acquire in 2s
  allowExitOnIdle: true,      // Allow process to exit when pool is idle (useful for scripts)
});

// postgres.js options
const sql = postgres(url, {
  max: 10,           // Max connections
  idle_timeout: 30,  // Seconds before closing idle connections
  max_lifetime: 3600, // Max lifetime of a connection in seconds (avoids stale connections)
  connect_timeout: 5, // Seconds to wait for connection
  prepare: true,      // Use prepared statements (better performance)
});

PgBouncer — The External Pooler

PgBouncer sits between your application and PostgreSQL and multiplexes many short-lived application connections onto a smaller number of real server connections. For serverless and edge deployments, it is effectively required.

graph LR
    subgraph without["Without PgBouncer"]
        L1a["Lambda function #1"]
        L2a["Lambda function #2"]
        LNa["Lambda function #N"]
        PG1a["PostgreSQL\n(connection 1)"]
        PG2a["PostgreSQL\n(connection 2)"]
        PGNa["PostgreSQL\n(connection N)"]
        L1a --> PG1a
        L2a --> PG2a
        LNa --> PGNa
    end

    subgraph with["With PgBouncer"]
        L1b["Lambda function #1"]
        L2b["Lambda function #2"]
        L3b["Lambda function #3"]
        LNb["Lambda function #N"]
        PGB["PgBouncer"]
        PG1b["PostgreSQL\n(connection 1)"]
        PG2b["PostgreSQL\n(connection 2, reused)"]
        L1b --> PGB
        L2b --> PGB
        L3b --> PGB
        LNb --> PGB
        PGB --> PG1b
        PGB --> PG2b
    end

PgBouncer modes:

ModeHow it worksUse case
sessionOne server connection per client sessionLong-lived connections, full PostgreSQL feature support
transactionServer connection released after each transactionServerless, high concurrency — most common choice
statementReleased after each statementRarely used; breaks multi-statement transactions

transaction mode is what you want for serverless. One important constraint: named prepared statements and SET session variables do not survive across transactions in transaction mode. Prisma’s Accelerate and Supabase’s Supavisor handle this transparently.

Render PostgreSQL free tier limits:

Render’s free tier allows 25 connections maximum. This is a hard limit at the PostgreSQL server level — not a soft pool limit.

# Render free tier allocation strategy:
# - Reserve 5 connections for admin/monitoring
# - Give your Node.js app a pool of 10
# - Give your dev/migration tooling 5
# - Leave 5 in reserve

DATABASE_URL="postgresql://user:pass@host/db?connection_limit=10&pool_timeout=20"

If you deploy to a serverless platform (Vercel, AWS Lambda, Cloudflare Workers), add PgBouncer or use Prisma Accelerate:

// Prisma Accelerate (Prisma's managed connection pooler + query cache)
// schema.prisma: datasource db { url = env("PRISMA_ACCELERATE_URL") }
// Uses the @prisma/extension-accelerate package

import { PrismaClient } from '@prisma/client';
import { withAccelerate } from '@prisma/extension-accelerate';

const prisma = new PrismaClient().$extends(withAccelerate());

// With caching
const user = await prisma.user.findUnique({
  where: { id: userId },
  cacheStrategy: { ttl: 60, swr: 30 }, // cache for 60s, stale-while-revalidate 30s
});

Monitoring Connection Usage

// Monitor pool health with node-postgres
pool.on('connect', () => console.log('New DB connection created'));
pool.on('acquire', () => console.log('Connection acquired from pool'));
pool.on('remove', () => console.log('Connection removed from pool'));

// Log pool stats periodically
setInterval(() => {
  console.log({
    totalCount: pool.totalCount,
    idleCount: pool.idleCount,
    waitingCount: pool.waitingCount,
  });
}, 30_000);

// With Prisma — no direct pool introspection API
// Use pg_stat_activity in PostgreSQL instead:
// SELECT count(*), state, wait_event_type
// FROM pg_stat_activity
// WHERE datname = 'mydb'
// GROUP BY state, wait_event_type;

Key Differences

ConcernADO.NET / Npgsql / EF CorePrismaDrizzle + pg
Pool managementAutomatic, transparentBuilt-in, config via URL paramsManual — you create the Pool
DbContext lifetimeScoped per request (DI)Module-level singletonModule-level singleton
Hot reload safeYes (DI manages it)Requires globalThis trickSame
ServerlessWorks (connections held briefly)Breaks without Accelerate/PgBouncerBreaks without PgBouncer
Pool size default100 (Npgsql)num_cpus * 2 + 110 (pg), 10 (postgres.js)
External poolerPgBouncer optionalAccelerate or PgBouncer for serverlessPgBouncer for serverless
Pool monitoringCounters on NpgsqlDataSourcepg_stat_activity onlypool.totalCount etc.
Connection validationAutomaticAutomatic (retry on failure)Manual (pg validates on acquire)

Gotchas for .NET Engineers

1. Creating PrismaClient per request is a pool-exhausting bug

The single most common mistake from engineers coming from EF Core’s scoped DbContext model. DbContext is cheap to construct — the underlying pool is separate and managed by Npgsql. PrismaClient creates its own pool on instantiation. One per request means hundreds of pools, each trying to open connections, immediately overwhelming your PostgreSQL’s max_connections.

The fix is the module-level singleton shown above. In NestJS, register PrismaClient as a provider with module scope, not request scope.

// NestJS — correct registration
@Module({
  providers: [
    {
      provide: PrismaClient,
      useFactory: () => {
        const prisma = new PrismaClient();
        return prisma;
      },
    },
  ],
  exports: [PrismaClient],
})
export class DatabaseModule {}

2. Serverless cold starts create new pool connections on every invocation

In .NET, a long-running process holds its pool for the lifetime of the app. In serverless (AWS Lambda, Vercel Edge Functions), each invocation may spin up a fresh process, establish new connections, and then the process is frozen or terminated. Even with a module-level singleton, a cold start means new TCP connections to PostgreSQL.

At low concurrency this is tolerable. At high concurrency, hundreds of simultaneous cold starts each open their own pool connections, spiking past the database’s max_connections limit instantly. PgBouncer or a managed pooler (Prisma Accelerate, Supabase Supavisor) is the correct mitigation.

// Vercel / Lambda — with Prisma Accelerate
// The connection pooling happens at Accelerate's edge layer,
// not at the Lambda level. Your function connects to Accelerate,
// not directly to PostgreSQL.
const prisma = new PrismaClient({
  datasources: {
    db: { url: process.env.PRISMA_ACCELERATE_URL },
  },
}).$extends(withAccelerate());

3. PgBouncer transaction mode breaks session-level features

If you rely on PostgreSQL advisory locks, SET LOCAL, temporary tables, or prepared statements across a session, PgBouncer in transaction mode will silently break them. Your application code assumes it has a stable server connection for the duration of a session; PgBouncer reassigns the server connection after each transaction.

-- This DOES NOT work with PgBouncer transaction mode:
-- The SET applies to the server connection, which is returned to the pool
-- after the transaction. The next statement may get a different server connection.
SET search_path TO tenant_schema;
SELECT * FROM users;  -- may run on a connection with default search_path

If you need session-level features, use session mode or avoid PgBouncer for those specific connection paths.

4. Node.js needs fewer connections than you expect — but serverless needs a pooler anyway

The single-threaded event loop in Node.js means your application can handle hundreds of concurrent requests on a handful of DB connections. Ten to twenty connections is often sufficient for a busy long-running Node.js server. This is very different from ASP.NET Core, where each thread needs its own connection and you might want 50-100 connections for a busy API.

The paradox: Node.js needs fewer connections per server instance, but serverless means many server instances, each wanting their own pool. PgBouncer resolves this by acting as the single pooled gateway.

5. Pool exhaustion silently degrades to queuing, then errors

When ADO.NET’s pool is exhausted and Max Pool Size is reached, further connection requests queue and eventually throw InvalidOperationException: Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool.

In node-postgres, when max connections are in use, new requests queue. When connectionTimeoutMillis is exceeded, they reject with an error. Without explicit connectionTimeoutMillis, the wait is indefinite — your requests will hang forever rather than failing fast.

// Always set connectionTimeoutMillis
const pool = new Pool({
  max: 10,
  connectionTimeoutMillis: 5_000, // fail after 5s rather than hang forever
});

Hands-On Exercise

You are moving a .NET API to a Node.js/Prisma stack and deploying to Vercel (serverless). The PostgreSQL database is on Render’s free tier (25 connections max). There is also a CI pipeline that runs migrations and seeds data.

Tasks:

  1. Calculate the connection budget: Render allows 25 connections. How would you allocate them between the Vercel production deployment, staging, CI, and a DBA’s direct access? Write out the allocation.

  2. Configure DATABASE_URL with appropriate connection_limit and pool_timeout for each environment (production, staging, CI).

  3. Implement the PrismaClient singleton pattern that is safe for Next.js hot reload in development.

  4. The app is hitting max_connections in production during traffic spikes. Outline the steps to add PgBouncer on Render. What mode would you choose, and what feature does it break that requires a code change?

  5. Write a health check endpoint that queries pg_stat_activity to report current connection usage, and returns a 503 if usage exceeds 80% of your budget.


Quick Reference

Concern.NET (Npgsql/EF Core)PrismaDrizzle (pg)
Pool size configMax Pool Size=100 in conn string?connection_limit=10 in URLnew Pool({ max: 10 })
Pool timeoutTimeout=30?pool_timeout=20connectionTimeoutMillis: 5000
Singleton patternDI Scoped DbContextglobalThis singletonModule-level Pool
Serverless solutionWorks (long-lived process)Prisma Accelerate or PgBouncerPgBouncer
External pooler modesPgBouncer session/transactionPgBouncer or AcceleratePgBouncer session/transaction
Monitor connectionsNpgsqlDataSource metricspg_stat_activity SQLpool.totalCount / pg_stat_activity
Disconnect cleanlyDispose()prisma.$disconnect()pool.end()

Useful PostgreSQL queries for connection monitoring:

-- Current connections by state and application
SELECT
  application_name,
  state,
  count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY application_name, state
ORDER BY count DESC;

-- Connections waiting (potential pool exhaustion signal)
SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
  AND wait_event_type = 'Client'
  AND state = 'idle in transaction';

-- Kill idle connections older than 10 minutes
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = current_database()
  AND state = 'idle'
  AND query_start < NOW() - INTERVAL '10 minutes';

PgBouncer pgbouncer.ini minimal config for transaction mode:

[databases]
mydb = host=db.render.com port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 200
default_pool_size = 15
min_pool_size = 2
reserve_pool_size = 3
server_idle_timeout = 600
log_connections = 1
log_disconnections = 1

Further Reading

  • Prisma — Connection management — the official guide covering singleton patterns, connection limits, and serverless considerations
  • Prisma Accelerate — Prisma’s managed connection pooler with edge caching; the zero-config solution for serverless Prisma deployments
  • node-postgres Pool documentation — pool configuration options, event handlers, and lifecycle management
  • PgBouncer documentation — full configuration reference; pay particular attention to pool modes and their trade-offs
  • Supabase Supavisor — Supabase’s modern Elixir-based pooler that handles both session and transaction modes correctly across serverless workloads