Loading content...
If creating objects is expensive and destroying them wastes that investment, the solution becomes obvious: don't create or destroy them at all during normal operation. Instead, create objects once at startup, maintain them in a ready-to-use state, lend them out when needed, and accept them back when the borrower is done.
This is the essence of the Object Pool Pattern: a managed collection of reusable objects that eliminates the creation/destruction overhead from the hot path of your application. Like a library that loans books rather than printing and shredding them for each reader, an object pool amortizes the expensive initialization cost across many usages.
By the end of this page, you will understand the complete anatomy of the Object Pool Pattern: its participants and their responsibilities, the acquire/release lifecycle, the critical reset contract, and multiple implementation approaches. You'll be able to recognize when and how to apply pooling to your own expensive resources.
Intent:
Manage a pool of reusable objects, avoiding the expense of creating and destroying objects on demand. Objects are acquired from the pool when needed and returned to the pool when no longer in use, ready for the next consumer.
Also Known As:
Motivation:
The Object Pool Pattern addresses scenarios where:
The Core Insight:
Instead of this lifecycle:
CREATE (expensive) → USE (cheap) → DESTROY (overhead)
↓
CREATE (expensive) → USE (cheap) → DESTROY (overhead)
↓
... repeat for each use
The Object Pool provides this lifecycle:
[Pool Creation — one-time startup cost]
↓
ACQUIRE (cheap) → USE (cheap) → RELEASE (cheap) → RESET (cheap)
↓ ↓
ACQUIRE (cheap) → USE (cheap) → RELEASE (cheap) → RESET (cheap)
↓
... repeat indefinitely
[Pool Destruction — one-time shutdown cost]
The expensive operations happen at the boundaries (startup/shutdown); normal operation stays entirely in the cheap inner loop.
If an object costs 100ms to create and is used 10,000 times, the per-use creation cost is 0.01ms (100ms ÷ 10,000). This is the power of amortization—spreading the fixed cost across many uses reduces the effective cost per use to near zero.
The Object Pool Pattern involves several participants, each with distinct responsibilities:
1. PooledObject (Reusable)
The class of objects being pooled. These objects must support:
2. ObjectPool
The pool manager that:
acquire() and release() operations3. Client
The consumer code that:
acquire()release() when done1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
/** * Interface for objects that can be pooled. * Each pooled object must be able to reset itself for reuse. */interface Poolable { /** * Resets the object to a clean, reusable state. * Called when the object is returned to the pool. */ reset(): void; /** * Validates that the object is in a usable state. * Called before lending the object to a client. * @returns true if the object is valid and usable */ validate(): boolean;} /** * The Object Pool manages a collection of reusable objects. * Generic type T must extend Poolable. */interface ObjectPool<T extends Poolable> { /** * Acquires an object from the pool. * Blocks or fails if no objects are available (implementation-dependent). * @returns A ready-to-use pooled object */ acquire(): T; /** * Acquires an object with a timeout. * @param timeoutMs Maximum time to wait for an available object * @returns The object, or null if timeout elapsed */ acquireWithTimeout(timeoutMs: number): T | null; /** * Releases an object back to the pool. * The object is reset and made available for future acquisition. * @param obj The object to return to the pool */ release(obj: T): void; /** * Returns the current pool statistics. */ getStats(): PoolStats; /** * Closes the pool, destroying all pooled objects. * No further operations are permitted after close. */ close(): void;} interface PoolStats { totalSize: number; // Total objects (available + in use) availableCount: number; // Objects ready to be acquired inUseCount: number; // Objects currently acquired by clients createdCount: number; // Total objects ever created destroyedCount: number; // Total objects destroyed waitingCount: number; // Clients waiting for an object}The Structure Diagram:
┌─────────────────────────────────────────────────────────────────┐
│ ObjectPool<T> │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Available Objects │ │
│ │ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ │
│ │ │ Obj 1 │ │ Obj 2 │ │ Obj 3 │ │ Obj 4 │ ... │ │
│ │ └───────┘ └───────┘ └───────┘ └───────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ + acquire(): T │
│ + release(obj: T): void │
│ + getStats(): PoolStats │
└─────────────────────────────────────────────────────────────────┘
│ ▲
│ acquire() │ release()
▼ │
┌─────────────────────────────────────────────────────────────────┐
│ Client │
│ │
│ const obj = pool.acquire(); │
│ try { │
│ // use obj │
│ } finally { │
│ pool.release(obj); │
│ } │
└─────────────────────────────────────────────────────────────────┘
Understanding the acquire-release lifecycle is crucial for correctly using object pools. This lifecycle defines the contract between the pool and its clients.
Phase 1: Acquisition
When a client calls acquire(), the pool performs the following steps:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
class ObjectPoolImpl<T extends Poolable> implements ObjectPool<T> { private available: T[] = []; private inUse: Set<T> = new Set(); private factory: () => T; private maxSize: number; private waitQueue: Array<(obj: T) => void> = []; acquire(): T { // Try to get an existing object while (this.available.length > 0) { const obj = this.available.pop()!; // Validate before returning if (obj.validate()) { this.inUse.add(obj); return obj; } else { // Object is stale/broken, discard and try next this.destroyObject(obj); } } // No available objects — can we create one? const totalSize = this.inUse.size + this.available.length; if (totalSize < this.maxSize) { const newObj = this.factory(); this.inUse.add(newObj); return newObj; } // Pool exhausted — must wait for a release return this.waitForObject(); } private waitForObject(): T { return new Promise<T>((resolve) => { this.waitQueue.push(resolve); }) as unknown as T; // Simplified for illustration } private destroyObject(obj: T): void { // Cleanup resources, GC will handle memory if ('destroy' in obj && typeof obj.destroy === 'function') { (obj as any).destroy(); } }}Phase 2: Usage
During usage, the client treats the pooled object exactly like an object it created directly. The object should be fully functional and stateless (or reset to a known state). Key rules for clients:
Phase 3: Release
When the client calls release(), the pool reclaims the object:
reset() to restore to clean, reusable state.1234567891011121314151617181920212223242526272829303132333435
release(obj: T): void { // Validate this object belongs to us if (!this.inUse.has(obj)) { throw new Error( 'Attempted to release object not acquired from this pool' ); } // Remove from in-use tracking this.inUse.delete(obj); // Reset the object to clean state try { obj.reset(); } catch (e) { // Reset failed — object is corrupted, destroy it this.destroyObject(obj); return; } // Check if anyone is waiting if (this.waitQueue.length > 0) { const waiter = this.waitQueue.shift()!; this.inUse.add(obj); waiter(obj); return; } // No waiters — should we keep this object? if (this.shouldShrink()) { this.destroyObject(obj); } else { this.available.push(obj); }}Failure to release objects is the most common pool-related bug. It leads to pool exhaustion where all objects are 'in use' forever. Always use try-finally or language constructs (try-with-resources, using statements, RAII) to guarantee release.
The reset() method is the heart of object pooling. It's what allows an object to be reused safely by different clients. The reset contract defines what the object promises to do when reset and what the pool guarantees about reset behavior.
The Reset Contract:
What Reset Should Clear:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
class PooledDatabaseConnection implements Poolable { private connection: RawConnection; private transactionState: TransactionState = null; private sessionVariables: Map<string, string> = new Map(); private preparedStatements: Map<string, PreparedStatement> = new Map(); private lastUsedAt: Date; private lastError: Error | null = null; reset(): void { // 1. Roll back any pending transaction if (this.transactionState === TransactionState.ACTIVE) { this.connection.rollback(); } this.transactionState = null; // 2. Clear session-specific variables if (this.sessionVariables.size > 0) { this.connection.execute('RESET ALL'); this.sessionVariables.clear(); } // 3. Clear any cached results or cursors this.connection.clearResults(); // 4. Clear error state this.lastError = null; // 5. Update timestamps for idle tracking this.lastUsedAt = new Date(); // NOTE: We do NOT: // - Close the connection (that's the expensive part) // - Clear prepared statements (they can be reused) // - Reallocate network buffers } validate(): boolean { // Check if connection is still alive if (!this.connection.isConnected) { return false; } // Check for stale connection (idle too long) const idleMs = Date.now() - this.lastUsedAt.getTime(); if (idleMs > 30 * 60 * 1000) { // 30 minutes return false; } // Optionally: ping the database try { this.connection.execute('SELECT 1'); return true; } catch { return false; } }}What Reset Should NOT Clear:
The whole point of pooling is to avoid recreating expensive state. Reset should preserve:
| Preserve (Expensive) | Clear (Per-Use State) |
|---|---|
| Network connection/socket | Transaction state |
| Authentication credentials | Session variables |
| Allocated buffers | Cached query results |
| Compiled prepared statements | Error state from last use |
| TLS session state | Cursor positions |
| Internal connection pool references | User-specific context |
Incomplete reset can cause security vulnerabilities. If one user's authentication token or query results leak to another user through a pooled connection, you have a critical security breach. Reset must be comprehensive for any security-sensitive state.
Let's build a complete, production-quality basic object pool. This implementation includes proper synchronization, timeout support, and lifecycle management.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
interface PoolConfig<T> { /** Creates a new pooled object */ factory: () => T | Promise<T>; /** Resets an object for reuse */ reset: (obj: T) => void | Promise<void>; /** Validates an object is still usable */ validate?: (obj: T) => boolean | Promise<boolean>; /** Destroys an object (cleanup resources) */ destroy?: (obj: T) => void | Promise<void>; /** Minimum objects to keep in pool */ minSize?: number; /** Maximum objects allowed in pool */ maxSize?: number; /** Maximum time (ms) to wait for an object */ acquireTimeoutMs?: number; /** Maximum time (ms) an object can remain idle */ idleTimeoutMs?: number;} interface PooledItem<T> { object: T; createdAt: Date; lastUsedAt: Date;} class GenericObjectPool<T> { private available: PooledItem<T>[] = []; private inUse: Map<T, PooledItem<T>> = new Map(); private waitQueue: Array<{ resolve: (item: PooledItem<T>) => void; reject: (error: Error) => void; timeoutId: NodeJS.Timeout; }> = []; private config: Required<PoolConfig<T>>; private closed: boolean = false; private maintenanceInterval: NodeJS.Timer | null = null; constructor(config: PoolConfig<T>) { this.config = { factory: config.factory, reset: config.reset, validate: config.validate ?? (() => true), destroy: config.destroy ?? (() => {}), minSize: config.minSize ?? 0, maxSize: config.maxSize ?? 10, acquireTimeoutMs: config.acquireTimeoutMs ?? 30000, idleTimeoutMs: config.idleTimeoutMs ?? 300000, // 5 minutes }; } /** * Initializes the pool with minimum number of objects. * Call this before using the pool. */ async initialize(): Promise<void> { const createPromises: Promise<void>[] = []; for (let i = 0; i < this.config.minSize; i++) { createPromises.push(this.createAndAddObject()); } await Promise.all(createPromises); // Start maintenance routine this.maintenanceInterval = setInterval( () => this.performMaintenance(), 60000 // Every minute ); } private async createAndAddObject(): Promise<void> { const obj = await this.config.factory(); const item: PooledItem<T> = { object: obj, createdAt: new Date(), lastUsedAt: new Date(), }; this.available.push(item); } /** * Acquires an object from the pool. */ async acquire(): Promise<T> { this.ensureNotClosed(); // Try to get an available object while (this.available.length > 0) { const item = this.available.pop()!; if (await this.config.validate(item.object)) { this.inUse.set(item.object, item); item.lastUsedAt = new Date(); return item.object; } else { // Object is stale, destroy it await this.destroyItem(item); } } // Can we create a new object? const totalSize = this.inUse.size + this.available.length; if (totalSize < this.config.maxSize) { try { const obj = await this.config.factory(); const item: PooledItem<T> = { object: obj, createdAt: new Date(), lastUsedAt: new Date(), }; this.inUse.set(obj, item); return obj; } catch (error) { throw new Error( `Failed to create pooled object: ${error}` ); } } // Pool exhausted — wait for release return this.waitForObject(); } private waitForObject(): Promise<T> { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { // Remove from queue const index = this.waitQueue.findIndex( w => w.resolve === resolve ); if (index !== -1) { this.waitQueue.splice(index, 1); } reject(new Error( `Timeout waiting for pooled object (${this.config.acquireTimeoutMs}ms)` )); }, this.config.acquireTimeoutMs); this.waitQueue.push({ resolve: (item: PooledItem<T>) => { clearTimeout(timeoutId); resolve(item.object); }, reject, timeoutId, }); }); } /** * Releases an object back to the pool. */ async release(obj: T): Promise<void> { const item = this.inUse.get(obj); if (!item) { console.warn( 'Attempted to release object not owned by this pool' ); return; } this.inUse.delete(obj); // If pool is closed, destroy the object if (this.closed) { await this.destroyItem(item); return; } // Reset the object try { await this.config.reset(obj); } catch (error) { // Reset failed — destroy the corrupted object await this.destroyItem(item); return; } item.lastUsedAt = new Date(); // Check if anyone is waiting if (this.waitQueue.length > 0) { const waiter = this.waitQueue.shift()!; this.inUse.set(obj, item); waiter.resolve(item); return; } // Return to available pool this.available.push(item); }123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
/** * Performs periodic maintenance: evict idle objects, * replenish minimum pool size. */ private async performMaintenance(): Promise<void> { if (this.closed) return; const now = Date.now(); // Evict idle objects above minimum size const toEvict: PooledItem<T>[] = []; for (let i = this.available.length - 1; i >= 0; i--) { const item = this.available[i]; const idleTime = now - item.lastUsedAt.getTime(); // Keep minimum objects, evict idle ones above minimum const totalSize = this.available.length + this.inUse.size; if ( idleTime > this.config.idleTimeoutMs && totalSize > this.config.minSize ) { toEvict.push(item); this.available.splice(i, 1); } } // Destroy evicted objects for (const item of toEvict) { await this.destroyItem(item); } // Replenish to minimum size const totalSize = this.available.length + this.inUse.size; const toCreate = Math.max(0, this.config.minSize - totalSize); for (let i = 0; i < toCreate; i++) { try { await this.createAndAddObject(); } catch (error) { console.error('Failed to replenish pool:', error); } } } private async destroyItem(item: PooledItem<T>): Promise<void> { try { await this.config.destroy(item.object); } catch (error) { console.error('Error destroying pooled object:', error); } } private ensureNotClosed(): void { if (this.closed) { throw new Error('Pool is closed'); } } /** * Returns pool statistics. */ getStats(): PoolStats { return { totalSize: this.available.length + this.inUse.size, availableCount: this.available.length, inUseCount: this.inUse.size, waitingCount: this.waitQueue.length, createdCount: 0, // Would track in production destroyedCount: 0, // Would track in production }; } /** * Closes the pool and destroys all objects. */ async close(): Promise<void> { this.closed = true; // Stop maintenance if (this.maintenanceInterval) { clearInterval(this.maintenanceInterval); } // Reject all waiters for (const waiter of this.waitQueue) { clearTimeout(waiter.timeoutId); waiter.reject(new Error('Pool is closing')); } this.waitQueue = []; // Destroy all available objects for (const item of this.available) { await this.destroyItem(item); } this.available = []; // Note: in-use objects will be destroyed when released }}How clients interact with the pool is as important as the pool implementation itself. Correct usage patterns prevent resource leaks and maximize pooling benefits.
Pattern 1: Try-Finally (Guaranteed Release)
1234567891011
// The fundamental pattern: ALWAYS release in finallyasync function executeQuery(query: string): Promise<Result> { const connection = await connectionPool.acquire(); try { return await connection.execute(query); } finally { // This runs whether execute succeeds OR throws await connectionPool.release(connection); }}Pattern 2: Using Wrapper (Automatic Release)
Encapsulate the acquire-release logic in a higher-order function:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Define a 'using' helper that guarantees releaseasync function using<T, R>( pool: ObjectPool<T>, action: (obj: T) => Promise<R>): Promise<R> { const obj = await pool.acquire(); try { return await action(obj); } finally { await pool.release(obj); }} // Usage becomes simple and safeasync function getUser(userId: string): Promise<User> { return using(connectionPool, async (conn) => { const result = await conn.query( 'SELECT * FROM users WHERE id = $1', [userId] ); return result.rows[0]; });} // Multiple operations in one acquireasync function transferFunds( fromId: string, toId: string, amount: number): Promise<void> { return using(connectionPool, async (conn) => { await conn.beginTransaction(); try { await conn.query( 'UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId] ); await conn.query( 'UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId] ); await conn.commit(); } catch (error) { await conn.rollback(); throw error; } });}Pattern 3: Scoped Resource (Language Support)
Some languages provide built-in syntax for automatic cleanup:
123456789101112131415
// C# using statement with IDisposablepublic async Task<User> GetUserAsync(string userId){ // Connection automatically returns to pool when scope exits await using var connection = await connectionPool.GetAsync(); var result = await connection.QueryAsync( "SELECT * FROM users WHERE id = @id", new { id = userId } ); return result.FirstOrDefault(); // Connection.DisposeAsync() called here, which releases to pool}123456789101112131415161718
// Java try-with-resources with AutoCloseablepublic User getUser(String userId) throws SQLException { // Connection automatically closes (returns to pool) // when try block exits try (Connection conn = dataSource.getConnection()) { PreparedStatement stmt = conn.prepareStatement( "SELECT * FROM users WHERE id = ?" ); stmt.setString(1, userId); ResultSet rs = stmt.executeQuery(); if (rs.next()) { return mapUser(rs); } return null; } // conn.close() called here, which returns to pool}When your language provides automatic resource management (using, try-with-resources, RAII), prefer these over manual try-finally. They're impossible to forget and clearly communicate intent.
We've now established a complete understanding of the Object Pool Pattern as a solution to expensive object creation. Let's consolidate the key concepts:
What's Next: Pool Management
The basic pool we've built works, but production systems need more sophisticated management:
The next page explores Pool Management strategies that make object pools robust, efficient, and observable in production environments.
You now understand the Object Pool Pattern's structure, participants, lifecycle, and the critical reset contract. You can implement a basic pool and use it correctly with guaranteed release patterns. Next, we'll explore advanced pool management for production systems.