HTTPconnection poolhttp agentrequests.sessionpgbouncerhikaricp

Connection Pooling

Opening a connection costs handshakes, authentication and a cold congestion window, so clients keep a pool of open ones and hand them out per request — HTTP pools hold stateless connections any request can use, database pools hold connections that carry session state, and both fail the same way when the pool runs dry.

ConceptualNode.js
Interview question
Progress

The problem

A request handler calls two internal services and runs three queries. If each call opens its own connection, the handler spends most of its time in handshakes and the database spends its time forking backends. Keep connections open and share them — but how many, who owns one at any moment, and what happens when the twentieth handler asks for one and all nineteen are busy?

Why opening is expensive

Every new connection pays the costs from The Lifecycle of One HTTP Request: a The Three-Way Handshake (1 RTT), a The TLS Handshake (1–2 RTT plus asymmetric crypto), and a congestion window that starts at ~14 kB and must grow (Congestion Control: Protecting the Network). A database connection then adds its own protocol setup on top of the same transport: PostgreSQL’s startup message, SCRAM-SHA-256 authentication (two more round trips), parameter negotiation, and — because PostgreSQL forks a backend process per connection — several milliseconds of fork() and 5–10 MB of memory on the server (Creating Processes: fork, exec, wait). MySQL uses a thread per connection; the cost is smaller but not zero. Then the application’s own setup: SET search_path, SET timezone, prepared statements, an ORM’s type introspection.

Measured on a 1 ms intra-datacentre link, a cold PostgreSQL connection with TLS costs on the order of 5–20 ms end to end; a query on a warm one costs 0.2 ms. Across a region boundary the connection costs hundreds of milliseconds. A handler that opens one per query is two orders of magnitude slower than it needs to be, and a fleet of such handlers exhausts the server’s connection limit (max_connections, default 100) long before it exhausts its CPU.

  • HTTPS connection: 2–3 RTT + crypto before the first byte.
  • PostgreSQL connection: those, plus auth round trips, plus a fork() on the server, plus session setup.
  • Both start with a cold congestion window; a pooled connection is warm.
  • Server-side limits are hard: max_connections, file descriptors, memory per backend.

HTTP pools: stateless connections

Node.js

An HTTP client pool is a set of open connections per origin (host + port + scheme). A request takes an idle one, or opens a new one up to a limit, or waits. Because HTTP requests carry all their context in headers, any request can use any connection to the same origin: the connection has no memory of the previous request. That makes HTTP pools simple — the only per-connection state is whether it is idle, and the only correctness concern is the idle-timeout race from Keep-Alive and Connection Reuse.

What differs is the defaults, and they bite. Node’s built-in http.Agent did not reuse connections until Node 19 made keepAlive: true the default; before that every fetch-style call from a service opened a new socket unless the code passed an agent. undici (the engine behind Node’s fetch) pools by origin with connections per pool. Python’s requests.get() creates and discards a session per call — no reuse at all — while requests.Session() (urllib3’s PoolManager, 10 connections per host by default) reuses. Go’s http.Transport reuses but keeps only MaxIdleConnsPerHost = 2 idle by default, so a burst of 50 concurrent calls opens 50 connections and closes 48 of them — visible as TIME_WAIT growth and wasted handshakes. Java’s HttpClient and Apache HttpClient pool; the JDK’s old HttpURLConnection does, but per-JVM with a small cap.

For HTTP/2 the pool is usually a single connection per origin carrying many streams, and the sizing question becomes the server’s SETTINGS_MAX_CONCURRENT_STREAMS and whether the client opens a second connection when it is reached (HTTP/2: Streams on One Connection).

Node: one agent, reused across calls; the pool size is the concurrency ceiling per origin
1import { Agent, setGlobalDispatcher } from 'undici'
2
3// one pooled dispatcher for the process; fetch() reuses connections per origin
4setGlobalDispatcher(new Agent({
5 connections: 32, // max sockets per origin — beyond this, requests queue
6 keepAliveTimeout: 4_000, // must be BELOW the server's idle timeout (see keep-alive)
7 pipelining: 1, // one in-flight request per h1 connection
8}))
9
10// every fetch to the same origin now shares warm connections
11const res = await fetch('https://users.internal/users/42')

Database pools: connections with state

A database connection is not interchangeable in the same way. It carries a session: an open transaction, SET variables, prepared statements, temporary tables, advisory locks, a cursor position. A pool must therefore hand a connection to exactly one caller at a time for the duration of a unit of work, and reset it on return — roll back any open transaction, DISCARD ALL or the equivalent — so the next borrower does not inherit a half-finished transaction or a search_path someone else set. Application-side pools (HikariCP in Java, pgx’s pool in Go, node-postgres Pool, SQLAlchemy’s QueuePool) do this per process.

A server-side pooler such as PgBouncer sits between many application processes and one database, multiplexing thousands of client connections onto a few dozen real backends. Its pooling mode determines what state it can preserve: session mode ties a backend to a client for the client’s lifetime (safe, little multiplexing); transaction mode assigns a backend only for the duration of a transaction (high multiplexing, but session-level features — SET, prepared statements by name, LISTEN, advisory locks held across transactions — break or need care); statement mode is for autocommit-only workloads. Choosing transaction mode without auditing the application for session state is a classic outage.

