Definition
An object pool keeps resources that are expensive to make or scarce, lends each one to a single user at a time, and takes it back for the next user instead of destroying it.
After Kircher and Jain, Pattern-Oriented Software Architecture, Volume 3 (2004), Pooling
01Notice the repeated setup
The next thumbnail could use the same session.
A thumbnail job needs a processing session: its decoder, its buffers, the settings the job asks for. The first design opens a session for each thumbnail and closes it when the thumbnail is done.
// Each thumbnail opens its own session and closes it when it is done.
for (const thumbnail of thumbnails) {
const session = openSession(); // the setup, paid again for every thumbnail
try {
console.log(job(session, thumbnail));
} finally {
session.close();
}
} Creating, using, and closing a resource per operation is a sensible first design. Its lifetime follows the work, and each thumbnail starts fresh: nothing one thumbnail sets can reach the next, and nobody has to remember to give anything back.
The pressure appears when repeated setup matters or too many simultaneous resources compete for limited capacity.
02Give borrowing a boundary
The pool owns the sessions. A job only borrows one.
Say opening a session takes longer than rendering a thumbnail, and the machine has memory for two sessions at a time. Every thumbnail now pays for setup again, and nothing stops a busy minute from opening ten.
An object pool keeps reusable objects and lends an available one to a caller, which returns it when its work is finished. This lesson uses a bounded pool: two sessions, made once, each lent to one thumbnail at a time.
A third thumbnail gets no session. That is an expected outcome, not permission to silently create a third session. Its caller must decide whether to wait, retry later, or refuse the request.
We model one retained setting: whether to flip the image horizontally. The program returns that setting with each result instead of writing image files. This is the whole program the lab runs: the pool, one job, and three thumbnails.
type Thumbnail = { name: string; flipX?: boolean };
class Session {
id: number;
flipX = false;
constructor(id: number) {
this.id = id;
}
}
// The pool: every session made once, lent to one job at a time.
class SessionPool {
private idle: Session[] = [];
constructor(size: number) {
for (let id = 1; id <= size; id++) this.idle.push(new Session(id));
}
acquire(): Session | null {
return this.idle.shift() ?? null;
}
release(session: Session): void {
session.flipX = false;
this.idle.push(session);
}
}
// One job on a borrowed session. An omitted flipX keeps the session's setting.
function job(session: Session | null, thumbnail: Thumbnail): string {
if (!session) return `${thumbnail.name}: no session free`;
if (thumbnail.flipX !== undefined) session.flipX = thumbnail.flipX;
const look = session.flipX ? 'mirrored' : 'original';
return `${thumbnail.name}: session ${session.id}, ${look}`;
}
const pool = new SessionPool(2);
const first = pool.acquire();
const second = pool.acquire();
const third = pool.acquire();
const a = job(first, { name: 'A', flipX: true });
const b = job(second, { name: 'B' });
const c = job(third, { name: 'C' });
if (first) pool.release(first);
if (second) pool.release(second);
const retry = pool.acquire();
const cRetry = job(retry, { name: 'C' });
console.log([a, b, c, cRetry].join('\n')); | Step | Who | What happens |
|---|---|---|
| Acquire | Pool → job | Takes an idle session off its list and lends it. With none idle, the job gets no session. |
| Use | Job | Uses the session exclusively. The thumbnail can change its setting; no other thumbnail can borrow it meanwhile. |
| Release | Job → pool | Ends the borrowing. The reset clears the flip setting before the session can be borrowed again. |
| No session | The caller | Decides what a thumbnail without a session does: wait, retry later, or refuse it. |
What a production pool addsA checked size, releases that report, retired sessions
The same pool, checked. A size that is not a whole number is rejected. Release says what it
did: released; discarded when the reset reports that the
session cannot be made clean, so it leaves the pool for good; or inactive for a session the pool has not lent. A retired session is not replaced: the pool has fewer usable sessions until its owner decides
on a replacement policy.
withSession borrows, runs the job and releases in finally, so a job
that throws still gives its session back. It ignores what release reports; a service should
count discarded sessions somewhere it can see them. The pool refuses a second release only
while the session is idle: once another job has borrowed it, a stale reference looks exactly
like the new borrower’s. Closing that gap takes a lease object per borrowing that release
invalidates.
export class Session {
id: number;
flipX = false;
constructor(id: number) {
this.id = id;
}
}
export type Release = 'released' | 'discarded' | 'inactive';
/** Puts a session back to its baseline; false when it cannot be made clean. */
export type Reset = (session: Session) => boolean;
const clearFlip: Reset = (session) => {
session.flipX = false;
return true;
};
export class SessionPool {
private idle: Session[] = [];
private lent = new Set<Session>();
private reset: Reset;
constructor(size: number, reset: Reset = clearFlip) {
if (!Number.isInteger(size) || size < 0) {
throw new RangeError(`pool size must be a whole number: ${size}`);
}
for (let id = 1; id <= size; id++) this.idle.push(new Session(id));
this.reset = reset;
}
/** An idle session, or null: the caller decides to wait, retry or refuse. */
acquire(): Session | null {
const session = this.idle.shift();
if (!session) return null;
this.lent.add(session);
return session;
}
release(session: Session): Release {
// Not lent by this pool right now: a second release, or a stranger.
if (!this.lent.delete(session)) return 'inactive';
// A session that cannot be made clean leaves the pool for good.
if (!this.reset(session)) return 'discarded';
this.idle.push(session);
return 'released';
}
get available(): number {
return this.idle.length;
}
}
/** Borrows a session for `use` and gives it back on every path, a throw included. */
export function withSession<T>(pool: SessionPool, use: (session: Session) => T): T | null {
const session = pool.acquire();
if (!session) return null;
try {
return use(session);
} finally {
pool.release(session);
}
} 03Predict the next borrower
A session comes back. Is it ready?
Experiment
Two sessions, three thumbnails
// release (lines 20–23) without its reset, line 21:
release(session: Session): void {
this.idle.push(session);
} Reading the TypeScriptshift, ?? null, private, and null checks
idle.shift() takes the session at the front and returns undefined when the array is empty; ?? null turns that into the null the
signature promises. Release pushes to the back, so the session given back first is lent
first.
acquire() returns Session | null, so strict null checks make every
caller handle “no session free” before it can use one. That is why the job takes Session | null and why each release is guarded with if (first).
private is checked by the compiler only; at run time anyone holding the pool
can reach idle. A #idle field would be private at run time too.
Release takes the object back, but nothing stops a job from keeping its reference and
changing the session afterwards: the pool trusts its borrowers.
04Meet it in your language
You already borrow from pools.
node-postgres (pg 8) keeps a pool of database connections: pool.connect() lends one, client.release() gives it back. The bug its
users meet is a query that throws and skips the release. With the default max of ten, ten failed queries leave every connection lent to nobody, and the next connect() waits; with the default connectionTimeoutMillis of 0, it
waits forever.
import pg from 'pg';
// node-postgres keeps up to max connections and lends each to one caller.
const pool = new pg.Pool({ max: 10 });
const sql = 'select total from invoices where id = $1';
// Before: a query that throws skips the release, and the connection
// stays lent to nobody.
export async function invoiceTotalBefore(id: string): Promise<number> {
const client = await pool.connect();
const result = await client.query(sql, [id]);
client.release();
return Number(result.rows[0]?.total ?? 0);
}
// After: the connection goes back on every path, a throw included.
export async function invoiceTotal(id: string): Promise<number> {
const client = await pool.connect();
try {
const result = await client.query(sql, [id]);
return Number(result.rows[0]?.total ?? 0);
} finally {
client.release();
}
} For a single statement, pool.query() borrows and releases for you. Passing true or an error to release destroys the client instead of returning
it, the pool’s way of retiring a connection you no longer trust. And a connection keeps
session state: a SET search_path reaches whoever borrows it next, so use SET LOCAL inside a transaction.
05Decide when it helps
Reuse is useful when its cleanup contract is believable.
Two separate mistakes can break this lifecycle. A thumbnail can finish and forget to return its session, leaving a perfectly usable session unavailable. Or it can return the session without restoring its baseline, making the previous thumbnail’s setting someone else’s problem.
Give the code a plausible change, and follow what has to change with it.
| The change | A session per thumbnail | A pool |
|---|---|---|
| Opening a session gets twice as slow | Every thumbnail pays it again. | Paid once per session; borrowers don’t notice. |
| At most two sessions may exist at once | Needs a separate limit around the code that opens them. | The pool’s size is the limit. |
| Thumbnails gain a crop setting | Nothing to do: every session starts fresh. | The reset must clear it too, or the next borrower inherits it. |
| A session can break | The next thumbnail opens a new one. | Release has to notice and retire it, and someone has to decide about a replacement. |
Consider a pool when creating the resource has meaningful cost, its supply needs a bound, and you can define when it is safe to lend again. Database connections, browser tabs and some reusable buffers can have that shape. You still need measurements to establish a performance benefit.
Keep per-operation construction when it is cheap or when a fresh resource is the clearest isolation boundary. A pool retains idle objects, adds ownership rules, and creates exhaustion paths. Reusing tiny ordinary objects can cost more complexity than it saves.
The pattern leaves several decisions to you: whether to make the objects up front, as here, or when first needed; which idle one to lend, the one idle longest or the one just returned, which may still be warm; what a caller without one does; and what happens after a reset fails.
The pool owns availability; its caller owns the waiting. Our pool knows nothing about thumbnail priority, request deadlines, or retry order. When acquire finds nothing, the dispatcher keeps the thumbnail in its own pending list and chooses a waiting policy: a bounded queue, cancellation while waiting, or an immediate refusal may each suit a different service. Quietly increasing the limit whenever acquisition fails would defeat the original resource bound.
Watch for a caller holding one object while trying to acquire another from an exhausted pool. If everyone waits while keeping what everyone else needs, more elaborate waiting code will not fix the ownership cycle. Keep the borrowing scope as small as the work allows.
| Need | Useful starting point |
|---|---|
| Reuse scarce resources with exclusive borrowers | A bounded object pool with a return contract. |
| Limit simultaneous work without retaining objects | A semaphore or concurrency limiter. |
| Decide which pending job runs next | A queue and dispatcher; these can use a pool. |
| Share stable data among many simultaneous users | Flyweight, with local context kept separate. |
| Give every operation a fresh baseline | Create, use, and dispose per operation. |
Its neighbours: Flyweight shares stable data among many live users at once, where a pool lends mutable objects to one user at a time. Factory is often how a pool makes its objects, and Lazy initialization is how it makes them only when first needed.
From a synchronous model to real image workCleanup, cancellation, and shutdown
A real render and reset can be asynchronous. Keep the session unavailable until its current operation has stopped and cleanup has finished. Canceling a request does not by itself prove the worker stopped using its session. Do not return it while an old operation can still mutate it.
Keep long operations outside the pool’s bookkeeping lock: the lock guards which sessions are idle, not the work done with them. A reset that can fail needs a timeout and a way to report it, or a wedged session can hold up every release behind it.
For shutdown, stop accepting new work, resolve waiting jobs, let active use stop, and dispose of the resources. Retired resources need disposal as well.
06Make the call
Explain why “finished” does not mean “available.”
Before you open the answer below, explain the thumbnail sessions without saying “object pool”: what stays alive, who may use it, and what has to happen before the next borrower gets it? Then say why a job that has finished does not mean a session that is available.
One way to say itWho owns what, and when a session is ready
Two sessions stay alive for the life of the service. Each has at most one borrower, and the pool, not the borrower, decides who gets one. Ending a borrowing has two parts: giving the session back, on every path including failure, and restoring its baseline before anyone else can have it, because a borrower that leaves a setting out inherits whatever the last one left. A finished job that still holds its session leaves the pool exhausted with nothing running; a session returned without its reset is available but not ready. When the reset fails, the pool retires the session rather than lend an unknown baseline, and replacing it is the pool owner’s decision. Waiting, retries and order belong to the caller, and the limit is the reason for the pool, so it does not grow when acquire comes back empty. And when objects are cheap and plentiful, make a fresh one: that is the clearest reset there is.
Practice in the lab
One step from the lesson’s thumbnails: an invoice service renders each PDF in a headless browser tab, and a tab, like a session, keeps state between uses: the customer it is signed in as, and its layout. The host has memory for only a few tabs. Each level starts from the previous level’s finished code, so you can begin at any of them.
- OBJECT-POOL-101 (easy) Every invoice opens a browser tab of its own
- OBJECT-POOL-102 (medium) A tab that will not sign out Pro
- OBJECT-POOL-103 (hard) Review: cover pages and a spare tab Pro