Skip to content

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.

Impact: HIGH the owner decides when the release runs

  • Process: unkeyed infrastructure (pool, HTTP client, SDK client) does acquireRelease inside make, exposed through Layer.effect and built once at the root. There is no Layer.scoped in v4; Layer.effect is the scoped constructor.
  • Request: anything one request uses is acquired in the handler. HttpApiBuilder.group gives the handler the request’s scope, so a bare acquireRelease is released after the response. A streaming response hands the scope to the body with HttpEffect.scopeTransferToStream.
  • Operation: a socket per send or a file lock is wrapped in Effect.scoped at 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 LayerMap with an idleTimeToLive, built by Keyed.layerMap and held by a layer. The per-key layer does its own acquireRelease.
  • RcMap stores the lookup’s Exit, a failure included. So feature code never calls LayerMap.Service, LayerMap.make or RcMap.make. infra/keyed.ts drops a failed entry before its waiters wake, so the next get builds again.
  • Callers provide XMap.get(key) around the effect that uses it, so the lease lasts exactly as long as that effect. Calling get inside a layer’s make leases 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.ts
search: ({ 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, or Scope.provide with 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.rcMap held by a layer. Duration.infinity when only an explicit stop ends it.
  • Start is map.get(id).pipe(Effect.scoped). Stop is map.invalidate(id), the helper’s, never RcMap.invalidate. Shutdown needs no code: the layer’s scope closes every entry.
  • The exception is infra/: a helper there may use Scope.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.forkDetach in feature code. Work that must outlive the request (a webhook, a notification) is offered to a worker queue owned by a layer, built with makeWorker from error boundaries.
  • Long-lived loops are forkScoped inside a layer’s make, through that same helper. A dynamic set of loops keyed by something is a FiberMap created in the owning layer.
  • Concurrency inside one operation stays structured: Effect.all({ concurrency }), Effect.forEach, or forkChild joined 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 makeWorker item (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:

labeling/webhooks.ts
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.ts
yield* webhooks.notify(new RuleCreated({ ruleId: rule.id })) // returns at once

Source: 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

  • acquireRelease is the default bracket. acquireUseRelease is 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" }). Never orDie (lint rule app/no-effect-die), and never Effect.promise for 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 uninterruptible or uninterruptibleMask in feature code; that belongs in an infra/ 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

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