Structured Concurrency

Effect’s concurrency model is built on fibers - lightweight virtual threads that the runtime schedules cooperatively. Fibers organize hierarchically: when a parent fiber completes or is interrupted, all children are interrupted too.

Fiber Tree

  • Child fibers live within the parent’s scope
  • When the scope closes, all children receive an interrupt signal
  • Finalizers run before fibers terminate, ensuring resource cleanup

Effect.all

Effect.all combines multiple effects. The concurrency option controls how they run:

import { Effect } from "effect"

const fetchUser = (id: number) => Effect.succeed({ id, name: `user-${id}` })

// Sequential (default): concurrency 1
const sequential = Effect.all([fetchUser(1), fetchUser(2), fetchUser(3)], {
  concurrency: 1
})

// Parallel: unbounded
const parallel = Effect.all([fetchUser(1), fetchUser(2), fetchUser(3)], {
  concurrency: "unbounded"
})

// Bounded: at most N concurrent
const bounded = Effect.all([fetchUser(1), fetchUser(2), fetchUser(3)], {
  concurrency: 2
})
ConcurrencyBehavior
1 (default)Sequential, one at a time
"unbounded"All run in parallel
N (number)At most N concurrent

mode: “result”

By default, Effect.all fails fast - if any effect fails, the whole computation fails. Use mode: "result" to collect all outcomes, both success and failure:

import { Effect, Result } from "effect"

const effects = [
  Effect.succeed(1),
  Effect.fail("error-1"),
  Effect.succeed(3),
  Effect.fail("error-2")
]

const program = Effect.all(effects, {
  concurrency: "unbounded",
  mode: "result"
})

// Result: [Success(1), Failure("error-1"), Success(3), Failure("error-2")]

Effect.forEach

Effect.forEach applies an effectful function to each element of a collection:

const urls = ["https://api1.com", "https://api2.com", "https://api3.com"]

const fetchAll = Effect.forEach(urls, (url) =>
  Effect.tryPromise(() => fetch(url).then(r => r.json())),
  { concurrency: 3 }
)

Forking Fibers

For explicit control, fork fibers and join them later:

import { Effect } from "effect"

const program = Effect.gen(function*() {
  // Fork: starts running concurrently, returns a Fiber handle
  const fiber1 = yield* Effect.fork(Effect.succeed(1).pipe(Effect.delay("1 second")))
  const fiber2 = yield* Effect.fork(Effect.succeed(2).pipe(Effect.delay("500 millis")))

  // Join: wait for a fiber to complete and get its result
  const result1 = yield* fiber1.join
  const result2 = yield* fiber2.join

  return [result1, result2] // [1, 2]
})

Racing

Effect.race returns the first successful result and interrupts the loser:

import { Effect } from "effect"

const fast = Effect.succeed("fast").pipe(Effect.delay("100 millis"))
const slow = Effect.succeed("slow").pipe(Effect.delay("1 second"))

const winner = Effect.race(fast, slow)
// Result: "fast" - slow fiber is interrupted

Effect.raceAll races multiple effects:

const winner = Effect.raceAll([
  fetchFromPrimary,
  fetchFromSecondary,
  fetchFromCache
])

Tip: Use racing for timeout patterns - race your effect against Effect.sleep(duration) to enforce a time limit.

Scope-Bounded Lifetime

Fibers forked with Effect.fork are tied to the current scope. When the scope closes (through Effect.scoped, normal completion, or interruption), all forked fibers are interrupted:

import { Effect } from "effect"

const program = Effect.scoped(
  Effect.gen(function*() {
    // This fiber runs forever, but is bounded by the scope
    const fiber = yield* Effect.fork(
      Effect.log("heartbeat").pipe(Effect.forever)
    )

    yield* Effect.sleep("2 seconds")
    yield* Effect.log("main work done")
    // Scope closes here - heartbeat fiber is interrupted
  })
)

Gotcha: Effect.forkDaemon forks a fiber that is NOT tied to the parent scope. It survives parent interruption. Use this only for deliberate background tasks that should outlive their parent.

See Runtime and Fibers for the scheduler internals, and Scope and Resources for the resource lifecycle model.