Skip to content

Pool

A Pool<A, E> owns a bounded set of expensive, scoped resources — database connections, HTTP clients, buffers, anything where acquisition is costly and total concurrency must be capped. Instead of each fiber acquiring and releasing its own resource, fibers borrow a shared resource from the pool and return it automatically.

The pool acquires each item with a scoped effect (Effect<A, E, Scope>), keeps items alive between a minimum and maximum size, and releases everything when the pool’s own scope closes. Borrowing is done with Pool.get, which checks out an item scoped to the caller — when that scope closes, the item goes back into the pool for reuse.

Imagine a resource that takes real time and money to set up — a database connection. Without a pool, 100 concurrent requests would open 100 connections. A pool lets those 100 requests share, say, 10 connections: the 11th request waits for one of the first 10 to be returned.

import { Effect, Pool, Scope } from "effect"
interface Connection {
readonly execute: (sql: string) => Effect.Effect<ReadonlyArray<string>>
readonly close: Effect.Effect<void>
}
// `acquire` is a *scoped* effect: it acquires a connection and registers its
// own release. The pool runs this once per item it keeps alive.
const acquireConnection: Effect.Effect<Connection, never, Scope.Scope> =
Effect.acquireRelease(
Effect.sync(() => ({
execute: (sql: string) => Effect.succeed([`executed: ${sql}`]),
close: Effect.void
})),
(conn) => conn.close
)

The simplest pool is fixed-size: Pool.make keeps exactly size items. Borrow one with Pool.get. Because get returns an Effect<A, E, Scope>, the borrowed item is returned to the pool when the borrowing scope closes — you never call a release yourself.

import { Effect, Pool } from "effect"
const program = Effect.gen(function*() {
// `make` itself needs a Scope: closing that scope shuts the pool down.
const pool = yield* Pool.make({
acquire: acquireConnection,
size: 10
})
// Borrow a connection. `Effect.scoped` opens a scope for the checkout, so the
// connection is returned to the pool the moment this block finishes.
const rows = yield* Effect.scoped(
Effect.gen(function*() {
const conn = yield* Pool.get(pool)
return yield* conn.execute("select * from users")
})
)
return rows
// => ["executed: select * from users"]
})
// `Effect.scoped` here owns the *pool's* lifetime: connections close on exit.
Effect.runPromise(Effect.scoped(program))

concurrency controls how many fibers may use a single item at once (default 1). A pool with size: 4 and concurrency: 2 can serve up to 8 simultaneous checkouts before callers start waiting. This is useful when a single resource (such as an HTTP client with connection multiplexing) safely supports several concurrent operations.

import { Effect, Pool } from "effect"
const pool = Pool.make({
acquire: acquireConnection,
size: 4,
concurrency: 2 // up to 4 * 2 = 8 concurrent checkouts
})

When load is spiky, a fixed size is wasteful (idle resources) or limiting (too few under burst). Pool.makeWithTTL grows the pool up to max under load and reclaims excess idle items after a timeToLive, shrinking back toward min.

import { Duration, Effect, Pool } from "effect"
const program = Effect.gen(function*() {
const pool = yield* Pool.makeWithTTL({
acquire: acquireConnection,
min: 2, // always keep at least 2 connections warm
max: 20, // never exceed 20
timeToLive: Duration.seconds(60) // reclaim idle excess after 60s
})
const conn = yield* Pool.get(pool)
return yield* conn.execute("select 1")
})
Effect.runPromise(Effect.scoped(program))

timeToLiveStrategy chooses how the TTL is measured:

  • "usage" (default) — excess items expire relative to overall pool usage.
  • "creation" — each item expires a fixed timeToLive after it was created.
const pool = Pool.makeWithTTL({
acquire: acquireConnection,
min: 2,
max: 20,
timeToLive: Duration.minutes(5),
timeToLiveStrategy: "creation"
})

targetUtilization (a value in (0, 1], default 1) decides how eagerly the pool grows. At 1, new items are created only once existing items are fully utilized; at 0.5, new items are created when existing ones reach 50% utilization, trading more resources for lower latency under load.

If you discover a borrowed item is unhealthy — a dropped connection, a failed health check — tell the pool to discard it with Pool.invalidate. The item is finalized once it is no longer checked out, and the pool allocates a replacement on demand.

import { Effect, Pool } from "effect"
const useConnection = Effect.gen(function*() {
const pool = yield* Pool.make({ acquire: acquireConnection, size: 10 })
yield* Effect.scoped(
Effect.gen(function*() {
const conn = yield* Pool.get(pool)
const result = yield* conn.execute("ping").pipe(
Effect.catchCause(() =>
// Connection looks broken: discard it instead of returning it.
Effect.as(Pool.invalidate(pool, conn), ["error"] as ReadonlyArray<string>)
)
)
return result
})
)
})

