Skip to main content

Command Palette

Search for a command to run...

Connection Pooling Explained: Build One From Scratch in 30 Lines

Updated
7 min readView as Markdown
Connection Pooling Explained: Build One From Scratch in 30 Lines
J

I am software developer, primarily working on the nodejs, graphql, react and mongoDB.

I used to think things like request queuing and DB connection pooling were deep infrastructure magic — something libraries did that I could never see. Then I built each piece myself in ~30 lines of TypeScript, and it turned out the whole thing is arrays, counters, and one key insight about what a "connection" actually is.

This post builds the idea bottom-up. By the end, connection pooling should feel obvious.

Building Block 1: A connection is just a held reference

When a client calls fetch(), the kernel opens a TCP connection identified by a unique 4-tuple: (client IP, client port, server IP, server port). Ten simultaneous requests from the same laptop use ten ephemeral client ports — ten distinct sockets. (Sequential requests would reuse one connection via HTTP keep-alive, but concurrent ones can't share an HTTP/1.1 socket.)

When Node accepts one, it hands your callback a (req, res) pair. Here's the insight that unlocked everything for me:

res is not "response data". It is a handle to one specific open socket.

There's no ID matching, no "which user does this response belong to" lookup. The correlation is structural — res is the write-end of that exact connection. As long as you hold the reference, you can reply whenever you want, and the bytes go down the right socket.

Which means: you can put res in an array and answer later. That single trick is what queuing is.

Building Block 2: A queue is an array and a counter

Say I only want to process 2 requests at a time. I need three things:

let active = 0;                // how many are being processed right now
const waiting: Job[] = [];     // everyone else — this array IS the queue
 
http.createServer((req, res) => {
  const job = { res, enqueuedAt: Date.now() };
 
  if (active < MAX_CONCURRENT) {
    process(job);              // free slot → run now
  } else if (waiting.length < MAX_QUEUE) {
    waiting.push(job);         // ENQUEUE: res sits in memory, client keeps waiting
  } else {
    res.writeHead(503).end();  // queue full → shed load
  }
});

And when a job finishes, pull the next one:

async function process(job: Job) {
  active++;
  await doWork();              // the slow part
  active--;
  job.res.end("done");         // reply NOW — the socket was open this whole time
  drain();
}
 
function drain() {
  while (active < MAX_CONCURRENT && waiting.length > 0) {
    process(waiting.shift()!); // DEQUEUE: FIFO, next in line gets the slot
  }
}

Run this and fire 10 concurrent requests: 2 start immediately, 5 queue up (their wait times climb 2s → 4s → 6s), and 3 get instant 503s. Every "slow server" you've ever hit was some version of this. While a request "queues", nothing mystical happens — its res object sits in an array, its TCP socket stays open, and the client's fetch() promise simply hasn't resolved yet.

(Below this array there are more queues you don't manage: the kernel's accept queue, socket buffers, Node's event loop. Same concept at every layer — bytes parked in a buffer, waiting for capacity.)

Building Block 3: Connections are expensive, requests are cheap

One more fact before the payoff. Opening a database connection costs real time:

  1. TCP handshake (a network round trip)

  2. TLS negotiation (another round trip — two on older TLS 1.2)

  3. Authentication (password/SCRAM exchange)

  4. The DB allocating resources — Postgres forks an entire OS process per connection.

That's easily 5–50ms, often more than the query itself. Doing this per query would be like hiring and firing an employee for every task. So the obvious move: pay the setup cost once, and keep the connection alive.

But "keep it alive" — where? Same answer as before. In an array. An idle DB connection is just an authenticated, open socket wrapped in a JS object, sitting in your process memory doing nothing, ready to be handed out.

The Payoff: A pool is the queue, inverted

Look at what we've built:

  • Request queue: work waits in an array for a free slot.

  • Connection pool: connections wait in an array for incoming work.

Same machinery, mirrored. And when the pool runs dry, it flips back into our request queue — callers wait in line for a connection:

class Pool {
  private idle: Connection[] = [];                     // connections waiting for work
  private waiters: ((c: Connection) => void)[] = [];   // work waiting for connections
  private total = 0;
 
  async acquire(): Promise<Connection> {
    if (this.idle.length > 0)
      return this.idle.pop()!;              // reuse: no handshake, ~0ms
 
    if (this.total < MAX_POOL_SIZE) {
      this.total++;
      return await createConnection();      // expensive path, done rarely
    }
 
    // pool exhausted → the caller queues (Building Block 2 again!)
    return new Promise(resolve => this.waiters.push(resolve));
  }
 
  release(conn: Connection) {
    const waiter = this.waiters.shift();
    if (waiter) waiter(conn);               // hand straight to next in line
    else this.idle.push(conn);              // nobody waiting → back on the shelf
  }
}

Usage:

const conn = await pool.acquire();
try {
  await conn.query("SELECT ...");
} finally {
  pool.release(conn);   // forget this and you have a "connection leak"
}

That's it. That's connection pooling. Two arrays and a counter.

Everything you've heard about pools now decodes for free:

  • "Pool exhaustion" — all connections checked out, the waiters array is growing. Usually a missing release() or slow queries hogging connections.

  • "Acquire timeout" — a waiter that gives up (rejects its promise) after N ms instead of queuing forever.

  • "Idle timeout" — a janitor that closes sockets sitting unused in idle for too long, so the DB isn't holding resources for nothing.

  • "Max pool size" — the bound on total, playing the same role MAX_CONCURRENT did in our request queue. (Some pools also take a min size: connections pre-created and kept warm even when idle — our toy has no equivalent.)

Proof: the real thing is the same two arrays

Don't take my word for it — open pg-pool/index.js, the pool used by node-postgres. Its constructor has:

this._clients = []        // every connection that exists
this._idle = []           // connections sitting alive, waiting for work
this._pendingQueue = []   // callers waiting because the pool is full

Our toy pool, with better names. "Holding a connection alive" is literally an object pushed onto _idle:

class IdleItem {
  constructor(client, idleListener, timeoutId) {
    this.client = client              // wraps a live net.Socket to Postgres
    this.idleListener = idleListener  // if the server dies while parked, evict
    this.timeoutId = timeoutId        // idle-timeout janitor
  }
}

The client holds the underlying socket, the array holds the IdleItem, so nothing closes or garbage-collects the connection. A live socket, held by an object, held by an array — the same trick as parking res in our HTTP queue.

Our drain() is their _pulseQueue(): _pendingQueue.shift() to dequeue the next waiter (FIFO), _idle.pop() to grab a parked connection, newClient() if there's room to grow. Even the production metrics are just array lengths:

get waitingCount() { return this._pendingQueue.length }
get idleCount()    { return this._idle.length }
get totalCount()   { return this._clients.length }

The remaining ~400 lines are exactly the hardening we predicted: idle timeouts, acquire timeouts, maxUses/maxLifetimeSeconds rotation, error listeners on parked clients, double-release protection.

One elegant quirk: reuse is _idle.pop() — LIFO, not FIFO. The most-recently-used connection gets handed out first, keeping a small hot set busy while rarely-used connections age out via idle timeout and close on their own.

The bigger lesson

Your typical Node service is queues all the way down: HTTP requests queue for the event loop, handlers queue for pool connections, queries queue inside the database, and TCP backpressure queues bytes all the way back to the client when anything fills up. None of it is magic. At every layer it's the same primitive:

Hold a reference in an array. Hand it out when there's capacity.

Whenever infrastructure feels magical, try building the naive 30-line version. The real thing is almost always the naive version plus error handling.