Skip to content

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 by K.
  • 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.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.

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

RcMap.make accepts the same idleTimeToLive (a duration or a function of the key) plus an optional capacity:

  • capacity bounds how many keys can be live at once. Exceeding it makes RcMap.get fail with a Cause.ExceededCapacityError rather 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 next get re-acquires it.
  • RcMap.touch(map, key) resets the idle timer for a key, keeping an otherwise idle resource alive longer.
  • RcMap.keys(map) and RcMap.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) })
})
)
}

Reference counting is about resources and their lifecycle, not about memoizing computed values:

  • Use Cache / ScopedCache when you want to remember the result of a lookup (including failures), with TTL-based and capacity-based eviction.
  • Use RcRef / RcMap when 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 current

A ScopedRef is itself created within a Scope; when that scope closes, the currently held value’s scope is closed too.

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(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(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(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(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<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.

The RcRef module is small: a constructor, a borrowing getter, and an invalidation operation.

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(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(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<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 mirrors RcRef but keys resources by K, exposing extra operations to inspect and manage individual keys.

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(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(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(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(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(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<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)

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)