Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 016 / 100

Object pool: concepts, trade-offs and production lessons

Reuse expensive objects instead of recreating them.

Expona Admin7 min read

Object pool

Creating expensive objects repeatedly is a hidden performance killer in production systems. The Object pool pattern solves this by maintaining a reusable collection of pre-initialized objects that you borrow when needed and return when done, avoiding the overhead of construction and destruction.

Why Object pool matters

I've debugged enough production incidents to know that resource exhaustion doesn't announce itself politely. It creeps in through wasteful allocation patterns. Database connections, thread pools, buffer pools—these aren't abstract concepts. They're finite resources with real limits. When you create and destroy them constantly, you pay the cost in CPU, memory, and initialization latency. An object pool reverses this: instead of manufacturing fresh instances every time, you maintain a pool of ready-to-use objects that circulate through your application.

The pattern is particularly valuable in systems handling unpredictable load. If you're processing thousands of requests per second, spinning up a new database connection for each one isn't just inefficient—it's a pathway to connection starvation and cascading failures.

Classic examples—Database connections, threads and buffers

The most familiar use case is database connection pooling. When your application starts, the pool pre-creates a configurable number of connections—say, 10–50 depending on load expectations. Each request borrows a connection from the pool, executes its query, then returns the connection to be reused. Without pooling, you'd open a fresh connection for each query: TCP handshake, authentication, resource allocation. With pooling, that overhead happens once at startup.

Threads follow the same logic. Creating an OS thread is expensive. A thread pool (like Java's ExecutorService or .NET's ThreadPool) maintains a set of worker threads waiting for tasks. When work arrives, you hand it off to an available thread. The thread completes the job and returns to the pool to accept more work. This is vastly cheaper than spawning a new thread per task.

Byte buffers are another classic case. In high-performance systems handling network I/O, allocating fresh buffers for every packet is wasteful. A buffer pool maintains pre-allocated byte arrays. Your code borrows a buffer, fills it with data, processes it, then returns the memory back to the pool. Languages like Java and Go have proven this pattern at scale across millions of transactions per second.

How it works—Borrow from the pool, return when done

The mechanics are straightforward conceptually. A pool maintains a collection of ready-to-use objects. When your code needs an object:

  1. Request an object from the pool (borrow)
  2. Use the object
  3. Return the object to the pool (return)

The pool tracks which objects are in use and which are available. If all objects are borrowed and you request another, the pool either waits for one to be returned or raises an error depending on its configuration.

Here's a minimal pool implementation in TypeScript to illustrate the mechanics:

typescript
class ObjectPool<T> {
  private available: T[] = [];
  private inUse: Set<T> = new Set();
  private factory: () => T;
  private maxSize: number;

  constructor(factory: () => T, initialSize: number, maxSize: number) {
    this.factory = factory;
    this.maxSize = maxSize;
    for (let i = 0; i < initialSize; i++) {
      this.available.push(factory());
    }
  }

  acquire(): T {
    if (this.available.length > 0) {
      const obj = this.available.pop()!;
      this.inUse.add(obj);
      return obj;
    }

    if (this.inUse.size < this.maxSize) {
      const obj = this.factory();
      this.inUse.add(obj);
      return obj;
    }

    throw new Error('Pool exhausted');
  }

  release(obj: T): void {
    if (this.inUse.has(obj)) {
      this.inUse.delete(obj);
      this.available.push(obj);
    }
  }
}

