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.

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