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
})
| Concurrency | Behavior |
|---|---|
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.forkDaemonforks 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.