Skip to content

Object Pool

Reusing expensive objects instead of creating them — connection pools, the sizing question, and why a pool is a queue with a failure mode.

Advanced18 minUpdated 8 August 2026

Object Pool is not in the classic catalogue and is one of the few patterns you will almost certainly use without choosing to: every database driver, HTTP client and thread executor you touch has one inside.

The pattern keeps a set of expensive-to-create objects alive and hands them out on request. It is worth understanding properly because a pool is not a performance trick, it is a concurrency control, and its most important behaviour is what it does when it runs out.

When a pool is warranted

The object must be expensive to create relative to how long it is used, and it must be safely reusable.

A database connection is the canonical case: a TCP handshake, a TLS negotiation and an authentication round trip — often 20–100 ms — to run a query that takes 2 ms. Creating one per query means the cost of connecting dominates entirely, and the database sees a connection storm it must also pay for.

Compare a plain object with three fields: creating it costs nanoseconds, and pooling it adds bookkeeping, a lifetime question and a source of bugs to save nothing. Object pools for ordinary objects are a well-known way to make a program slower and more complicated at the same time, because modern allocators are extremely good and a pooled object defeats generational garbage collection by staying alive.

The shape

Conceptual diagramyesnoyesnoyesnoCaller: acquire()Idle objectavailable?Hand it overmark in-useBelow max size?Create a new oneBlock on the wait queueTimeout reached?Fail fastpool exhaustedCaller uses itrelease() — validate,reset, return to idle
Diagram, as text
  • Caller: acquire() leads to Idle object available?
  • Idle object available? leads to Hand it over mark in-use (yes)
  • Idle object available? leads to Below max size? (no)
  • Below max size? leads to Create a new one (yes)
  • Below max size? leads to Block on the wait queue (no)
  • Block on the wait queue leads to Timeout reached?
  • Timeout reached? leads to Fail fast pool exhausted (yes)
  • Timeout reached? leads to Hand it over mark in-use (no)
  • Hand it over mark in-use leads to Caller uses it
  • Caller uses it leads to release() — validate, reset, return to idle
  • release() — validate, reset, return to idle leads to Idle object available?

Four decisions define a pool, and every one of them is a failure mode if left to a default.

Maximum size. The hard limit on concurrency against the resource. This is the pool's most important property and it is a systems decision, not a performance tuning knob.

Acquire timeout. How long a caller waits before failing. Without one, a saturated pool turns into an unbounded queue and the whole service hangs rather than shedding load.

Validation. A pooled connection can be dead — the database restarted, a firewall dropped an idle socket. Handing out a dead connection turns a pool into an intermittent-failure generator.

Reset. The object must be returned to a clean state. A connection with an open transaction, or a buffer holding the last caller's bytes, leaks state across requests.

Why maximum size is not a performance knob

The instinct when a pool is exhausted is to make it bigger. That is usually wrong, and understanding why is the main thing this pattern teaches.

The pool size is the concurrency limit you are imposing on the downstream resource. A database has a finite number of workers and a finite amount of memory per connection. Sizing your pool at 500 does not give you 500× throughput; it gives the database 500 concurrent queries to context-switch between, and past its actual capacity, throughput falls while latency climbs for everyone.

The queue is doing you a favour. Waiting in your process is far cheaper than overwhelming the shared resource, and a pool that blocks is applying backpressure — the same mechanism discussed under rate limiting and resilience patterns, expressed as a resource limit.

If callers are timing out on acquisition, the honest diagnoses are, in order: queries are slower than they should be; connections are being held longer than the work requires; there are more application instances than the database was sized for. Enlarging the pool addresses none of them and hides all three.

Leaks: the failure mode that matters

The defining bug of pools is the object that is acquired and never released.

const conn = await pool.acquire();
const rows = await conn.query(sql);   // throws
await pool.release(conn);             // never runs

One leaked connection is invisible. A leak on an error path that fires under load removes capacity exactly when it is needed, and the service dies with the failure mode "everything times out" — while the database sits idle, holding open connections nobody is using.

The structural fix is to make release impossible to forget: never hand the raw object to arbitrary code.

await pool.withConnection(async (conn) => {
  return conn.query(sql);
});   // release happens in a finally, always

A callback-scoped API is strictly better than acquire/release because the correct behaviour is the only behaviour available. Where a language has scope-based cleanup — using, defer, context managers, try-with-resources — that is the same idea with syntax support.

Operationally, three signals catch what design does not: acquisition wait time, in-use count versus maximum, and a leak detector that logs a stack trace when an object is held beyond some threshold. The last one is the only thing that tells you which code path leaked, and it is worth enabling permanently in staging.

Check your understanding

Common mistakes

Pooling ordinary objects. Allocation is cheap and pooling adds bookkeeping, longer lifetimes and a new class of bug.

No acquire timeout. A saturated pool becomes an unbounded queue, and the service hangs instead of shedding load.

Treating maximum size as a tuning knob. It is the concurrency limit on a shared resource, and it must be reasoned about across all instances.

Forgetting to multiply by instance count. Twenty per process across thirty instances is six hundred connections at the database.

Handing out raw objects with acquire/release. Every caller must then get cleanup right on every error path, forever.

No validation on acquire. Stale objects produce intermittent failures that look like network flakiness.

No reset on release. An open transaction or leftover buffer contents leak state into the next caller's request.

Practice

A service with a pool of 20 connections per instance runs on 40 instances against one database with a 500-connection limit. Latency is fine at normal load and collapses during traffic spikes, with 'too many connections' errors at the database. Analyse and propose fixes.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Do the arithmetic first.

