01 / Notice the repeated setup
The next thumbnail could use the same session.
Creating, using, and closing a resource per operation is a sensible first design. Its lifetime follows the work, and each thumbnail starts fresh. The pressure appears when repeated setup matters or too many simultaneous resources compete for limited capacity.
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 with exclusive leases: at most three thumbnails can hold our three sessions at once.
A fourth thumbnail gets no lease. That is an expected outcome, not permission to silently create a fourth 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 programs return that setting with each thumbnail result instead of writing image files, and the small arrow picture makes the flag visible.
02 / Give borrowing a boundary
The session survives. This lease does not.
The pool owns the sessions. A successful checkout creates a lease: one caller’s temporary right to use one session. Session 1 can serve A now and B later, but B receives a new lease. A’s old handle must stop working.
Reserve an idle session.
The pool records an owner before returning the lease. Other checkouts skip that session.
Use it exclusively.
The thumbnail can change the session’s setting. Another thumbnail cannot borrow it during this lease.
Reset, then make available.
The lease ends. Successful cleanup clears the flip setting before the session can be borrowed again.
Two separate mistakes can break this lifecycle. A thumbnail can finish and forget to return its lease, 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.
Our clean return does both jobs. If cleanup reports failure, the pool retires that slot instead of offering it again. The configured pool still has three slots, but fewer usable sessions until an owner provides a replacement policy.
03 / See the shape
Ask for a lease. End it in the same scope.
The basic form constructs the sessions once and finds the first idle, usable slot. The useful version adds the lease: rendering, checking that this is still the current owner, and returning the session after cleanup.
The produce helper shows the central calling rule. Acquire a lease, handle exhaustion,
and arrange its return before work can fail. The full dispatcher admits thumbnails in batches
of up to three, then finishes each thumbnail. Batch admission is visible; these source programs
perform their model renders synchronously.
Create a fixed set of sessions and lend an idle one. Checkout returns no lease immediately when all usable sessions are busy. The lease’s behavior appears in the next view; supporting types are in the complete file.
export class SessionPool {
#slots: Slot[];
#reset: Reset;
constructor(capacity: number, reset: Reset = (session) => session.reset()) {
if (!Number.isInteger(capacity) || capacity < 0 || capacity > 8) {
throw new RangeError('Capacity must be an integer from 0 through 8');
}
this.#slots = Array.from({ length: capacity }, (_, i) => ({
session: new Session(i + 1),
owner: null,
retired: false
}));
this.#reset = reset;
}
tryAcquire(): Lease | null {
const slot = this.#slots.find((slot) => slot.owner === null && !slot.retired);
if (!slot) return null; // The caller decides whether to wait, retry, or refuse work.
const token = Symbol();
slot.owner = token;
return new Lease(slot, token, this.#reset);
}
snapshot(): Snapshot[] {
return this.#slots.map((slot) => ({
id: slot.session.id,
busy: slot.owner !== null,
flipX: slot.session.flipX,
retired: slot.retired
}));
}
} type SessionPool struct {
mu sync.Mutex
slots []slot
reset Reset
}
func NewPool(capacity int, reset Reset) (*SessionPool, error) {
if capacity < 0 || capacity > 8 {
return nil, errCapacity
}
if reset == nil {
reset = func(s *Session) bool { return s.Reset() }
}
p := &SessionPool{slots: make([]slot, capacity), reset: reset}
for i := range p.slots {
p.slots[i].session.id = i + 1
}
return p, nil
}
func (p *SessionPool) TryAcquire() *Lease {
p.mu.Lock()
defer p.mu.Unlock()
for i := range p.slots {
if p.slots[i].owner == nil && !p.slots[i].retired {
lease := &Lease{pool: p, index: i}
p.slots[i].owner = lease
return lease
}
}
return nil // Waiting or refusing belongs to the caller.
}
func (p *SessionPool) Snapshot() []Snapshot {
p.mu.Lock()
defer p.mu.Unlock()
result := make([]Snapshot, len(p.slots))
for i, s := range p.slots {
result[i] = Snapshot{ID: s.session.id, Busy: s.owner != nil, FlipX: s.session.flipX, Retired: s.retired}
}
return result
} The shared contract accepts a capacity from zero through eight. Zero is an empty pool;
checkout returns no lease. Each session begins with flip off. A supplied flip setting
replaces the setting, including explicit false; omission keeps the session’s
current setting. Thumbnail F deliberately fails after changing it.
A release returns released, discarded after a reported reset
failure, or inactive for an old or already-returned lease. Trying to render through
an inactive lease fails. A repeated release cannot reset a new borrower’s session.
Reading the TypeScriptIdentity and finally
Each checkout creates a unique Symbol. The slot stores it as its owner; the
lease keeps the same token. Comparing tokens distinguishes two successive leases of
session 1. A public session number alone would not do that.
finally returns the lease whether rendering returns a result or throws an
ordinary error. The flipX !== undefined check preserves an explicit false. Private fields keep the live slot out of the caller’s normal API,
and snapshots copy scalar values for inspection.
Reading the GoA lease pointer, a mutex, and defer
The slot’s owner is a pointer to its current Lease. A
different checkout gets a different lease. The pool’s mutex protects checkout,
rendering, snapshots, and return in this synchronous model.
defer lease.Release() runs when the surrounding function returns. That is
why the call site has a per-thumbnail finishThumbnail helper: putting every
defer directly in main’s loop would keep those leases until main returned. *bool expresses omitted (nil) separately from explicit false. Read Go’s defer explanation.
Reading the PythonOwner locks, identity tokens, and finally
SessionPool keeps its slots and protects checkout, rendering, snapshots, and
release with one lock. Each lease carries a private object token, so a later borrower cannot
use or release an earlier lease for the same session.
Python uses None to distinguish an omitted flip setting from explicit False. produce uses try/finally for cleanup, and
the concurrency check uses the same lock rather than implying that the pool grows when it
is exhausted.
04 / Predict, borrow, inspect
Make the fourth thumbnail wait.
Watch one session pass from A to B, or step through the completed operations. In Try it, keep three sessions and choose a prediction. Fill the available sessions: A, B, and C now have leases. Try starting the next thumbnail. D stays pending because the pool has nothing available. Finish B and start again: D borrows that same session 2.
Now choose “Forget to return the lease,” fill the sessions, and finish all active thumbnails. Inspect the two counts: no jobs running, three sessions leased. Return one held session and D can finally start.
To expose dirty reuse, choose one session and “Return without resetting.” Start and finish A, then start and finish B. A requests horizontal flipping; B omits the setting. Predict B’s output before finishing it. Switching policy or capacity starts a fresh experiment.
Return it ready.
One session is available.
Session 1 begins with horizontal flip off. Neither thumbnail has run.
Reduced motion: choose a scene to see its completed state.
Read this scene
Session 1 begins with horizontal flip off. Neither thumbnail has run.
Session 1: available; flip off.
| Return policy | After A finishes | What happens to B? |
|---|---|---|
| Reset, then return | Available; flip cleared. | B reuses session 1 and renders an original image. |
| Keep the lease | Still leased; no job running. | B stays pending until A’s owner returns it. |
| Return without reset | Available; flip remains on. | B inherits the flip despite omitting the setting. |
The lab runs the displayed TypeScript pool with a small dispatcher around it. Broken modes deliberately retain completed leases or supply a no-op reset hook that falsely reports success.
Continue to F to see the error path. Its render fails after turning flip on. Clean mode still returns a session with flip off. The thumbnail result and the resource’s readiness are separate facts.
05 / Review the return
A completed thumbnail is only half the story.
Before adding capacity, check who still owns each lease and what happens on failure. These three reviews follow the points where ownership can get lost.
06 / Give the pool a real job
The dispatcher owns the waiting. The pool owns availability.
Imagine an image service adding thumbnail jobs to a dispatcher. The dispatcher chooses which pending job to admit. A thumbnail worker borrows a session, performs the work, stores the result or failure, and returns the lease. A service owner creates the pool when its worker service starts and eventually shuts it down.
Our pool knows nothing about thumbnail priority, request deadlines, or retry order. When checkout fails, the lab keeps the thumbnail in its own pending list. A production dispatcher needs a deliberate waiting policy: a bounded queue, cancellation while waiting, or an immediate refusal may each suit a different service.
Suppose failed sessions now need replacement. Put retirement and replacement under the pool owner so callers continue asking for usable leases. Decide what happens if creating the replacement also fails, and how waiting jobs learn that capacity changed. Quietly increasing the limit whenever acquisition fails would defeat the original resource bound.
The session’s identity can survive many thumbnails. Per-thumbnail data should not accidentally survive with it. Our baseline is one boolean. A real session might also retain pixel buffers, dimensions, clipping, or references to input data, and its reset contract must cover whatever the next borrower relies on.
From a synchronous model to real image workCleanup, cancellation, isolation, 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.
The Go sample holds a mutex during its tiny in-memory render and reset. A real implementation should design its lock boundaries around state changes, keeping long image operations outside a global bookkeeping lock while preserving exclusive ownership.
Reset hooks here must be synchronous, non-reentrant, and must not retain the session. They report failure by returning false. Hook exceptions, panics, process termination, and timeouts are outside this model’s contract. The small produce helper does not report a discarded cleanup outcome separately from the thumbnail result; a service needs cleanup observability too.
For shutdown, stop accepting new work, resolve waiting jobs, let active use stop, and dispose of the resources. Retired resources need disposal as well.
Build UIs?Every live feed you keep to one stream per page respects a pool the browser runs, and one day your own workers will need leases.
Where it already is in your components
You may already follow this rule if your app streams live updates: open one EventSource per page and share it between components, or serve the stream
over HTTP/2. MDN warns that without HTTP/2 the browser allows only a few of these connections per domain, counted across
all tabs. The reason is this lesson’s pool, run by the browser.
For HTTP/1.1, the browser keeps a bounded pool of connections to each host. A request borrows one connection for itself until its response ends, and then the connection goes back for the next request. Chromium allows six per host. A server-sent event stream is a response that does not end, so its lease does not end either. It is section 04’s kept lease, except that nobody forgot anything: the stream is still doing its job.
<script lang="ts">
let { orderId }: { orderId: string } = $props();
let status = $state('Waiting for updates');
// Each card opens its own stream. Over HTTP/1.1 that stream keeps one of the
// browser's connections to this host until the card unmounts.
$effect(() => {
const source = new EventSource(`/api/orders/${orderId}/events`);
source.onmessage = (event) => (status = event.data);
return () => source.close();
});
</script>
<article>
<h3>Order {orderId}</h3>
<p>{status}</p>
</article>
Render this card for six orders and the pool is empty. In Chromium 153, a Save button’s fetch to the same host then waited inside the browser. The server did not receive it until one card
unmounted and closed its stream, and it arrived a few milliseconds later. With three tabs of
the same site holding two streams each, a fourth tab did not even load its page until one of
them closed. That is the fourth thumbnail with no lease, and the browser’s queue is the waiting
policy. DevTools’ timing reference lists “six TCP connections open for this origin” as a reason a request is queued.
So share the lease. Open one stream per page and hand its events to the cards through
context or a store. Over HTTP/2 the same page kept ten streams and a fetch moving on a
single connection, because each request becomes a stream on it rather than a lease on a
whole connection. React once ran a pool you could trip over, too: React 16 reused its event objects and cleared their fields after each handler, which is why older code calls e.persist(). React 17 removed that pooling.
When you have to own it
Now the pool is yours. A code review page shows forty changed files and syntax-highlights
each diff in a Web Worker, so scrolling stays smooth. A worker per file would start forty
threads, each loading the highlighter. A small pool, sized from navigator.hardwareConcurrency and capped, lends one worker to one file at a time. The review page owns it: create the workers
when the page mounts and terminate them when it unmounts, rather than in each file’s component.
Exclusive use is your bookkeeping. A worker handles one message at a time and does not refuse a second: in Chromium, a file posted to a busy worker simply waited until the first file’s work finished. Mark a worker leased when you post to it and available when its reply arrives. Files beyond the pool wait in the page’s own queue, where you choose the order, such as visible files first.
Then reset before reuse. A worker’s module-scope variables survive between messages. If it keeps the current language there and a file with no detected language leaves it out, that file is highlighted as the previous file’s language, the way B inherited A’s flip. Send every setting with each job, or reset in the worker before it replies.
Cancellation is the hard return. Collapse a file mid-highlight and its worker is still
busy; marking it available would lend it while the old job runs. Wait for the reply and
discard it, or call terminate() and create a replacement. A terminated worker fails quietly: in Chromium, postMessage on it did not throw and no reply ever came. Take it out of the pool
the moment you terminate it, as our pool retires a session whose cleanup failed.
07 / Recognize the contract
You may already borrow from a pool.
Look at what an API promises when it says “close,” “release,” or “return.” Ending your use of a handle can leave the underlying resource alive for someone else.
A dedicated database connection has a return point.
Go’s database/sql manages a connection pool. A dedicated DB.Conn gives a caller one connection; Conn.Close returns it to that pool and subsequent
operations on the handle fail. That is the lease boundary in a familiar API. Ordinary DB queries
usually manage the borrowing for you.
Capacity and waiting are real policy choices.
DB.SetMaxOpenConns limits open connections; operations beyond the available capacity
may wait. Go’s guide also discusses idle and lifetime limits. Our example’s immediate “no lease”
result leaves that waiting to its caller.
Go’s sync.Pool has a different promise.
sync.Pool caches temporary objects for reuse, and its entries may disappear without
notification. It does not itself provide this lesson’s fixed session limit, explicit lease ownership,
or resource-disposal lifecycle. The similar name is a reason to inspect the contract.
08 / Make the call
Reuse is useful when its cleanup contract is believable.
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. Browser workers, dedicated connections, 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.
| 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. |
Watch for a caller holding a lease 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.
09 / Take the idea with you
Explain why “finished” does not mean “available.”
Try explaining the thumbnail desk without saying “object pool”: three sessions stay alive; each has at most one borrower; ending a lease cleans the session before another borrower can use it.
Then transfer the idea. A session fails its reset while five thumbnails are pending. Who retires it? Who creates its replacement? Who decides how long those thumbnails wait? Those answers define the system around the small acquire/use/return mechanism.
Connections to follow nextRelated lessons
Flyweight shares stable data among many live users. Ownership, aliasing, and lifetimes explains who can still use a resource. State machine helps make valid lifecycle transitions explicit. Here, those questions meet at the moment one borrower hands a reusable session to the next.