In an application you rarely thread a Pool around by hand. Instead, build it once inside a Context.Service and expose it behind a Layer. Layer.effect runs construction in the layer’s own scope, so the pool (and all its connections) is torn down when the layer closes. Expose a withConnection helper, written with Effect.fn since it returns an Effect.

import { Context, Effect, Layer, Pool, Scope } from "effect"
interface Connection {
readonly execute: (sql: string) => Effect.Effect<ReadonlyArray<string>>
readonly close: Effect.Effect<void>
}
const acquireConnection: Effect.Effect<Connection, never, Scope.Scope> =
Effect.acquireRelease(
Effect.sync(() => ({
execute: (sql: string) => Effect.succeed([`executed: ${sql}`]),
close: Effect.void
})),
(conn) => conn.close
)
class Database extends Context.Service<Database, {
// `withConnection` borrows a connection for the duration of `use`,
// then returns it to the pool automatically.
readonly withConnection: <A, E, R>(
use: (conn: Connection) => Effect.Effect<A, E, R>
) => Effect.Effect<A, E, R>
}>()("app/Database") {
// `Layer.effect` provides a Scope for construction and removes it from the
// result, so the pool lives exactly as long as this layer.
static layer = Layer.effect(
Database,
Effect.gen(function*() {
const pool = yield* Pool.makeWithTTL({
acquire: acquireConnection,
min: 2,
max: 10,
timeToLive: "1 minute"
})
return Database.of({
withConnection: Effect.fn("Database.withConnection")(
(use) => Effect.scoped(Effect.flatMap(Pool.get(pool), use))
)
})
})
)
}
// Callers depend on the service, never on the pool directly.
const query = Effect.gen(function*() {
const db = yield* Database
return yield* db.withConnection((conn) => conn.execute("select * from orders"))
})
Effect.runPromise(query.pipe(Effect.provide(Database.layer)))
// => ["executed: select * from orders"]

Every public export of the Pool module, with a runnable snippet.

Creates a fixed-size pool that keeps exactly size items (min equals max, no growth or shrink). Optional concurrency and targetUtilization tune per-item sharing. Returns Effect<Pool<A, E>, never, R | Scope> — it needs a Scope whose closure shuts the pool down.

import { Effect, Pool } from "effect"
const program = Effect.gen(function*() {
const pool = yield* Pool.make({
acquire: acquireConnection,
size: 5
})
return yield* Effect.scoped(
Effect.flatMap(Pool.get(pool), (conn) => conn.execute("select 1"))
)
// => ["executed: select 1"]
})
Effect.runPromise(Effect.scoped(program))

Creates an elastic pool with min/max bounds that grows under load and reclaims excess idle items after timeToLive. timeToLiveStrategy is "usage" (default) or "creation".

import { Duration, Effect, Pool } from "effect"
const program = Effect.gen(function*() {
const pool = yield* Pool.makeWithTTL({
acquire: acquireConnection,
min: 1,
max: 8,
timeToLive: Duration.seconds(30)
})
return yield* Effect.scoped(
Effect.flatMap(Pool.get(pool), (conn) => conn.execute("select now()"))
)
// => ["executed: select now()"]
})
Effect.runPromise(Effect.scoped(program))

Creates a pool driven by a custom Strategy, giving you full control over background resizing and item reclamation. make and makeWithTTL are both thin wrappers over this (a no-op strategy and TTL strategies, respectively). Use it only when the built-in policies are insufficient.

import { Effect, Pool } from "effect"
// A strategy that does no background resizing and never reclaims —
// equivalent to what `make` uses internally.
const noopStrategy: Pool.Strategy<Connection, never> = {
run: () => Effect.void,
onAcquire: () => Effect.void,
reclaim: () => Effect.succeed(undefined)
}
const program = Effect.gen(function*() {
const pool = yield* Pool.makeWithStrategy({
acquire: acquireConnection,
min: 2,
max: 4,
strategy: noopStrategy
})
return yield* Effect.scoped(
Effect.flatMap(Pool.get(pool), (conn) => conn.execute("ping"))
)
// => ["executed: ping"]
})
Effect.runPromise(Effect.scoped(program))

Borrows one item from the pool as a scoped effect: Effect<A, E, Scope>. The item is returned to the pool when the surrounding scope closes. If the pool is at capacity, the effect waits for an item; if acquisition fails, the effect fails with E (a later get can retry).

