← Design patterns
Creation Reuse, ownership, and cleanup

Object pool

Borrow it. Use it. Return it ready.

Your image service has thumbnails to prepare. Each job uses a processing session with its own working state. Creating a fresh session for every image is easy to understand. When setup becomes costly, or the service needs a firm resource limit, you start looking for something reusable. Keep two sessions and lend each to one thumbnail at a time. The interesting part comes afterward: how do you know a session is ready for the next thumbnail?

TypeScriptGoRust The same pool in each language.

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.

thumbnails.ts
// 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.

sessions.ts
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'));
The borrowing lifecycle
StepWhoWhat happens
AcquirePool → jobTakes an idle session off its list and lends it. With none idle, the job gets no session.
UseJobUses the session exclusively. The thumbnail can change its setting; no other thumbnail can borrow it meanwhile.
ReleaseJob → poolEnds the borrowing. The reset clears the flip setting before the session can be borrowed again.
No sessionThe callerDecides 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.

sessions.ts
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

sessions.ts
// release (lines 20–23) without its reset, line 21:
release(session: Session): void {
  this.idle.push(session);
}
Suppose release skipped its reset, as above. After A and B give their sessions back, C tries again, and omits flipX. What does C’s retry print?
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.

invoices.ts
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.

What would you have to change tomorrow?
The changeA session per thumbnailA pool
Opening a session gets twice as slowEvery thumbnail pays it again.Paid once per session; borrowers don’t notice.
At most two sessions may exist at onceNeeds a separate limit around the code that opens them.The pool’s size is the limit.
Thumbnails gain a crop settingNothing to do: every session starts fresh.The reset must clear it too, or the next borrower inherits it.
A session can breakThe 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.

Choose the responsibility you need
NeedUseful starting point
Reuse scarce resources with exclusive borrowersA bounded object pool with a return contract.
Limit simultaneous work without retaining objectsA semaphore or concurrency limiter.
Decide which pending job runs nextA queue and dispatcher; these can use a pool.
Share stable data among many simultaneous usersFlyweight, with local context kept separate.
Give every operation a fresh baselineCreate, 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.”

Both thumbnails have finished, but both sessions are still lent, and every new thumbnail gets no session. How should the dispatcher recover?
A lab scheduler lends each sample batch one of two chromatography instruments, and only a validated flush makes an instrument ready for the next batch. Instrument 1’s flush fails while batch 42 waits. Which decision belongs to the instrument pool?
A request handler builds a small options object, three fields, for every request. A teammate proposes pooling them to save allocations. What do you say?

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.