Reference Counting
Sometimes the thing you want to reuse is not a value but a resource: a database connection, an HTTP client, a worker, a websocket. Many fibers may need it at once, it should be acquired only once, and it must be released — but only after the last user is done with it. That is exactly what reference counting provides.
RcRef<A, E>shares a single scoped resource.RcMap<K, A, E>shares scoped resources keyed byK.ScopedRef<A>is the lower-level primitive: a mutable cell holding one scoped resource that you can swap out, closing the previous scope.
RcRef and RcMap are lazy (the resource is acquired on first get),
reference-counted (each borrowing scope adds a reference and a release
finalizer), and self-cleaning (the resource is finalized when the count hits
zero, optionally after an idle delay).
RcRef: one shared resource
Section titled “RcRef: one shared resource”RcRef.make takes an acquire effect — typically an
Effect.acquireRelease that defines how to open and
close the resource. RcRef.get borrows the current resource for the caller’s
scope, acquiring it the first time it is needed.
import { Effect, RcRef } from "effect"
// `acquire` describes how to open and close one connection. RcRef makes// sure it is opened at most once and closed when the last borrower leaves.const program = Effect.gen(function*() { const ref = yield* RcRef.make({ acquire: Effect.acquireRelease( Effect.as(Effect.log("connecting"), { id: 1 }), () => Effect.log("disconnecting") ) })
// Two borrows in nested scopes: the connection is acquired once for the // first get and reused for the second. It is released only when both // borrowing scopes have closed. yield* Effect.scoped( Effect.gen(function*() { const a = yield* RcRef.get(ref) const b = yield* RcRef.get(ref) // a === b: the same acquired resource }) )}).pipe(Effect.scoped)Note the two scopes at play. RcRef.make captures the surrounding scope (here
the outer Effect.scoped) — that is the resource’s ultimate owner. Each
RcRef.get requires a Scope of its own (the inner Effect.scoped); the
reference is held for as long as that inner scope is open.
Idle time-to-live and invalidation
Section titled “Idle time-to-live and invalidation”By default, the moment the reference count drops to zero the resource is
released. Pass idleTimeToLive to keep it alive for a grace period, so a quick
re-acquisition reuses the still-open resource instead of paying to reopen it:
import { Effect, RcRef } from "effect"
const makeClientRef = RcRef.make({ acquire: Effect.acquireRelease( Effect.succeed({ requests: 0 }), () => Effect.log("closing client") ), // Keep the client around for 30 seconds after the last borrower // releases it, in case another request comes in soon. idleTimeToLive: "30 seconds"})RcRef.invalidate forces the next get to acquire a fresh resource — useful
when the current one is stale or broken. It does not revoke resources already
borrowed by active scopes; those keep using the old value until their scopes
close.
RcMap: shared resources by key
Section titled “RcMap: shared resources by key”RcMap is the keyed version: one acquired resource per key, each independently
reference-counted. Reach for it when you pool resources by some identifier — a
connection per database, a client per region, a session per tenant.
import { Effect, RcMap } from "effect"
const program = Effect.gen(function*() { // One connection per database name. Idle connections are released // 5 minutes after their last reference; at most 10 are held at once. const connections = yield* RcMap.make({ lookup: (dbName: string) => Effect.acquireRelease( Effect.as(Effect.log(`open ${dbName}`), { dbName }), () => Effect.log(`close ${dbName}`) ), idleTimeToLive: "5 minutes", capacity: 10 })
// Getting the same key twice acquires the resource once and shares it. yield* Effect.scoped( Effect.gen(function*() { yield* RcMap.get(connections, "orders") yield* RcMap.get(connections, "orders") // reuses the same connection yield* RcMap.get(connections, "billing") // a different connection }) )}).pipe(Effect.scoped)As with RcRef, the map itself is scoped (it releases every remaining entry when
its owning scope closes), and each RcMap.get requires a Scope that holds the
reference.
Capacity, idle TTL, and explicit control
Section titled “Capacity, idle TTL, and explicit control”RcMap.make accepts the same idleTimeToLive (a duration or a function of the
key) plus an optional capacity:
capacitybounds how many keys can be live at once. Exceeding it makesRcMap.getfail with aCause.ExceededCapacityErrorrather than evicting an in-use resource — reference counting means the map cannot safely drop something still in use.RcMap.invalidate(map, key)removes a key; if nothing is currently using it, it is released immediately, and the nextgetre-acquires it.RcMap.touch(map, key)resets the idle timer for a key, keeping an otherwise idle resource alive longer.RcMap.keys(map)andRcMap.has(map, key)inspect the current contents.
A typical use is a service that hands out pooled resources:
import { Context, Effect, Layer, RcMap, Scope } from "effect"
interface Connection { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>}
// A connection pool keyed by database name. Callers borrow a connection// for the duration of their own scope; the pool reuses and releases// connections under the hood.class Pool extends Context.Service<Pool, { readonly get: (db: string) => Effect.Effect<Connection, never, Scope.Scope>}>()("app/Pool") { // Layer.effect runs construction in the layer's own scope, so the // RcMap (and everything it holds) is torn down when the layer closes. static layer = Layer.effect( Pool, Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (db: string) => Effect.acquireRelease( Effect.succeed<Connection>({ query: (_sql) => Effect.succeed([]) }), () => Effect.log(`closing pool connection for ${db}`) ), idleTimeToLive: "2 minutes" })
return Pool.of({ get: (db) => RcMap.get(map, db) }) }) )}RcRef and RcMap vs. Cache
Section titled “RcRef and RcMap vs. Cache”Reference counting is about resources and their lifecycle, not about memoizing computed values:
- Use
Cache/ScopedCachewhen you want to remember the result of a lookup (including failures), with TTL-based and capacity-based eviction. - Use
RcRef/RcMapwhen you want to share a live resource and have it released precisely when the last borrower is done, with idle grace periods.
If you need a single value kept loaded in memory and refreshed periodically, see
Resource.
ScopedRef: a mutable cell holding one scoped resource
Section titled “ScopedRef: a mutable cell holding one scoped resource”ScopedRef<A> is the lower-level primitive that RcRef and
Resource build on. Think of it as a Ref whose value
owns a Scope: setting a new value runs a fresh acquisition in a new scope and
closes the previous value’s scope, so swapping a resource never leaks the old
one. Unlike RcRef/RcMap, there is no reference counting — exactly one value is
held at a time, and you control when it is replaced.
import { Effect, ScopedRef } from "effect"
const program = Effect.gen(function*() { // Start holding one acquired connection. const ref = yield* ScopedRef.fromAcquire( Effect.acquireRelease( Effect.as(Effect.log("open v1"), "conn-v1"), (c) => Effect.log(`close ${c}`) ) )
const current = yield* ScopedRef.get(ref) // => "conn-v1"
// Swap in a new connection; the old one's finalizer runs as part of set. yield* ScopedRef.set( ref, Effect.acquireRelease( Effect.as(Effect.log("open v2"), "conn-v2"), (c) => Effect.log(`close ${c}`) ) ) // => logs "close conn-v1" then completes with v2 acquired
const next = yield* ScopedRef.get(ref) // => "conn-v2"}).pipe(Effect.scoped) // closing the outer scope finalizes whatever value is currentA ScopedRef is itself created within a Scope; when that scope closes, the
currently held value’s scope is closed too.
ScopedRef.make
Section titled “ScopedRef.make”ScopedRef.make(evaluate) creates a ScopedRef from a LazyArg<A> — a plain
value that acquires no resources. Returns Effect<ScopedRef<A>, never, Scope>.
Use this only for non-resourceful values; for acquired values use fromAcquire.
import { Effect, ScopedRef } from "effect"
Effect.gen(function*() { const ref = yield* ScopedRef.make(() => 0) // => ScopedRef holding 0 console.log(yield* ScopedRef.get(ref)) // => 0}).pipe(Effect.scoped)ScopedRef.fromAcquire
Section titled “ScopedRef.fromAcquire”ScopedRef.fromAcquire(acquire) initializes the cell from an acquiring effect,
running acquire in a fresh scope owned by the ref. Returns
Effect<ScopedRef<A>, E, Scope | R>. If acquisition fails, the new scope is
closed with that failure.
import { Effect, ScopedRef } from "effect"
Effect.gen(function*() { const ref = yield* ScopedRef.fromAcquire( Effect.acquireRelease(Effect.succeed("client"), () => Effect.log("close client")) ) console.log(yield* ScopedRef.get(ref)) // => "client"}).pipe(Effect.scoped)ScopedRef.get
Section titled “ScopedRef.get”ScopedRef.get(self) reads the current value, returning Effect<A> (no error or
requirement channel — the value is already held).
import { Effect, ScopedRef } from "effect"
Effect.gen(function*() { const ref = yield* ScopedRef.make(() => "hello") const v = yield* ScopedRef.get(ref) // => "hello"}).pipe(Effect.scoped)ScopedRef.getUnsafe
Section titled “ScopedRef.getUnsafe”ScopedRef.getUnsafe(self) reads the current value synchronously, returning A
directly without wrapping it in an Effect. Use only where an unsafe read is
acceptable.
import { Effect, ScopedRef } from "effect"
Effect.gen(function*() { const ref = yield* ScopedRef.make(() => 42) const v = ScopedRef.getUnsafe(ref) // => 42 (plain number, not an Effect)}).pipe(Effect.scoped)ScopedRef.set
Section titled “ScopedRef.set”ScopedRef.set(self, acquire) (also curryable as ScopedRef.set(acquire)) runs
acquire in a fresh scope and closes the old value’s scope. The operation is
uninterruptible and guarded by a semaphore, so concurrent sets are serialized; it
returns Effect<void, E, Exclude<R, Scope>> and does not complete until the new
value is acquired and the old resources are released.
import { Effect, ScopedRef } from "effect"
Effect.gen(function*() { const ref = yield* ScopedRef.fromAcquire( Effect.acquireRelease(Effect.succeed("v1"), (v) => Effect.log(`close ${v}`)) )
yield* ScopedRef.set( ref, Effect.acquireRelease(Effect.succeed("v2"), (v) => Effect.log(`close ${v}`)) ) // => logs "close v1"; ref now holds "v2"
console.log(yield* ScopedRef.get(ref)) // => "v2"}).pipe(Effect.scoped)ScopedRef (type)
Section titled “ScopedRef (type)”ScopedRef<in out A> is the cell handle. Internally it wraps a
SynchronizedRef holding a [Scope.Closeable, A] pair (the current value
together with the scope that owns its resources); you interact with it only
through the module’s functions.
import type { ScopedRef } from "effect"
// A ScopedRef whose current value is a number-backed resource.type Counter = ScopedRef.ScopedRef<number>Resource is built on this same swap-in-a-fresh-scope, close-the-old-scope
pattern: a Resource is essentially a ScopedRef whose value is refreshed on a
schedule. See Resource for the higher-level, auto-refreshing
variant.
RcRef reference
Section titled “RcRef reference”The RcRef module is small: a constructor, a borrowing getter, and an
invalidation operation.
RcRef.make
Section titled “RcRef.make”RcRef.make({ acquire, idleTimeToLive? }) stores the acquisition effect,
captures the surrounding Scope, and returns an
Effect<RcRef<A, E>, never, R | Scope>. The resource is acquired lazily on the
first get; idleTimeToLive (a Duration.Input) keeps a zero-reference
resource alive for that long before release.
import { Effect, RcRef } from "effect"
const program = Effect.gen(function*() { const ref = yield* RcRef.make({ acquire: Effect.acquireRelease( Effect.as(Effect.log("acquire"), "resource"), // => logs "acquire" () => Effect.log("release") ), idleTimeToLive: "10 seconds" })
yield* Effect.scoped(RcRef.get(ref)) // => logs "release" 10s after scope closes}).pipe(Effect.scoped)RcRef.get
Section titled “RcRef.get”RcRef.get(self) borrows the current resource for the caller’s Scope,
acquiring it on demand. It returns Effect<A, E, Scope>; the reference is
released when the borrowing scope closes, and the resource is finalized once the
last reference is gone (subject to idleTimeToLive).
import { Effect, RcRef } from "effect"
Effect.gen(function*() { const ref = yield* RcRef.make({ acquire: Effect.acquireRelease( Effect.succeed("conn"), () => Effect.log("close conn") // => logged once, after both borrowers leave ) })
// Two concurrent borrowers share a single acquisition. yield* Effect.all( [Effect.scoped(RcRef.get(ref)), Effect.scoped(RcRef.get(ref))], { concurrency: 2 } ) // => acquired once, "close conn" logged once}).pipe(Effect.scoped)RcRef.invalidate
Section titled “RcRef.invalidate”RcRef.invalidate(self) drops the currently cached resource so the next get
re-acquires a fresh one. Returns Effect<void>. Active borrowers keep the old
resource until their scopes close.
import { Effect, RcRef } from "effect"
Effect.gen(function*() { const ref = yield* RcRef.make({ acquire: Effect.acquireRelease(Effect.succeed(Math.random()), () => Effect.void) })
yield* RcRef.invalidate(ref) // => next get re-acquires from scratch}).pipe(Effect.scoped)RcRef (type)
Section titled “RcRef (type)”RcRef<out A, out E = never> is the handle returned by make. A is the
resource type and E the error its acquisition can produce. It carries no public
fields; interact with it through get and invalidate.
import type { RcRef } from "effect"
// A reference-counted handle to a string resource that cannot fail.type StringRef = RcRef.RcRef<string>RcMap reference
Section titled “RcMap reference”RcMap mirrors RcRef but keys resources by K, exposing extra operations to
inspect and manage individual keys.
RcMap.make
Section titled “RcMap.make”RcMap.make({ lookup, idleTimeToLive?, capacity? }) creates a scoped,
reference-counted map. lookup: (key: K) => Effect<A, E, R> acquires a resource
for a key on first access. idleTimeToLive accepts either a single
Duration.Input or a per-key function (key: K) => Duration.Input. Passing
capacity switches to an overload whose error channel adds
Cause.ExceededCapacityError — a get beyond the limit fails rather than
evicting an in-use resource.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (region: string) => Effect.acquireRelease( Effect.succeed(`client:${region}`), () => Effect.log(`close ${region}`) ), // Per-key idle TTL: keep "us-east" longer than other regions. idleTimeToLive: (region) => region === "us-east" ? "5 minutes" : "30 seconds", capacity: 100 // => get fails with ExceededCapacityError past 100 live keys })
yield* Effect.scoped(RcMap.get(map, "us-east")) // => "client:us-east"}).pipe(Effect.scoped)RcMap.get
Section titled “RcMap.get”RcMap.get(map, key) (also curryable as RcMap.get(key)) acquires the resource
for key via lookup, or retains the already-cached one, incrementing its
reference count for the current Scope. Returns Effect<A, E, Scope>.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (key: string) => Effect.succeed(`value-${key}`) })
const v = yield* RcMap.get(map, "foo") // => "value-foo"}).pipe(Effect.scoped)RcMap.keys
Section titled “RcMap.keys”RcMap.keys(map) returns Effect<Iterable<K>> of the keys currently held. If the
map has been closed, the effect is interrupted.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (k: string) => Effect.succeed(k) })
yield* RcMap.get(map, "foo") yield* RcMap.get(map, "bar")
const ks = yield* RcMap.keys(map) // => ["foo", "bar"]}).pipe(Effect.scoped)RcMap.invalidate
Section titled “RcMap.invalidate”RcMap.invalidate(map, key) removes a key from the map. If its reference count is
zero it is released immediately; the next get performs a fresh lookup. Returns
Effect<void>.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (key: string) => Effect.acquireRelease(Effect.succeed(key), () => Effect.log(`close ${key}`)) })
yield* RcMap.invalidate(map, "cache") // => releases "cache" if idle}).pipe(Effect.scoped)RcMap.has
Section titled “RcMap.has”RcMap.has(map, key) returns Effect<boolean> indicating whether the key is
currently held, without running the lookup. A closed map always returns false.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (k: string) => Effect.succeed(k) })
const before = yield* RcMap.has(map, "foo") // => false yield* RcMap.get(map, "foo") const after = yield* RcMap.has(map, "foo") // => true}).pipe(Effect.scoped)RcMap.touch
Section titled “RcMap.touch”RcMap.touch(map, key) resets the idle timer for a key, keeping an otherwise idle
resource alive for another full idleTimeToLive from now. Returns Effect<void>;
it is a no-op when the key is absent or has a zero TTL.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (k: string) => Effect.acquireRelease(Effect.succeed(k), () => Effect.void), idleTimeToLive: "10 seconds" })
yield* RcMap.touch(map, "session") // => resets the 10s expiry for "session"}).pipe(Effect.scoped)RcMap (type)
Section titled “RcMap (type)”RcMap<in out K, in out A, in out E = never> is the map handle. It exposes the
configured lookup, capacity, idleTimeToLive (always normalized to a
per-key function on the value), and a mutable state field.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (k: string) => Effect.succeed(k), capacity: 10 })
console.log(map.capacity) // => 10}).pipe(Effect.scoped)RcMap.State
Section titled “RcMap.State”State<K, A, E> is the union State.Open<K, A, E> | State.Closed exposed on
map.state. Open carries a MutableHashMap of entries while the map is active;
Closed ({ _tag: "Closed" }) means the owning scope has closed and operations
no longer mutate the map.
import { Effect, RcMap } from "effect"
Effect.gen(function*() { const map = yield* RcMap.make({ lookup: (k: string) => Effect.succeed(k) })
if (map.state._tag === "Open") { // map.state.map is the MutableHashMap of live entries }}).pipe(Effect.scoped)