Scope & resources
Every resource and every fiber has exactly one owning scope, and feature code never opens or closes one by hand. This page answers which owner a resource gets, how a keyed or long-lived resource is held, and what a release may do when it fails.
Acquire at the narrowest lifetime tier
Section titled “Acquire at the narrowest lifetime tier”Impact: HIGH the owner decides when the release runs
- Process: unkeyed infrastructure (pool, HTTP client, SDK client) does
acquireReleaseinsidemake, exposed throughLayer.effectand built once at the root. There is noLayer.scopedin v4;Layer.effectis the scoped constructor. - Request: anything one request uses is acquired in the handler.
HttpApiBuilder.groupgives the handler the request’s scope, so a bareacquireReleaseis released after the response. A streaming response hands the scope to the body withHttpEffect.scopeTransferToStream. - Operation: a socket per send or a file lock is wrapped in
Effect.scopedat the narrowest point that covers its use. - On a Cloudflare Worker nothing disposable is acquired at init; see runtime and entrypoint.
❌ Incorrect — a v3 constructor, and a per-call socket held for the process:
export const layer = Layer.scoped(Mailer, Effect.gen(function* () { // no Layer.scoped in v4 const socket = yield* Effect.acquireRelease(openSmtp(), closeSmtp) // lives until shutdown return Mailer.of({ send: (msg) => socket.write(msg) })}))✅ Correct — the provider is process-lifetime; the socket is per operation:
export const layer = Layer.effect(Mailer, Effect.gen(function* () { const config = yield* MailerConfig return Mailer.of({ send: (msg) => Effect.scoped(Effect.gen(function* () { const socket = yield* Effect.acquireRelease(openSmtp(config), closeSmtp) yield* socket.write(msg) })), })}))Source: notes/05-effect-fundamentals/scope-and-resources.md · Decision 1
Build keyed maps only through infra/keyed.ts
Section titled “Build keyed maps only through infra/keyed.ts”Impact: HIGH a raw RcMap replays a failed build to every later get
- A resource that is expensive to build and keyed by an open-ended value (per repo, per tenant,
per workspace) is a
LayerMapwith anidleTimeToLive, built byKeyed.layerMapand held by a layer. The per-key layer does its ownacquireRelease. RcMapstores the lookup’sExit, a failure included. So feature code never callsLayerMap.Service,LayerMap.makeorRcMap.make.infra/keyed.tsdrops a failed entry before its waiters wake, so the nextgetbuilds again.- Callers provide
XMap.get(key)around the effect that uses it, so the lease lasts exactly as long as that effect. Callinggetinside a layer’smakeleases for the whole process, and nothing flags it. - Accepted cost: a key whose build always fails is rebuilt on every
get, with no backoff.
❌ Incorrect — a raw LayerMap; one transient failure poisons the key:
export class RepoIndexMap extends LayerMap.Service<RepoIndexMap>()("@app/repos/RepoIndexMap", { lookup: (repoId: RepoId) => RepoIndex.layer(repoId), idleTimeToLive: "15 minutes",}) {}✅ Correct — the keyed helper, leased inside the request:
export class RepoIndexMap extends Context.Service< RepoIndexMap, LayerMap.LayerMap<RepoId, RepoIndex, RepoIndexOpenFailed>>()("@app/repos/RepoIndexMap") {}
export const layer = Layer.effect( RepoIndexMap, Keyed.layerMap(RepoIndex.layer, { idleTimeToLive: "15 minutes" }),)
// repos/http.tssearch: ({ path, urlParams }) => RepoIndex.RepoIndex.use((index) => index.search(urlParams.q)).pipe( Effect.provide(indexes.get(path.id)))Source: notes/05-effect-fundamentals/scope-and-resources.md · Decision 1, amended
No Scope.make in feature code; a long-lived resource is a keyed entry
Section titled “No Scope.make in feature code; a long-lived resource is a keyed entry”Impact: HIGH no close to forget, no failure path that leaks
- Feature code never calls
Scope.make,Scope.close, orScope.providewith a scope it made. Every scope comes from one of the four owners. - A resource that must outlive the call that created it and be stopped on its own (a child-process
session, a tunnel, a subscription) is an entry in a
Keyed.rcMapheld by a layer.Duration.infinitywhen only an explicit stop ends it. - Start is
map.get(id).pipe(Effect.scoped). Stop ismap.invalidate(id), the helper’s, neverRcMap.invalidate. Shutdown needs no code: the layer’s scope closes every entry. - The exception is
infra/: a helper there may useScope.fork(parent)from a scope that something else closes. The lint rules for this are in linting and formatting.
❌ Incorrect — a hand-made scope that every failure path must remember:
start: (id) => Effect.gen(function* () { const scope = yield* Scope.make() const proc = yield* spawnAgent(id).pipe(Scope.provide(scope)) live.set(id, { proc, scope }) // stop must find and close it})✅ Correct — an RcMap entry owned by the layer:
export const make = Effect.gen(function* () { const sessions = yield* Keyed.rcMap({ lookup: (id: SessionId) => Effect.acquireRelease(spawnAgent(id), (proc) => killAgent(proc).pipe( Effect.timeoutOption("2 seconds"), Effect.ignoreCause({ log: "Warn", message: "agents: release failed" }))), idleTimeToLive: Duration.infinity, }) return Sessions.of({ start: (id) => sessions.get(id).pipe(Effect.scoped, Effect.asVoid), stop: (id) => sessions.invalidate(id), })})Source: notes/05-effect-fundamentals/scope-and-resources.md · Decision 2, amended
Give every fiber an owner; enqueue, never detach
Section titled “Give every fiber an owner; enqueue, never detach”Impact: HIGH a detached fiber outlives SIGTERM and the layers it uses
- No
Effect.forkDetachin feature code. Work that must outlive the request (a webhook, a notification) is offered to a worker queue owned by a layer, built withmakeWorkerfrom error boundaries. - Long-lived loops are
forkScopedinside a layer’smake, through that same helper. A dynamic set of loops keyed by something is aFiberMapcreated in the owning layer. - Concurrency inside one operation stays structured:
Effect.all({ concurrency }),Effect.forEach, orforkChildjoined before returning. - No fire-and-forget fork into the request scope: it is interrupted when the response is sent.
Work that must not be lost is an outbox row, not a
makeWorkeritem (see idempotency and outbox).
❌ Incorrect — detached from every scope:
const rule = yield* rules.create(payload)yield* callWebhook(new RuleCreated({ ruleId: rule.id })).pipe( Effect.ignoreCause({ log: true }), Effect.forkDetach, // runs on after shutdown and after SqlLayer closes)✅ Correct — offered to a worker that a layer owns:
export const make = Effect.gen(function* () { const worker = yield* makeWorker("WebhookWorker", (event: RuleCreated) => callWebhook(event)) return Webhooks.of({ notify: (event) => worker.offer(event) })})
// labeling/http.tsyield* webhooks.notify(new RuleCreated({ ruleId: rule.id })) // returns at onceSource: notes/05-effect-fundamentals/scope-and-resources.md · Decision 3, amended
A release never fails: it logs, and it times out
Section titled “A release never fails: it logs, and it times out”Impact: MEDIUM one dying release turns a clean close into a defect
acquireReleaseis the default bracket.acquireUseReleaseis fine when acquire, use and release sit in one expression.- Wrap anything in a release that can throw or reject in
Effect.ignoreCause({ log: "Warn", message: "<feature>: release failed" }). NeverorDie(lint ruleapp/no-effect-die), and neverEffect.promisefor a promise that can reject. - A release that does I/O carries
Effect.timeoutOption(2s default) and Warns on timeout, so one stuck release cannot eat the shutdown budget. - Acquire in dependency order: finalizers run in reverse. No hand-written
uninterruptibleoruninterruptibleMaskin feature code; that belongs in aninfra/helper.
❌ Incorrect — a failed destroy becomes a defect on the whole scope:
const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => Effect.try(() => finder.destroy()).pipe(Effect.orDie))✅ Correct — a failed release is a Warn line:
const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => Effect.try(() => finder.destroy()).pipe( Effect.ignoreCause({ log: "Warn", message: "repos: index destroy failed" }), ))Source: notes/05-effect-fundamentals/scope-and-resources.md · Decision 4, amended
Deferred
Section titled “Deferred”- Promoting a resource up a tier, or into a
LayerMap— trigger: building it per call is measured to take more than 10% of the call’s p50 latency.