The distinction with HTTP is the important part: an HTTP connection is stateless per request and can be reused by anyone; a database connection is a stateful session that must be borrowed exclusively and cleaned. Treat them as different resources with different failure modes even though both are "a TCP socket kept open".

HTTP pool vs database pool
HTTP client poolDatabase connection pool
Unit of borrowingOne request (or one stream on h2)One transaction or unit of work, exclusively
State on the connectionNone between requestsTransaction, session variables, prepared statements, locks
Reset on returnNot neededRollback + discard session state
Typical sizeTens per origin; bounded by server stream/connection limitsSmall: roughly cores × 2 on the database side, shared by all app instances
Server-side cost per connectionA socket and buffersA process (PostgreSQL) or thread (MySQL), memory, lock-table entries
MultiplexerReverse proxy / LBPgBouncer, ProxySQL, RDS Proxy

Sizing and exhaustion

Bigger is not better. A database does useful work on roughly as many connections as it has cores (plus a few waiting on I/O); beyond that, extra connections only add context switches, lock contention and memory. PostgreSQL’s own guidance and HikariCP’s well-known analysis both land near cores × 2 (+ disk spindles) for the *total* across every application instance — a 4-core database serving 20 app pods should not have 20 × 20 = 400 connections. Little’s law gives the demand side: connections needed ≈ request rate × time each holds a connection. 500 queries/s at 4 ms each need about 2 busy connections; if a query is slow at 400 ms, the same load needs 200, which is why one slow query drains a pool.

Pool exhaustion is the failure mode. When every connection is borrowed, the next request waits (a wait queue — Queue) until one is returned or a wait timeout fires: pool timeout, connection pool exhausted, SQLSTATE 53300 too many connections on the database side, ECONNREFUSED-looking stalls on the HTTP side. Causes are always one of: a leak (a handler that takes a connection and never releases it on an exception path), a slow consumer (one query or one downstream call holding connections for seconds), or fan-out (a request that needs three connections at once — nested transactions, parallel queries — so a pool of 10 deadlocks at four concurrent requests each holding two and waiting for a third). The symptom looks like the database is slow while the database is idle: the wait is in the application, in line for a connection.

  • Database-side total ≈ cores × 2 (+ spindles); divide it across app instances, or put PgBouncer in front so the sum stays bounded.
  • Little’s law: pool size ≈ rate × hold time. Reduce hold time (release before calling another service) before increasing size.
  • Always set a borrow timeout; a request that waits forever for a connection is worse than a fast failure.
  • Never borrow a second connection while holding one; it is the recipe for pool deadlock.
  • Monitor: active, idle, waiting, and wait time — waiting > 0 for more than a moment is the early warning.

Key points

  • A new connection costs TCP + TLS handshakes, a cold congestion window, and — for databases — authentication round trips, a server process or thread, and session setup.
  • HTTP pools hold stateless connections any request may use; the defaults of the client library (Node < 19, requests.get, Go’s 2 idle per host) decide whether reuse happens.
  • Database connections carry session state and must be borrowed exclusively per unit of work and reset on return; PgBouncer’s transaction mode trades session features for multiplexing.
  • Size the database pool for the database (≈ cores × 2 total), not for the application’s concurrency; Little’s law relates size, rate and hold time.
  • Exhaustion comes from leaks, slow holders or nested borrowing; the symptom is application-side waiting while the database looks idle.
  • Set borrow timeouts and monitor waiters; never hold one connection while waiting for another.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why not open a connection per request and rely on the OS?

Because the OS cannot skip the handshakes, and the database cannot skip the fork and the authentication. Only reuse avoids them, and reuse needs an owner that tracks which connections are idle — a pool.

Why is the ideal database pool so small?

A database is bound by cores and disk; connections beyond what those can service run in parallel only in the sense of contending for locks and CPU. Queuing requests in the application costs less than queuing them inside the database.

Why can an HTTP connection be shared freely but a database connection cannot?

HTTP puts all context in each request; the database keeps context on the connection (transaction, session settings). Sharing the latter between two callers would interleave their transactions.

Why does one slow query take down unrelated endpoints?

They share the pool. By Little’s law the slow query’s hold time multiplies its share of the pool; when it holds every connection, the fast queries wait in line behind it, and the whole service reports timeouts.

How it fails

What the failure looks like from inside real software.

  • pool timeout / connection pool exhausted while the database shows 10% CPU: a leak on an exception path, or one slow query holding all connections.
  • PostgreSQL FATAL: sorry, too many clients already after a scale-out: every new pod brought its own full-size pool; the sum exceeded max_connections.
  • Switching PgBouncer to transaction mode breaks named prepared statements and SET calls; queries fail intermittently depending on which backend is assigned.
  • Go service opens hundreds of short-lived HTTPS connections under load because MaxIdleConnsPerHost is 2; handshake CPU and TIME_WAIT climb.
  • Nested borrowing deadlock: each request holds a connection and requests a second; with N concurrent requests and N connections, nobody progresses until the borrow timeout.
  • Stale pooled connections after a database failover: every borrowed connection fails on first use with a reset until the pool validates or recycles them.
Don't delegate understanding
The manifesto →