Connections: Pooling, Keep-Alive and Limits
Connections are expensive and limited. Most production outages from load come from exhausted pools, missing timeouts, or unbounded concurrency.
- 150 msDNS, TCP, TLS 1.3 at 50 ms round trip
- 15 per instancepool: 200 max, 20 reserved, 12 instances
- ~17useful concurrent connections on 8 cores
Show what to say
“Connections are expensive to open, so I reuse them: pool database connections and keep HTTP alive. The rule is that instances times pool size must stay under max_connections minus headroom, and past about twice the cores a bigger pool only adds contention. When instances multiply I add PgBouncer, and every call gets a timeout inside a propagated deadline.”
Trade-offs
- Bigger pool vs smaller: a bigger pool absorbs bursts, but past the core count queries get slower from context switching and lock contention.
- Transaction vs session pooling: transaction mode multiplexes many clients onto few connections, but SET, LISTEN and temp tables don't survive between transactions.
- Fail fast vs wait: a 1 to 2 s pool-acquire timeout rejects some requests early, but stops a slow dependency tying up every slot.
Bring it up before they ask
Not releasing a checked-out client when a query throws leaks the pool until it runs dry, so always release in a finally block.
New to the words? Read the one-minute primer
Opening a connection is slow and costs the server memory, so reuse them. Keep the pool total under the database's limit, and put a timeout on every call.
Why it matters
Opening a connection is like phoning a shop and waiting on hold before you can ask anything.
Read more · 216 words
A keeps a few lines open and lends them out.
At a 50 ms round trip, , and burn 150 ms before your first question. The server also caps you: PostgreSQL's max_connections defaults to 100.
Picture the Monday-morning peak with one slow query. Every connection stays busy longer, the pool runs dry, new requests queue, callers time out and retry, and the retries deepen the queue. The diagram plays a healthy pool, a dry one, and PgBouncer squeezing many app connections onto few database ones.
The page follows the decisions in order: what a connection costs, how many to hold, whether to put a proxy in front, how long to wait, how many calls to allow at once, and what breaks when you get one wrong.
Tap any box to see what it is and what it talks to.
- Client
- Load balancer
- Service
- API gateway
- Database
Text description
- A request for /orders/42 arrives at the load balancer.
- Instance A gets it. Its pool holds 10 open connections; 3 are busy right now.
- The handler borrows an idle connection. No TCP, TLS or authentication handshake: borrowing takes microseconds.
- The query runs on PostgreSQL in about 5 ms.
- Rows come back.
- The connection goes back to the pool, still open. At 5 ms a query, 10 connections can serve up to 2,000 queries per second.
- The response leaves instance A.
- The user gets the order. The connection is already serving someone else.
Connection cost and keep-alive
Read more · 326 words
| Connection | Setup | Server holds | Reuse with |
|---|---|---|---|
| TCP | 1 round trip | A socket and a file descriptor | Keep-alive |
| TLS 1.3 / 1.2 | +1 / +2 round trips, plus crypto | Session keys | Keep-alive, session resumption |
| HTTP/2 | One TCP + TLS for many requests | Streams on one connection | Multiplexing |
| HTTP/3 (QUIC) | 1 round trip for transport and TLS together | QUIC connection state | Multiplexing, 0-RTT resumption |
| PostgreSQL | TCP + TLS + auth: up to tens of ms | A whole process (a few MB) | A pool, then PgBouncer |
| MySQL | TCP + TLS + auth | A thread | A pool, ProxySQL |
| Redis | TCP + AUTH: under 1 ms nearby | A few KB | One shared, pipelined client |
Drag the round-trip time. A cold HTTPS request pays about 4 round trips; a kept-alive one pays 1.
leaves a finished connection open for the next request, so the handshakes are paid once. Node's fetch does this by default, and since Node 19 so does the default http.Agent. Two problems remain.
Reuse beats reconnecting. Without keep-alive every request opens a connection and then closes it, and Linux holds each closed outgoing connection in TIME_WAIT for 60 s.
That caps you near 470 new connections per second to one destination; past it, connect fails. Use keep-alive for all service-to-service HTTP, and raise the client pool (connections: 128 in undici) for heavy traffic.
The idle-timeout race. If the server closes idle sockets before the does, the balancer eventually sends a request into a socket that is already gone and returns a 502. Node's default keep-alive timeout is 5 s and an AWS ALB keeps idle connections for 60 s. Play both tabs.
Keep the server's timeout longer than the load balancer's (65 s against 60 s), and keep headersTimeout above that.
- Client
- Load balancer
- Service
Text description
- User → Load balancer: GET /orders
- Load balancer → Node server: forwarded on a kept-alive socket
- Node server replies to Load balancer: 200 OK
- User → Load balancer: GET /orders (at 30 s)
- Load balancer → Node server: reuses the dead socket
- Load balancer replies to User: 502 Bad Gateway
Pool sizing and PgBouncer
Each instance opens its own pool, so the rule is about the total:
Read more · 569 words
instances × pool size ≤
max_connections− headroom (admin tools, migrations)
Set the pool size, request rate and database cores. Requests arriving faster than the pool serves them queue, then time out. Now make the pool far bigger than the cores: queries get slower.
| Question | Answer |
|---|---|
| Top down: what is the default? | An app-side pool keeps a few open connections inside each instance and lends them to requests, so only the first request on a connection pays the handshakes. 15 per instance gives 12 × 15 = 180 server connections, under the 200 limit with 20 reserved. The cost: the total moves with your instance count, so autoscaling to 30 instances quietly makes it 450 |
| Bottom up: is it big enough? | Busy connections equal queries per second times the time each holds one (Little's law). The pool needs room for that number plus bursts |
| Too big? | A pool bigger than the database can use only adds waiting. A useful rule is about twice the cores plus one, so roughly 17 on 8 cores. A pool of 200 on that database puts 25 backends on every core, so each query runs slower and the timeouts you wanted to cure get worse |
| Too many instances? | The fix is a proxy, not bigger pools. 50 instances × 20 = 1,000 connections against a limit near 200 needs PgBouncer. With a few instances, skip it |
- 0.5 busy: a tiny pool is plenty.
- 5 busy: a pool of 15 has room for bursts.
- 50 busy: the pool of 15 runs dry, so fix the query, not the pool size.
When the pool is dry, callers wait for a free connection. Give that wait a short timeout (1 to 2 s), so they fail fast. Play Full, so wait on the pool diagram to see what a handler does when all 15 are busy.
PgBouncer is a small proxy that lets hundreds of cheap app connections share a few real database connections. Without it, 240 clients cost 240 PostgreSQL processes; with it, they cost 40. Add it only when the app-side total stops fitting: when the total of app connections would pass the database limit (1,000 against 200), or when you run many short-lived instances such as serverless functions. Below that, an app-side pool is simpler and one hop faster. Follow the questions in the tree.
| Mode | A server connection lasts | Use it for |
|---|---|---|
| Session | As long as the client stays connected. Saves handshakes but almost no connections | Session features (LISTEN, advisory locks, temporary tables) and a modest client count: a migration runner, a LISTEN worker |
| Transaction (the usual pick) | From BEGIN to COMMIT. Between transactions the client holds nothing, which is what lets 240 clients share 40 | Most web apps, and the default answer in an interview. Size the server-side pool by what the database can run at once, not by the client count |
| Statement | One statement, so multi-statement transactions are refused | Autocommit-only workloads, such as simple key lookups. Rare in practice |
What breaks in mode. Session state does not survive, because the next transaction may land on another server connection. A plain SET silently stops applying; SET LOCAL inside the transaction works. LISTEN and temporary tables break the same way. Prepared statements need max_prepared_statements (PgBouncer 1.21 or newer).
Tap any box to see what it is and what it talks to.
- Service
- API gateway
- Database
Text description
- A handler asks the pool for a connection. The pool has 9 open, 4 of them idle.
- It lends an idle one in microseconds. No TCP, TLS or login: the connection is already open.
- The query returns in about 5 ms.
- The handler releases it. The connection stays open for the next request.
Show data table
| connections | |
|---|---|
| 100 queries/s × 5 ms | 0.5 connections |
| 1,000 queries/s × 5 ms | 5 connections |
| 1,000 queries/s × 50 ms | 50 connections |
Do instances × pool size fit under max_connections minus headroom?
Timeouts, deadlines and limits
A timeout bounds one call and a deadline bounds the whole request.
Read more · 483 words
You need both, because without a timeout one slow dependency ties up your sockets and pool slots, and its outage becomes yours.
| Timeout | What it bounds | Typical setting |
|---|---|---|
| Connect | Opening the connection | 1 to 3 s |
| Pool acquire | Waiting for a free connection | 1 to 2 s, then fail fast |
| Request or query | One call | A few times its p99 |
| Deadline | The whole request, every hop | From the SLO, e.g. 2 s |
Set all four on every client. The default answer is a short connect timeout and a request timeout of a few times the call's .
Per-call timeouts do not add up across a chain: a 30 s timeout on the inner call outlives a 2 s edge timeout. Play both tabs: without a deadline Pricing finishes at 25 s for a caller who left at 2 s.
The edge sets one budget and every hop passes the remainder downstream, so nobody works on a request whose caller gave up. does this for you; over HTTP, pass a header such as x-deadline-ms. Each hop's timeout is the smaller of its own cap and what is left: with 2,000 ms at the edge, a 200 ms call to Users leaves 1,800 ms and Pricing starts with about 1,770 ms. Retries must fit inside what is left. If less than one round trip remains, fail now instead of starting another attempt.
A concurrency limit caps how many calls are in flight, so a big loop cannot drain a pool that every other request needs. Promise.all(ids.map(fetchUser)) over 10,000 ids fires 10,000 requests at once. Use a fixed number of workers (p-limit, or the mapLimit below) sized under the pool. Ten workers at 5 ms a call finish 10 ÷ 0.005 s = 2,000 calls per second, so 10,000 ids take about 5 s and 5 of the 15 connections stay free. Use it for any batch, or backfill. Play No limit, then 10 workers.
Put a bound on the waiting line too, so overload turns into fast rejections and not into growing memory and . A queue of 100 at 2,000 calls per second means no accepted call waits more than 50 ms. The 101st gets a 429 or 503 in microseconds. The cost is that some callers are turned away, which is the point (play the shed scenario).
- API gateway
- Service
Text description
- Edge → Orders service: POST /checkout
- Orders service → Pricing service: quote (30 s timeout)
- Pricing service replies to Orders service: answer at 25 s, nobody listening
Tap any box to see what it is and what it talks to.
- Service
- API gateway
- Database
Text description
- Promise.all fires 10,000 calls in the same tick. With no limiter, all of them go straight on.
- The pool has 15 connections. The other 9,985 calls wait in memory, each holding a socket and a timer.
- Acquire timeouts fire for most of them, and every other request on this instance is stuck behind the pile.
Descriptors, shutdown and HTTP versions
Every socket and open file uses one file descriptor, and a process has a limit on them.
Read more · 548 words
The default soft limit is often 1,024; past it, you get EMFILE: too many open files. A server uses one per client connection, one per upstream connection, and one per open file or log. 10,000 clients need 10,000 descriptors before you open a single file, so a limit of 1,024 fails at about the 1,000th client.
Raise the limit in the unit or container, not in a shell you will forget. Alert when use passes 80% of the limit.
A graceful shutdown stops taking new work, finishes what is running, and exits before the orchestrator kills the process. Done badly, every deploy drops in-flight requests. The diagram shows one shutdown inside Kubernetes' default 30 s grace period.
Fail readiness
On
SIGTERM, answer the readiness probe with 503, and wait a few seconds for the balancer to notice.Stop accepting and close idle sockets
server.close()stops new connections. WebSocket clients get close code 1001 and reconnect elsewhere with .Finish in-flight work
Let running requests finish, then close pools and flush logs.
Exit before the kill
Set a hard deadline a little under the orchestrator's grace period (25 s against 30 s).
Each HTTP version removes the connection bottleneck the last one left behind. is the default answer, and is an upgrade for lossy networks. Play the three tabs.
| Version | How it works | Use it when |
|---|---|---|
| HTTP/1.1 | One request at a time per connection, so a browser opens about 6 and pays six handshakes. Thirty files take 5 rounds | Legacy clients only. Do not shard domains to get more connections: under HTTP/2 that splits one good connection into several |
| HTTP/2 | One TCP connection shared by many streams, so one handshake serves all 30 files. The catch: one lost packet stalls every stream | The default, from browser to edge and between services. In a datacenter, packet loss is rare, so the stall seldom matters |
| HTTP/3 | Runs over QUIC: each stream has its own recovery path, a new connection costs one round trip, and the connection survives a change of network | The edge or CDN for mobile and lossy users, with HTTP/2 as the fallback when UDP is blocked. 0-RTT resumption can be replayed, so accept only safe, idempotent requests like GET in it |
and layer-4 balancers. gRPC holds one long connection, so behind a layer-4 all of a client's traffic lands on one backend. Use a layer-7 balancer (or client-side balancing), so requests, not connections, are spread.
ulimit -n # the current soft limit, often 1024
ls /proc/$(pgrep -n node)/fd | wc -l # descriptors in use right now
# Raise it: LimitNOFILE=1048576 in the systemd unit, or --ulimit nofile=1048576 for DockerSIGTERM arrives at second 0. The app fails readiness, drains, closes its pools and exits well before the orchestrator's kill.
Text description
- Orchestrator: grace period 30 s (0–30 s)
- Load balancer: sees 503, stops routing (0–3 s)
- App: ready 503 (0–1 s), finish in-flight requests (1–9 s), pool.end() (9–10 s)
Tap any box to see what it is and what it talks to.
- Client
- API gateway
- Service
Text description
- A page needs 30 files. HTTP/1.1 allows one request at a time per connection, so the browser opens about 6.
- Each of the 6 connections pays its own TCP and TLS handshake, then carries one request at a time.
- The other 24 files queue behind them: 30 files over 6 connections is 5 rounds.
What goes wrong with connections
Connection trouble shows up as one symptom, timeouts, from four different causes.
Read more · 517 words
Tell them apart by what the pool and the database each look like.
| Failure | Pool looks like | Database looks like |
|---|---|---|
| Exhaustion | Full, many waiting | Busy with slow queries |
| Storm | Opening many at once | At max_connections |
| Idle in transaction | Full, many waiting | Idle sessions, held locks |
| Leak | Full, none ever return | Idle |
Exhaustion is a pool with every connection busy and a queue behind it. Monday 09:00, a deploy drops an index and each query takes 3 s instead of 5 ms. A pool of 10 now serves 10 ÷ 3 ≈ 3.3 while 200 requests per second arrive. Fix the query first. Then set a statement_timeout, a pool acquire timeout of 1 to 2 s, and a , so overload fails fast. Raising the pool to 200 only adds more slow queries.
A storm is many instances opening connections at the same moment. Friday 15:00, a deploy restarts 30 instances and each opens up to 20 connections: 30 × 20 = 600 attempts against a limit of 200. PostgreSQL answers too many clients already, and the callers retry together.
Deploy a few instances at a time, let pools grow as traffic arrives, retry with and , and put PgBouncer in front so the server side has a hard cap.
A session that is idle in has run BEGIN and then stopped talking, usually while the app waits on something else. It holds its connection and its row locks. If the payments API stalls for 30 s, 15 handlers fill a pool of 15 and every writer to those rows queues behind them.
Never make a network call inside a transaction. Add idle_in_transaction_session_timeout (10 s is a sane start) so the server kills a stuck session itself. Old open transactions also stop VACUUM from cleaning up.
A leak is a checked-out connection that is never returned. A handler checks out a client, a constraint error throws, and the release sat after the query. Each such error strands one connection; after 15 the instance is stuck while the database is idle. Release in a finally block, or use pool.query which does it for you. Alert when waiting is above zero and idle is zero, and compare with pg_stat_activity. Some pools (HikariCP) can log a stack trace for any connection held longer than a threshold.
Tap any box to see what it is and what it talks to.
- Service
- API gateway
- Database
Text description
- Friday 15:00, a deploy restarts all 30 instances in one minute. Traffic returns and every cold pool starts opening connections.
- 30 × 20 = 600 connection attempts against max_connections 200. Each login also costs CPU on the server.
- PostgreSQL answers FATAL: sorry, too many clients already. Requests fail, and the callers retry in the same second.
- The retries are a second storm. The database stays at the limit even though the new code is fine.
- Service
- Database
- External API
Text description
- Handler → PostgreSQL: BEGIN; UPDATE orders
- Handler → Payments API: charge card
- Payments API replies to Handler: ok, 30 s later
- Handler → PostgreSQL: COMMIT
Quick checkA handler calls a slow API inside BEGIN and COMMIT. What suffers?
Code
Database connection pooling
import pg from 'pg';
import { poolMaxPerInstance } from './pool-size.js';
// Postgres max_connections = 200, keep 20 for admin and migrations, 12 app instances
const poolMax = poolMaxPerInstance({ maxConnections: 200, reserved: 20, instances: 12 }); // 15. More instances? Add PgBouncer first.
export const pool = new pg.Pool({
max: poolMax,
idleTimeoutMillis: 30_000, // close connections idle for 30 s
connectionTimeoutMillis: 2_000, // wait at most 2 s for a free connection, then fail fast
});
// A single query borrows a connection and returns it automatically
export const findUser = async id => (await pool.query('SELECT * FROM users WHERE id = $1', [id])).rows[0];
// A transaction must run every statement on ONE connection, and must always give it back
export async function transfer(from, to, cents) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [cents, from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [cents, to]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release(); // a forgotten release leaks a connection: 15 leaks and this instance is stuck
}
}HTTP keep-alive
import { Agent, setGlobalDispatcher } from 'undici';
// Node's fetch (undici) already reuses connections; tune its pool for heavy service-to-service traffic
setGlobalDispatcher(new Agent({ connections: 128, keepAliveTimeout: 10_000, keepAliveMaxTimeout: 60_000 }));
// The server side, behind a load balancer: keep idle sockets open LONGER than the LB does (AWS ALB: 60 s).
// Otherwise Node closes a socket at the moment the LB reuses it, and the user gets a 502.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000; // keep this above keepAliveTimeoutTimeouts and deadlines
// Deadlines: give the whole request a budget and pass what's left downstream
app.use((req, _res, next) => {
const fromCaller = Number(req.get('x-deadline-ms')) || Infinity; // an upstream service may have less time left
req.deadline = Date.now() + Math.min(2_000, fromCaller);
next();
});
const remaining = req => Math.max(0, req.deadline - Date.now());
app.get('/checkout', async (req, res) => {
const user = await fetch(`${USERS_URL}/${req.userId}`, { signal: AbortSignal.timeout(Math.min(500, remaining(req))) }).then(r => r.json());
const quote = await fetch(`${PRICING_URL}/quote`, {
method: 'POST', body: JSON.stringify(user.cart),
headers: { 'Content-Type': 'application/json', 'x-deadline-ms': String(remaining(req)) }, // downstream can give up early too
signal: AbortSignal.timeout(remaining(req)),
});
res.json(await quote.json());
});
// A timed-out dependency becomes a fast 504 instead of a hung request
app.use((err, _req, res, _next) => res.status(err.name === 'TimeoutError' ? 504 : 500).end());Limiting concurrency
// Run fn over items with at most `limit` calls in flight. Results keep the input order.
export async function mapLimit(items, limit, fn) {
const out = [];
let i = 0;
await Promise.all(Array.from({ length: limit }, async () => { // `limit` workers share one cursor
while (i < items.length) {
const n = i++; // claim an index synchronously: no two workers get the same one
out[n] = await fn(items[n]);
}
}));
return out;
}Graceful shutdown
// Graceful shutdown for an HTTP + WebSocket server (extends the source card)
let shuttingDown = false;
app.get('/ready', (_req, res) => res.sendStatus(shuttingDown ? 503 : 200)); // the load balancer's readiness probe
process.on('SIGTERM', () => {
shuttingDown = true; // readiness → 503: the LB stops sending new requests
wss?.clients.forEach(c => c.close(1001, 'server restarting')); // clients reconnect elsewhere (with jitter)
server.close(() => pool.end().then(() => process.exit(0))); // wait for in-flight requests, then close the DB pool
server.closeIdleConnections(); // drop idle keep-alive sockets now
setTimeout(() => process.exit(1), 25_000).unref(); // hard stop before the orchestrator's kill timeout
});Pool guardrails
import pg from 'pg';
export const pool = new pg.Pool({
max: 15,
connectionTimeoutMillis: 2_000, // fail fast when the pool is dry
statement_timeout: 5_000, // the server cancels any single query after 5 s
idle_in_transaction_session_timeout: 10_000, // the server kills a session that sits in a transaction for 10 s
});
// An idle client that errors must not crash the process
pool.on('error', err => console.error('idle client error', err.message));
// Log the three numbers that reveal exhaustion and leaks
setInterval(() => {
const { totalCount, idleCount, waitingCount } = pool;
if (waitingCount > 0 && idleCount === 0) {
console.warn(`pool dry: ${totalCount} open, 0 idle, ${waitingCount} waiting`); // alert on this
}
}, 5_000).unref();
// And ask the server who is stuck in a transaction:
// SELECT pid, now() - xact_start AS age, query FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY age DESC;Connection pooling in the interview
Bring connections up the moment the design has many app servers talking to one database, then walk through five decisions in order.
Read more · 360 words
The order shows you have run this in production.
Name the scarce resource
Say that database connections are limited and costly: "Postgres allows about 200, and each is a process."
Size the pools from the top
Instances times pool size must fit under the limit minus headroom. 12 instances get 15 each; more instances shrink the pools.
Add PgBouncer only when instances multiply
mode, and say what it breaks: session state such as
SET,LISTENand temporary tables.Bound the waiting
A timeout on every call inside one propagated deadline, a cap on concurrency, and a bounded queue that rejects fast.
Own the failures
Name exhaustion, a storm after a deploy, idle in transaction and leaks, then say graceful shutdown with jittered reconnects.
| If the interviewer says | You raise |
|---|---|
| "We autoscale to 100 instances" | Top-down pool math, then PgBouncer in transaction mode |
| "The database CPU is at 100%" | Pool too big or a slow query; fewer connections, not more |
| "Requests hang when a dependency is slow" | Timeouts inside a deadline, bounded concurrency, a breaker |
| "We deploy during peak traffic" | Rolling deploys, readiness, drain, jittered reconnect |
| "Mobile users on bad networks" | HTTP/3 at the edge, HTTP/2 as fallback |
Quick checkWhich comes first when sizing connection pools?
Interview questions
We scaled from 10 to 30 app instances and Postgres started refusing connections. Why?
Each instance opens its own pool: 30 × 20 = 600, far past max_connections. Divide the limit by instances, and add PgBouncer.
Why can a bigger pool make things slower?
Past about the database's core count, extra connections only add waiting. A small pool with a short queue serves more.
How do you deploy WebSocket servers without dropping users?
Fail readiness, send close 1001, and let clients reconnect elsewhere with .
How would you size the connection pool?
Top down: the total at maximum fits under the limit. Bottom up: × hold time, plus burst room.
A downstream service got slow, and now our service is down too. What happened?
Calls without timeouts held every slot. Add timeouts inside a deadline, a and a fallback.
Check yourself
Question 1 of 5Score 2 out of 3 or better to mark this page complete.