Runtime and Fibers

The Effect runtime is a fiber-based concurrency system. Fibers are lightweight virtual threads - not OS threads - that the runtime schedules cooperatively. Every effect you run executes inside a fiber, and fibers organize hierarchically through scopes.

Architecture Overview

  • The runtime owns the scheduler and the root scope
  • Each fiber belongs to a scope; when the scope closes, all fibers in it are interrupted
  • Finalizers run in reverse order of registration during scope cleanup

Fiber Lifecycle

Creating a Runtime

You rarely create a bare runtime. The common pattern is ManagedRuntime.make from a Layer that wires all your services:

import { Effect, Layer, ManagedRuntime } from "effect"

const MainLive = Layer.empty // your composed layers here

const runtime = ManagedRuntime.make(MainLive)

// Run effects using the runtime
await runtime.runPromise(
  Effect.log("Hello from runtime")
)

For simpler cases, you can use the built-in executors directly:

import { Effect } from "effect"

// Sync execution (effect must not be async)
const result = Effect.runSync(Effect.succeed(42))

// Async execution returning a Promise
await Effect.runPromise(Effect.succeed(42))

// Fork execution, get a Fiber handle
const fiber = Effect.runFork(Effect.succeed(42))

Tip: Use Effect.runFork when you need to control a fiber’s lifecycle explicitly - interrupt it, join it, or inspect its status.

Structured Concurrency Model

Fibers fork within a scope. The parent fiber owns the scope, and child fiber lifetimes are bounded by it. When the parent completes or is interrupted, all children are interrupted too.

import { Effect } from "effect"

const program = Effect.gen(function*() {
  // Fork a child fiber in the current scope
  const fiber = yield* Effect.fork(
    Effect.log("Child running...").pipe(Effect.forever)
  )

  yield* Effect.sleep("2 seconds")

  // Interrupt the child
  yield* fiber.interrupt
  yield* Effect.log("Parent done, child interrupted")
})

You don’t need to manually interrupt children in most cases. Effect.scoped creates a scope, runs your effect, and cleans up all fibers and resources when it finishes:

const program = Effect.scoped(
  Effect.gen(function*() {
    const fiber = yield* Effect.fork(Effect.never)
    yield* Effect.sleep("1 second")
    // scope closes here - fiber is automatically interrupted
  })
)

Scope-Based Resource Management

Scopes track finalizers. When a scope closes - whether through normal completion, failure, or interruption - finalizers run in reverse registration order. This guarantees cleanup:

import { Effect } from "effect"

const program = Effect.scoped(
  Effect.gen(function*() {
    yield* Effect.acquireRelease(
      Effect.log("Acquiring resource"),
      () => Effect.log("Releasing resource")
    )

    yield* Effect.acquireRelease(
      Effect.log("Acquiring second resource"),
      () => Effect.log("Releasing second resource")
    )

    yield* Effect.log("Working with resources...")
    // On scope close: releases second, then first
  })
)

Gotcha: If you fork a fiber outside a scope (using Effect.forkDaemon), it won’t be interrupted when the parent scope closes. Use this deliberately for background tasks that should outlive their parent.

Automatic Keep-Alive (v4)

In v4, the runtime automatically keeps the Node.js process alive while fibers are suspended (e.g., waiting on a timer or I/O). You no longer need NodeRuntime.runMain or manual keep-alive management for suspended fibers.

import { Effect, Layer } from "effect"

// v4: no runMain needed, runtime keeps process alive
const program = Effect.gen(function*() {
  yield* Effect.sleep("5 seconds")
  yield* Effect.log("Done waiting")
})

Effect.runFork(program)

Runtime Configuration

You can configure the runtime with custom services like clocks, random generators, and schedulers. This makes time-based and random effects fully testable:

import { Effect, TestClock, ManagedRuntime, Layer } from "effect"

const TestLive = Layer.mergeAll(
  Layer.setClock(TestClock.TestClock),
  // other test services...
)

const runtime = ManagedRuntime.make(TestLive)

// Time is controlled, not real
await runtime.runPromise(
  Effect.gen(function*() {
    yield* Effect.sleep("1 hour") // instant in test
    yield* Effect.log("1 hour passed instantly")
  }).pipe(Effect.withTestClock)
)

See Scope and Resources for the full resource lifecycle model, and Layer Composition for how services wire together.