How to test connection pool exhaustion

Send more concurrent requests than the pool has connections and read the responses. Against a pool of two with a 300 ms acquire timeout, four requests gave two 200s after 1.0 s and two 503s reading pool acquire timeout after 300ms. The database answered a direct query during the same second.

Why check this

A pool is the part of a service that fails first under load, and it fails in a way that points at the wrong component. Run this before a release that adds a slow downstream call inside a transaction, after any change to pool size, and when an incident shows request timeouts with a database that looks idle.

The failure it catches is the outage nobody can locate. Every request times out, the database reports no slow queries and no locks, and the cause is one handler that returns on an error path without giving its connection back. After a few of those the pool is empty and stays empty until a restart.

Prerequisites

CREATE TABLE orders (id INTEGER PRIMARY KEY, ref TEXT NOT NULL UNIQUE, total INTEGER NOT NULL);
INSERT INTO orders (ref, total) VALUES ('A-1000', 500);
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';

const SIZE = 2;              // pool size
const ACQUIRE_TIMEOUT = 300; // ms a request waits for a free connection

const idle = Array.from({ length: SIZE }, () => new DatabaseSync('shop.db'));
const waiters = [];
let inUse = 0;

const acquire = () => new Promise((resolve, reject) => {
  if (idle.length) { inUse += 1; return resolve(idle.pop()); }
  const w = { resolve };
  w.timer = setTimeout(() => {
    waiters.splice(waiters.indexOf(w), 1);
    reject(new Error(`pool acquire timeout after ${ACQUIRE_TIMEOUT}ms`));
  }, ACQUIRE_TIMEOUT);
  waiters.push(w);
});

const release = (db) => {
  const w = waiters.shift();
  if (w) { clearTimeout(w.timer); w.resolve(db); return; }
  inUse -= 1; idle.push(db);
};

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (url.pathname === '/stats') {
    res.end(JSON.stringify({ size: SIZE, inUse, idle: idle.length, waiting: waiters.length }) + '\n');
    return;
  }
  let db;
  try { db = await acquire(); }
  catch (err) { res.statusCode = 503; res.end(err.message + '\n'); return; }
  const n = db.prepare('SELECT count(*) AS n FROM orders').get().n;
  if (url.pathname === '/leak') { res.end(`orders=${n} (connection not released)\n`); return; }
  setTimeout(() => { release(db); res.end(`orders=${n}\n`); },
    Number(url.searchParams.get('hold') ?? 0));
}).listen(9573, () => console.log('pool server on 9573, size %d, acquire timeout %d ms', SIZE, ACQUIRE_TIMEOUT));
#!/bin/sh
# Four concurrent requests at a pool of two, each holding its connection 1000 ms.
for i in 1 2 3 4; do
  curl -s -o body.$i -w "req$i  HTTP %{http_code}  %{time_total}s  " \
    "http://127.0.0.1:9573/orders?hold=1000" > line.$i &
done
sleep 0.15
echo "pool stats mid burst:  $(curl -s http://127.0.0.1:9573/stats)"
echo "read straight from db: $(sqlite3 shop.db 'SELECT count(*) FROM orders;') row"
wait
for i in 1 2 3 4; do cat line.$i body.$i; done