import { Effect, Pool } from "effect"
const program = Effect.gen(function*() {
const pool = yield* Pool.make({ acquire: acquireConnection, size: 2 })
yield* Effect.scoped(
Effect.gen(function*() {
const conn = yield* Pool.get(pool) // borrowed here
yield* conn.execute("select 1")
}) // connection returned to the pool here
)
})
Effect.runPromise(Effect.scoped(program))

Marks a checked-out item as unusable so the pool discards and (lazily) replaces it instead of returning it for reuse. Returns Effect<void, never, Scope>. The item is matched by reference equality.

import { Effect, Pool } from "effect"
const program = Effect.gen(function*() {
const pool = yield* Pool.make({ acquire: acquireConnection, size: 3 })
yield* Effect.scoped(
Effect.gen(function*() {
const conn = yield* Pool.get(pool)
// Decide the connection is stale and should not be reused.
yield* Pool.invalidate(pool, conn)
})
)
})
Effect.runPromise(Effect.scoped(program))

invalidate is also dual, so it pipes:

import { Effect, Pool } from "effect"
declare const pool: Pool.Pool<Connection>
declare const conn: Connection
// data-first
Pool.invalidate(pool, conn)
// data-last (pipeable)
pool.pipe(Pool.invalidate(conn))

Type guard that narrows an unknown value to Pool<unknown, unknown>.

import { Effect, Pool } from "effect"
Effect.gen(function*() {
const pool = yield* Pool.make({ acquire: acquireConnection, size: 1 })
console.log(Pool.isPool(pool)) // => true
console.log(Pool.isPool({})) // => false
})

The pool’s value, configuration, and strategy interfaces. Most code only ever touches the Pool value via get/invalidate; the rest are exposed for inspection and for writing a custom Strategy.

The pool value: Pool<A, E>, where A is the item type and E is the error a get may fail with. It exposes its normalized Config and runtime State, and is Pipeable.

import type { Pool } from "effect"
declare const pool: Pool.Pool<Connection>
pool.config.maxSize // number
pool.state.items.size // current item count

The read-only, normalized configuration backing a pool: the scoped acquire effect, concurrency (permits per item), minSize/maxSize, the resizing strategy, and targetUtilization (clamped into [0.1, 1]).

import type { Pool } from "effect"
declare const pool: Pool.Pool<Connection>
const config: Pool.Config<Connection, never> = pool.config
config.minSize // e.g. 2
config.maxSize // e.g. 10
config.concurrency // permits per item
config.targetUtilization // 0.1 .. 1

Mutable runtime state of a pool — its scope, the items/available/ invalidated item sets, the bounding semaphores, the availability Latch, the waiters count, and the isShuttingDown flag. Read it for diagnostics; prefer the high-level operations for control.

import type { Pool } from "effect"
declare const pool: Pool.Pool<Connection>
const state: Pool.State<Connection, never> = pool.state
state.items.size // total acquired items
state.available.size // items free to hand out
state.invalidated.size // items marked for removal
state.waiters // fibers currently waiting for an item
state.isShuttingDown // true once the pool scope is closing

The internal record for one managed value: its acquisition exit, a finalizer, the current refCount, and a disableReclaim flag set when the item is invalidated. You receive PoolItems when implementing a custom Strategy.

import type { Pool } from "effect"
declare const item: Pool.PoolItem<Connection, never>
item.exit._tag // "Success" | "Failure"
item.refCount // how many fibers currently hold it
item.disableReclaim // true when invalidated

The lifecycle contract a pool uses for background resizing and reclamation, with three callbacks:

  • run(pool) — long-running background work (e.g. a TTL sweep loop), forked by the pool.
  • onAcquire(item) — invoked each time an item is allocated.
  • reclaim(pool) — returns a reusable PoolItem to recycle instead of allocating a fresh one, or undefined.
import { Effect, Pool } from "effect"
// Minimal strategy: no background work, no reclamation.
const strategy: Pool.Strategy<Connection, never> = {
run: (_pool) => Effect.void,
onAcquire: (_item) => Effect.void,
reclaim: (_pool) => Effect.succeed(undefined)
}
const pool = Pool.makeWithStrategy({
acquire: acquireConnection,
min: 1,
max: 4,
strategy
})
  • Scope and finalizers — the lifetime model that governs both the pool and each checkout.
  • Acquire and release — how to write the scoped acquire effect a pool needs.
  • Semaphore and Latch — the concurrency primitives a pool is built from.
  • Resource — for caching a single refreshable value rather than pooling many interchangeable ones.