Show a strong answer

The arithmetic, which is the whole diagnosis: 20 × 40 = 800 potential connections against a limit of 500. At normal load the pools are not full, so total usage sits under the limit and nothing is wrong. During a spike every pool tries to grow towards its maximum simultaneously, the aggregate crosses 500, and the database starts refusing connections. Every instance is behaving exactly as configured; the configuration was never checked as a whole.

Why this is worse than plain exhaustion. Local exhaustion causes a caller to wait — bad but orderly. Crossing the database's limit causes connection failures that hit instances at random, including instances that were serving healthy traffic. The failure is global and disorderly, and retries make it worse by generating more connection attempts at precisely the wrong moment.

Immediate fix: make the fleet-wide total fit the budget.

  • Reduce the per-instance maximum so that maximum × instances stays comfortably under the limit — allowing headroom for administrative connections, migrations and monitoring, which also consume from the same budget. With 40 instances and 500 connections, 10 per instance leaves 100 spare.
  • Callers now queue locally instead of failing globally, which is the behaviour you want: backpressure inside one instance rather than a shared resource collapsing.
  • Set an acquire timeout if there is not one, so a saturated pool sheds load rather than accumulating an unbounded queue.

Then remove the coupling to instance count, because the arithmetic will break again the next time the fleet scales.

  • Tie pool size to the fleet size if the platform can supply it, or move the limit to a proxy in front of the database — a connection pooler that multiplexes many application connections onto few database ones. That makes the database's limit a property enforced in one place rather than an invariant maintained by 40 copies of a configuration file.

Then attack the demand, since a spike should not need 800 connections.

  • Measure connection hold time. If a connection is held across an external API call or through application-side processing, the fix is to hold it only for the query.
  • Look for N+1 query patterns, which multiply connection demand exactly in proportion to load.

What I would monitor afterwards: aggregate connections at the database against its limit — one number, alerting well before the ceiling — plus per-instance acquisition wait time. The first catches the fleet-wide problem the per-instance metrics structurally cannot see.

Design the acquire/release contract for a pool of large reusable byte buffers used by a file-processing service. State what happens on each of: normal release, release of a buffer the caller resized, release after an exception, and a buffer never released.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

For each case, ask what the next caller would observe if you got it wrong.

Show a strong answer

Normal release. The buffer is returned to the idle set after its contents are considered invalid — not necessarily zeroed, but its logical length reset to zero so no reader can see previous bytes. Whether to zero the memory is a security decision: if buffers carry data from different tenants or users, zero them, because the cost is small next to the consequence of one caller reading another's data. I would zero by default and make not-zeroing an explicit opt-in for a measured hot path.

A buffer the caller resized. This is the case that quietly ruins pools. If callers can grow buffers, the pool slowly fills with objects far larger than the common case, and memory grows without bound while every metric looks healthy.

  • Reject oversized buffers on release — discard them and let them be collected, rather than pooling them.
  • Keep the maximum pooled size explicit and bounded, so the pool's worst-case memory is maxSize × capacity and can be reasoned about.
  • If resizes are frequent, that is a sizing signal: the pool's capacity is wrong for the workload, and the fix is to change the capacity rather than to accumulate outliers.

Release after an exception. It must still happen, which is why the API should be callback-scoped rather than acquire/release. The buffer's contents after a failure are undefined, so it is reset like any other. The one exception is a failure that could have left the buffer in a state the pool cannot verify — for example, if an asynchronous operation might still be writing into it. In that case the safe action is to discard rather than reuse: a pool that returns a buffer another operation still holds a pointer to produces data corruption, which is worse than an allocation.

Never released. With a callback-scoped API this should be structurally impossible, but a caller can still retain the buffer past the callback and write to it later — which is the same corruption risk from the other direction.

  • Invalidate the handle when the callback returns, so a later write throws instead of silently corrupting the next caller's data.
  • Detect the leak operationally with a threshold on hold time that logs a stack trace, and a gauge of in-use versus maximum.

The principle underneath all four cases: the pool must guarantee that a caller can never observe another caller's data, and it should prefer discarding an object over reusing one it cannot prove is clean. Allocation is cheap; cross-request data leakage is not.

Key takeaways

  • Pool things with real acquisition cost or an operating-system limit — connections, threads, large buffers. Pooling plain objects usually makes programs slower and buggier.
  • A pool is a concurrency control, not a performance trick. Its maximum size is the limit you impose on the downstream resource.
  • Enlarging an exhausted pool is usually wrong. Investigate slow queries, over-long hold times and instance count first.
  • Multiply the pool size by the number of instances before comparing it to a shared resource's limit.
  • Always set an acquire timeout, or a saturated pool becomes an unbounded queue and the service hangs instead of shedding load.
  • Validate on acquire and reset on release, or you get intermittent failures and cross-request state leakage.
  • Prefer a callback-scoped API so release cannot be skipped on an error path, and run a leak detector that logs a stack trace.

Further reading

  • Mark Grand, Patterns in Java, Volume 1 (Wiley, 1998) — an early statement of the object pool pattern.
  • Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018) — resource pools, blocked threads and the failure modes of exhaustion.
  • Brian Goetz and others, Java Concurrency in Practice (Addison-Wesley, 2006) — thread pools and bounded queues.
  • Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002).
  • Jeffrey Dean and Luiz André Barroso, "The Tail at Scale" (Communications of the ACM, 2013) — why queueing at a saturated resource dominates tail latency.

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonAdapter
Lessons in this track