Steps

  1. Step 1.

    Start the service and read the pool before any load.

    node --disable-warning=ExperimentalWarning pool-server.mjs & sleep 1.5; curl -s http://127.0.0.1:9573/stats
    
    pool server on 9573, size 2, acquire timeout 300 ms
    {"size":2,"inUse":0,"idle":2,"waiting":0}

    Two connections, both idle, nobody queued. Every number below is read against this baseline.

  2. Step 2.

    Send twice as many concurrent requests as the pool can serve.

    ./burst.sh
    
    pool stats mid burst:  {"size":2,"inUse":2,"idle":0,"waiting":2}
    read straight from db: 1 row
    req1  HTTP 200  1.005757s  orders=1
    req2  HTTP 200  1.009227s  orders=1
    req3  HTTP 503  0.302717s  pool acquire timeout after 300ms
    req4  HTTP 503  0.313112s  pool acquire timeout after 300ms

    Line 1 is the shape of the fault: both connections in use, two requests queued behind them. Line 2 is the part that misleads an incident call, a direct read answering during the same second the service was returning 503. Requests 3 and 4 gave up at the acquire timeout, 300 ms, without ever reaching the database.

  3. Step 3.

    Take two connections on a path that never gives them back.

    for i in 1 2; do curl -s http://127.0.0.1:9573/leak; done && curl -s http://127.0.0.1:9573/stats
    
    orders=1 (connection not released)
    orders=1 (connection not released)
    {"size":2,"inUse":2,"idle":0,"waiting":0}

    Both requests answered with 200 and the pool is empty. Nothing is queued, because nothing is asking yet.

  4. Step 4.

    Send one ordinary request, with no other traffic at all.

    curl -s -w "\nHTTP %{http_code}  %{time_total}s\n" http://127.0.0.1:9573/orders
    
    pool acquire timeout after 300ms
    
    HTTP 503  0.308548s

    One request against an idle machine and an idle database, and it fails. This is the state that never recovers. The burst in step 2 drained for a second; a leak drains until the process restarts.

  5. Step 5.

    Stop the service and confirm the port is free.

    netstat -ano | grep "9573.*LISTENING"
    
      TCP    0.0.0.0:9573           0.0.0.0:0              LISTENING       44072
    TCP    [::]:9573              [::]:0                 LISTENING       44072

    Take the process id from the last column and stop that one process with powershell -Command "Stop-Process -Id 44072 -Force", then run the same command again and expect no output.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Responses at exactly the acquire timeout | Requests are queuing for a connection, not running a query | Read the pool gauges. The database is not involved yet. | | inUse at the pool size with waiting above zero | Live saturation under load | Shorten the work done while holding a connection before raising the size. | | inUse at the pool size with waiting at zero and no traffic | Connections leaked and were never returned | Find the handler that returns before its release call. | | Failures at the timeout while a direct query answers instantly | The fault is in the pool, not the engine | Take the database off the incident list and look at handler duration. | | Responses slower than the hold but under the timeout | The queue is draining | The pool is small for this load and still working. |

Thresholds

A pool of 2 with a 300 ms acquire timeout rejected 2 of 4 concurrent requests that each held a connection for 1000 ms, at 302 ms and 313 ms. Source: Measured on this machine, 2026-09-12, node 22.23.2 and node:sqlite 3.51.3, values in the Verified block.

Common mistakes

Sign: The incident report blames the database because requests are timing out.Cause: In step 2 a direct read returned a row while the service was answering 503. Pool exhaustion produces client timeouts with no slow query, no lock and no load on the engine. Read the pool gauges before reading the database.
Sign: The pool size is raised and the failure comes back at higher load.Cause: Size buys time proportional to how long each request holds a connection. Doubling the pool doubled nothing here, because the 1000 ms hold is the variable. Measure hold duration first, and move slow calls out of the section that owns a connection.
Sign: The test passes because every request succeeds.Cause: Concurrency below the pool size never queues. Send more concurrent requests than the pool has connections, as step 2 does with four against two, or the test only proves the server starts.
Sign: A leak test is run with load and nothing looks unusual.Cause: Under load a leak and ordinary saturation give the same 503. The signature is step 3: `inUse` stuck at the pool size while `waiting` is zero and no traffic is arriving. Poll the gauges after the load stops, not during it.

What to check next

FAQ

How do I test a connection pool without a load tool?

Four concurrent curl calls are enough when the pool is small, as in step 2. Size the test to the pool, not to production traffic: one more concurrent request than the pool has connections is all it takes to make the queue visible.

What should a pool expose for this to be testable?

Size, in use, idle and waiting, on an endpoint the test can poll. Without those four numbers the only symptom is a 503 at the client, and steps 3 and 4 cannot be told apart from ordinary saturation.

Does this result apply to PostgreSQL or MySQL?

The pool behaviour does, since the queue is in the application. The database side does not: this ran against SQLite, which has one writer, and no server-side connection limit was involved. Rerun against your engine before quoting a pool size.

Should the acquire timeout be longer?

A longer timeout converts rejections into slow responses, which is worse when the caller has its own deadline. Keep it below the caller's timeout and treat the rejections as the signal to shorten how long a request holds a connection.

Verified

Verified by Maks Vernynode 22.23.2node:sqlite SQLite 3.51.3curl 8.1.2sqlite3 shell (Android platform-tools) 3.50.6

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

advanced12 minpublished updated Maks Verny