Runtime
Provision a Layer, execute programs and shut down gracefully.
Runtime is the managed execution boundary. It connects a complete Layer to a
backend, owns the Layer's root Scope, and checks each program against the
Services that the Layer provides. Without configuration it creates a fresh
MapLayerBackend. The published Runtime entrypoint is officially supported on
Node.js and Bun; CI uses the latest Bun release by default and the current
Node.js LTS for interoperability smoke tests.
Long-lived Runtime
const runtime = await Runtime.make(AppLive)
const result = await runtime.run(loadDashboard)
await runtime.dispose()The Runtime is designed for Node.js and Bun applications that execute many
programs against one environment. Layer-scoped resources remain alive
between calls to run.
Runtime also implements JavaScript's async disposal protocol:
await using runtime = await Runtime.make(AppLive)
const result = await runtime.run(loadDashboard)For a callback-scoped Runtime, use Runtime.use:
const result = await Runtime.use(AppLive, (runtime) => runtime.run(loadDashboard))Both forms dispose the Runtime after the work settles.
Non-owning executor capability
Framework adapters and other long-lived components can capture a restricted executor view from a Runtime execution:
const captureExecutor = Effect.fn(async function* () {
const executor = yield* Runtime.executor<Database>()
return Result.ok(executor)
})
const executor = (await runtime.run(captureExecutor)).unwrap()
await executor.run(loadDashboard)Runtime.Executor<R> exposes only run and runWith. Each call creates a new
child execution on the same Runtime root and preserves the Runtime's resolver,
signals, observers, metadata and cleanup semantics. Capturing it from a
request does not capture that request's Scope or request-local Services, and
the executor cannot dispose, warm up or inspect the owning Runtime. Ordinary
application code can continue using runtime.run directly.
Warmup and observability
Layer providers are lazy by default. Servers that need configuration failures before accepting requests can resolve every provider during startup:
const runtime = await Runtime.make(AppLive, { warmup: true })The same check can be requested later with await runtime.warmup(). A warmup
failure reports the failing Service and resolution path, closes resources that
were already acquired, disposes the backend and rejects the Runtime. No
partially warmed Runtime is returned by Runtime.make.
Runtime observers are optional and dependency-free. Hooks are best effort;
observer failures never change execution or cleanup results. Start and end
execution events share a collision-resistant executionId and include the
optional Program name and readonly attributes:
const runtime = await Runtime.make(AppLive, {
observers: [
{
onServiceResolve: (event) => trace('resolve', event.service.serviceTag),
onServiceAcquire: (event) => trace('acquire', event.service.serviceTag),
onExecutionStart: ({ executionId, name }) => trace('execution.start', { executionId, name }),
onExecutionEnd: ({ executionId, name, outcome, durationMs }) =>
metrics.observe('program.duration_ms', durationMs, {
executionId,
name,
outcome: outcome.status
}),
onResourceRelease: (event) => trace('release', event.service.serviceTag)
}
]
})
const result = await runtime.run(loadDashboard, {
attributes: { requestId, userId }
})durationMs uses the host monotonic clock and includes Program plus execution
Scope cleanup. Attributes are shallow-copied per run and the Runtime never
serializes or inspects their values. Do not attach secrets, large mutable
objects, or sensitive data that an observer might log or use as metric labels.
An integration package can translate these events to OpenTelemetry or another telemetry system without adding that dependency to better-effect.
For a deterministic startup graph, use the public testing observer with Layer warmup:
import { Runtime } from 'better-effect'
import { RuntimeGraphObserver } from 'better-effect/testing'
const graph = RuntimeGraphObserver.make({ rootLabel: 'Runtime' })
const runtime = await Runtime.make(AppLive, {
warmup: true,
observers: [graph]
})
console.log(graph.toJSON())
console.log(graph.toMermaid())
await runtime.dispose()The graph reflects observed resolution paths, so lazy Services do not appear
until they are resolved. Snapshots are sorted, detached and immutable, and keep
only literal Service tags and counters; instances, scopes, causes and execution
attributes are not serialized. Call graph.clear() before a later diagnostic
session.
Runtime inspection
For coarse, synchronous diagnostics, call runtime.inspect():
const inspection = runtime.inspect()
// {
// state: 'active',
// warmup: 'idle',
// activeExecutions: 0,
// executions: [],
// services: ['Database', 'UserRepository'],
// shutdownSignalAborted: false
// }The snapshot and its arrays are detached and immutable. It contains only public
Service tags and execution IDs, optional names and start timestamps. Execution
entries remain present until execution Scope cleanup settles. The warmup field
is idle, running, completed or failed; the lifecycle state is active,
disposing or disposed. shutdownSignalAborted reports the Runtime's
cooperative shutdown signal, when disposal has requested cancellation.
Inspection is diagnostic information, not a lock, synchronization primitive or
readiness guarantee. It does not resolve Services, warm the Runtime, fork a
Scope, invoke observers, expose providers or instances, or cancel/force-stop
executions. Use dispose() and cooperative AbortSignal handling for lifecycle
coordination.
Cooperative cancellation
Runtime cancellation is based on AbortSignal; it does not introduce fibers or
a scheduler. Pass a signal to one execution:
const result = await runtime.run(handleRequest, {
signal: request.signal
})Programs can read the same linked signal through the standard yieldable:
const handleRequest = Effect.fn(async function* () {
const signal = yield* CurrentAbortSignal
return Result.ok(await fetchData({ signal }))
})The Runtime also exposes a graceful shutdown policy. It waits for active work for the grace period, then aborts their linked signals when requested:
await runtime.dispose({
gracePeriod: 5_000,
abortAfterGracePeriod: true
})Cancellation remains cooperative: an execution that ignores its signal can still keep disposal open until it settles.
Service resolution, Scope access and acquisition paths share one
RuntimeContext. The published Runtime entrypoint is officially supported on
Node.js and Bun, where AsyncLocalStorage is the default context storage. The
better-effect/runtime/explicit subpath provides
ExplicitRuntimeContextStorage as a manually managed, sequential strategy only
when the package entrypoint and host can load it. One instance supports one
non-overlapping async flow at a time and rejects concurrent overlap. Use the
default Node/Bun storage for concurrent executions:
import { ExplicitRuntimeContextStorage } from 'better-effect/runtime/explicit'
const runtime = await Runtime.make(AppLive, {
contextStorage: new ExplicitRuntimeContextStorage()
})Using the explicit strategy does not make the package generally usable in browsers, Deno, Cloudflare Workers, or other non-Node hosts; separate explicit instances are not a general concurrent-isolation strategy.
One-shot Runtime
For a command, migration or script, use Runtime.run:
Database integrations that forward cancellation through a native query terminal can use the Kysely integration.
const migrate = Effect.fn(async function* () {
const database = yield* Database
const migrated = yield* Result.await(database.migrate())
return Result.ok(migrated)
})
const result = await Runtime.run(AppLive, migrate)The one-shot form creates the Runtime, runs exactly one program, closes its execution and root Scopes with the final outcome, and always attempts backend cleanup. A successful program can therefore still expose a shutdown cleanup failure; a failed program preserves its exact primary failure.
Node.js and Bun CLI entrypoint
Use the Node/Bun-only better-effect/node subpath when a process owns the
application Runtime. NodeRuntime.runMain installs no listeners at import time,
links one execution signal, and removes every configured listener in finally:
import { Result } from 'better-result'
import { NodeRuntime } from 'better-effect/node'
const main = Effect.fn(async function* () {
const signal = yield* CurrentAbortSignal
const value = yield* Result.await(runMigration({ signal }))
return Result.ok(value)
})
await NodeRuntime.runMain(AppLive, main, {
signals: ['SIGINT', 'SIGTERM'],
onFailure: (error) => {
console.error(error)
return 1
},
onSuccess: () => 0,
onDefect: (cause) => {
console.error(cause)
return 1
}
})The first configured SIGINT or SIGTERM immediately aborts
CurrentAbortSignal and begins graceful Runtime disposal. Runtime disposal
waits cooperatively for the main execution; repeated signals are ignored. The
Node boundary has no second grace-period policy, so use Runtime.dispose
directly when a managed Runtime needs one. Result.err is passed to
onFailure (or exit code 1 by default), while thrown or rejected defects remain rejected and are passed to
onDefect; cleanup-only failures go to onCleanupFailure and a cleanup failure
after success is still non-zero. The helper sets process.exitCode and never
calls process.exit(). Its overloads retain the Layer completeness and Program
Service-requirement checks of Runtime.run.
Migrating manual signal handlers
Replace a hand-written process.once/runtime.dispose pair:
process.once('SIGTERM', () => void shutdown())
process.once('SIGINT', () => void shutdown())with a main Program that waits for CurrentAbortSignal and one boundary call:
const main = Effect.fn(async function* () {
const signal = yield* CurrentAbortSignal
const server = startServer({ signal })
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true })
})
await server.stop()
return Result.ok(undefined)
})
await NodeRuntime.runMain(AppLive, main)Do not install a second signal handler or call process.exit; pass the linked
signal to request I/O and let the Runtime wait for active executions.
Worker entrypoints
Workers do not need process signal listeners. Use signals: [] and bridge a
parent message to an AbortController instead:
import { parentPort } from 'node:worker_threads'
import { NodeRuntime } from 'better-effect/node'
const controller = new AbortController()
const onMessage = (message: unknown) => {
if (message === 'shutdown') controller.abort('parent shutdown')
}
parentPort?.on('message', onMessage)
try {
const result = await NodeRuntime.runMain(Layer.empty, workerMain, {
signals: [],
signal: controller.signal
})
parentPort?.postMessage(result)
} finally {
parentPort?.off('message', onMessage)
}The worker owns its own Runtime and cleanup; the parent remains responsible for sending the shutdown message and terminating the worker after its result is reported.
Layers per execution
Use runWith when a Layer belongs to one execution instead of to the shared
Runtime root. The request Layer can depend on root Services, and its providers
are resolved through an overlay that is discarded when the execution Scope
closes:
const RequestLive = Layer.succeed(RequestContext, RequestContext.of({ requestId, userId }))
const handleRequest = Effect.fn(async function* () {
const request = yield* RequestContext
return Result.ok(await application.handle(request))
})
const result = await runtime.runWith(RequestLive, handleRequest)runWith checks the request Layer's external requirements against the root
environment and checks the program against the union of root and request
Services. Layer.scoped and Layer.scopedGen providers registered there are
released at the end of that execution, while root Layer resources remain
shared. A request provider with a compatible existing tag also acts as a
request-local override without mutating the root backend.
Typed execution boundaries
Every typed Runtime boundary validates the program's final
Effect.Requirements against Layer.Provided<typeof AppLive>:
const AppLive = Layer.succeed(Database, database)
const needsCache = Effect.fn(async function* () {
const cache = yield* Cache
return Result.ok(await cache.get('key'))
})
const runtime = await Runtime.make(AppLive)
await runtime.run(needsCache)
// TypeScript error includes MissingDependencies<Cache>.The Runtime and its inferred Provided union retain this information. A
Layer has exactly two public channels: Layer<Provided, Required>. Required
contains only dependencies still external after composition. Prefer inferred
Layers or satisfies Layer<Provided, Required> when naming a composition root;
a normal annotation is safe but erases precise per-provider provenance and makes
later overrides conservative.
Concrete Layer<A> | Layer<B> values are rejected at Runtime boundaries because
a runtime branch cannot guarantee both environments. Narrow the branch first.
Layer.Any is the explicit unchecked boundary; bare Layers and partial-any
shapes are not treated as unchecked.
Prefer Runtime.For when naming a Runtime from a concrete Layer:
type AppRuntime = Runtime.For<typeof AppLive>
// Runtime<Database>
export function createServer(runtime: AppRuntime) {
return {
handle: () => runtime.run(() => needsDatabase)
}
}RuntimeFor<typeof AppLive> remains an equivalent compatibility spelling. An
unparameterized Runtime intentionally erases its environment precision and
is an unchecked escape hatch.
Execution Scopes
Every runtime.run creates a child Scope of the Runtime root. The child remains
open until the program settles, including during re-entrant disposal:
Runtime root
├── Database from Layer.scoped
└── run() child
├── request body from Effect.acquireRelease
└── temporary file from Effect.addThe execution outcome is classified only at the Runtime boundary and only
nominal better-result values are inspected:
- a plain value is success, including an ordinary
{ status: 'error' }domain value; - a nominal
Result.okis success; - a nominal
Result.erris failure with itserroras the cause; - a thrown or rejected value is failure with that exact cause.
Intermediate Results do not close a Scope or change its outcome.
Graceful disposal
runtime.dispose() is idempotent and shared by concurrent callers. Its order is
deliberate:
- Transition to disposing and reject new
runcalls. - Wait for the snapshot of active executions.
- Optionally abort their signals after the configured grace period.
- Close the Runtime root Scope.
- Dispose the backend last.
An execution failure never prevents root or backend cleanup. A long-lived Runtime closes its root with success regardless of earlier execution outcomes; the one-shot form closes with the complete final outcome.
Cleanup diagnostics
Pass onCleanupFailure to observe aggregated cleanup failures without changing
the primary program result:
const runtime = await Runtime.make(AppLive, {
onCleanupFailure: async (diagnostic) => {
console.error('cleanup failed', diagnostic.error)
}
})Provide a custom adapter through the same options object:
const runtime = await Runtime.make(AppLive, {
backend: new ItiLayerBackend(),
onCleanupFailure: reportCleanupFailure
})Observers are best effort. If an observer itself throws, that observer failure is ignored. The precedence is:
program failure > cleanup failure > program successRuntime errors
LayerRegistrationError: a backend could not register a provider; partial construction is cleaned up and the registration cause remains primary.LayerDisposeError: root and/or backend cleanup failed.ServiceNotFoundError: the backend has no provider for a requested tag.CircularDependencyError: Service acquisition re-entered a tag already in the current resolution path.ServiceAcquisitionError: a provider failed while being acquired; inspectservice,resolutionPathandcause.RuntimeHandleDisposedError(internal): a disposed handle rejects new work.