This pool pre-allocates objects up to initialSize, tracks borrowing through the acquire method, and collects returns via release. The maxSize` cap prevents unbounded growth.

In a real HTTP context, you'd use it like this:

typescript
class DatabaseConnection {
  constructor(private connectionId: string) {}
  
  query(sql: string) {
    return `[${this.connectionId}] Executing: ${sql}`;
  }
}

const connPool = new ObjectPool(
  () => new DatabaseConnection(`conn-${Math.random()}`),
  5,  // Start with 5 connections
  20  // Never exceed 20
);

// In a request handler
const conn = connPool.acquire();
try {
  const result = conn.query('SELECT * FROM users');
  console.log(result);
} finally {
  connPool.release(conn);
}

The try/finally pattern ensures the connection returns even if an error occurs. This is critical.

You use pools already—Connection pools in Npgsql, Prisma and HttpClient handlers

Most of the time, you're not implementing pools yourself. Libraries do it for you, and understanding what they're doing behind the scenes helps you configure them correctly.

Npgsql (the .NET PostgreSQL driver) ships with a connection pool. When you create a NpgsqlConnection with a connection string, Npgsql pools connections by default. You can configure the pool size:

typescript
// Simulated .NET connection string config (conceptual)
const connString = "Host=localhost;Database=mydb;Max Pool Size=20;Min Pool Size=5";
// Npgsql maintains 5–20 connections automatically

Prisma (the popular ORM for JavaScript) uses Npgsql under the hood for PostgreSQL and maintains its own connection pool. By default, Prisma creates 5 connections and scales to 10. You tune this in your .env or connection configuration.

HttpClient in .NET manages a socket pool internally. When you make HTTP requests, the underlying connections are pooled. This is why you should never dispose of HttpClient per request—that defeats the pooling mechanism. Instead, reuse a single HttpClient instance (or use IHttpClientFactory for dependency injection).

All of these pools follow the same core pattern: maintain ready-to-use resources, lend them on demand, and collect them when done. The difference is someone else wrote the pool and you're configuring it.

Pitfalls—Forgetting to return objects leaks the pool

The most dangerous mistake is forgetting to return borrowed objects. If you acquire a connection, throw an exception, and never call release, that connection stays stuck in the "in use" set. Over time, all pool objects get stuck this way and the pool becomes useless.

Here's what not to do:

typescript
// ❌ Bad: No guarantee of release
const conn = connPool.acquire();
const result = conn.query('SELECT * FROM users');
connPool.release(conn);

// If query throws, release never executes

Always use try/finally or a resource wrapper:

typescript
// ✅ Good: Exception-safe
const conn = connPool.acquire();
try {
  const result = conn.query('SELECT * FROM users');
  return result;
} finally {
  connPool.release(conn);
}

Or wrap it in a helper:

typescript
// ✅ Also good: Helper method
async function withConnection<T>(fn: (conn: DatabaseConnection) => T): Promise<T> {
  const conn = connPool.acquire();
  try {
    return await fn(conn);
  } finally {
    connPool.release(conn);
  }
}

// Usage
const result = await withConnection(conn => conn.query('SELECT * FROM users'));

Another pitfall is holding onto objects longer than necessary. If you borrow a connection to execute one query but keep it for the entire request lifecycle, you reduce pool availability for concurrent requests. Borrow, use, return—as quickly as possible.

Misconfiguring pool size is also common. Too small and you'll hit exhaustion under moderate load. Too large and you waste memory and system resources. Monitor your actual usage patterns and adjust accordingly. Most database drivers let you query current pool stats.

Common mistakes

  • Forgetting exception handling: Not wrapping pool operations in try/finally causes leaked objects and eventual pool starvation.
  • Creating pools per request: If you instantiate a new pool for each incoming request instead of reusing a singleton pool, you lose all the benefits. Pools should live as long as your application.
  • Not resetting object state: If pooled objects retain state from previous use, that state bleeds into new users. Always reset or reinitialize objects after release (or before reuse).
  • Ignoring pool metrics: If you don't monitor pool utilization, you won't know if your pool size is too small or if there's a leak happening.
  • Mixing pool and non-pool usage: In a codebase, if some code paths use pooled connections and others don't, you create inconsistency and make debugging harder.

Key takeaways

  • Object pool reuses expensive objects (database connections, threads, buffers) instead of creating and destroying them repeatedly.
  • The pattern is simple: acquire from the pool, use the object, return it to the pool.
  • You encounter object pools constantly in production libraries—Npgsql connection pools, Prisma ORM pools, and HttpClient socket pools all follow this pattern.
  • Always wrap pool operations in try/finally to guarantee returns, preventing leaks and eventual pool exhaustion.
  • Configure pool size based on your workload and monitor utilization to avoid starvation or wasteful over-allocation.
  • Keep borrowed objects only as long as needed; holding them longer starves other concurrent requests.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles