36 - Fibers: Lightweight, Supervised Concurrency
A fiber is the running execution of an Effect. Effect.all and friends manage fibers implicitly; the primitives in this step expose them.
Use them when you need to start work now and collect the result later, race two strategies, interrupt a long-running task, or run a daemon that outlives its parent.
What you’ll work through
index.test.ts walks the fiber API in the order you’ll reach for it: Effect.forkChild + Fiber.join, then Fiber.await for failure-as-data, then Fiber.interrupt, then Effect.race, and finally Effect.forkDetach for work that should outlive its parent.
By the end you should be able to choose between fork, forkDaemon, and the high-level Effect.all for any “run this in the background” situation, and know why a forked fiber is supervised whereas a daemon is not.
Fiber Lifecycle
Effect immutable description of work (nothing is running)Fiber a live execution of an Effect
Effect.forkChild(effect) ──▶ Effect<Fiber<A, E>> starts a child fiber Effect.forkDetach(effect) ──▶ Effect<Fiber<A, E>> starts an untethered fiber Fiber.join(fiber) ──▶ Effect<A, E> wait, re-raise failure Fiber.await(fiber) ──▶ Effect<Exit<A, E>> wait, observe Exit Fiber.interrupt(fiber) ──▶ Effect<Exit<A, E>> cancel safely, finalizers run
Supervision fork ──▶ child is bound to its parent; parent completing interrupts child. forkDaemon ──▶ child is bound to the global scope; survives the parent.The supervision contract is what separates Effect.forkChild from setTimeout. A forked fiber cannot leak; its parent is responsible for it.
Effect.forkChild and Fiber.join
import { Effect, Fiber } from "effect"
// ┌─── Effect<string>// ▼const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.sleep("100 millis").pipe(Effect.as("result")), ) return yield* Fiber.join(fiber)})fork schedules the effect on a new fiber and returns immediately. join waits for the fiber’s result, re-raising any failure into the calling fiber.
Notice.
forkis non-blocking — the parent fiber continues on the next line. The fiber doesn’t run because you forked it; it runs because the runtime scheduled it. Joining is the only synchronisation point.
Fiber.await: Observe Without Throwing
// ┌─── Effect<Exit<number, string>>// ▼const exit = Fiber.await(fiber)Returns the Exit value directly so success and failure can be inspected as data. Use this when failure is expected and you want to branch on it.
Fiber.interrupt
Interrupts a fiber cooperatively: the runtime cancels the fiber at the next yield point, and all attached finalizers run before the interruption propagates.
// ┌─── Effect<void>// ▼const stop = Effect.gen(function* () { const fiber = yield* Effect.forkChild(Effect.sleep("10 seconds")) yield* Fiber.interrupt(fiber)})Interrupted fibers produce an Exit whose cause is Interrupt. Inspect it with Cause (step 42) when you need to distinguish interruption from failure.
Effect.race
Runs two effects in parallel, returns the first success, interrupts the loser.
// ┌─── Effect<string>// ▼const result = Effect.race( Effect.succeed("fast"), Effect.sleep("1 second").pipe(Effect.as("slow")),)Details
The loser is interrupted, not abandoned. Its finalizers run.
This is the difference from Promise.race, which leaves the loser running invisibly.
Effect.forkDetach: Untethered Background Work
// ┌─── Effect<Fiber<void, never>>// ▼const heartbeat = Effect.forkDetach( Effect.repeat( Effect.log("alive"), Schedule.fixed("1 second"), ),)A daemon fiber is bound to the global scope, not its parent. It survives the parent fiber and is shut down only when the runtime terminates.
When to Use
For housekeeping work whose lifetime should match the application, not whichever request forked it.
> interrupt it, it runs until the runtime exits. Reach for it only when the > work genuinely outlives its parent — and pair it with explicit shutdown.The forget-to-join mistake
Effect.forkChild starts a child fiber and returns immediately with a handle. The child runs inside its parent scope — if the parent finishes before the child does, the runtime interrupts the child to clean up. Without Fiber.join, the parent never observes the child’s result, and a slow child may be killed before it produces one.
import { Effect } from "effect"
// ✗ Parent finishes immediately; child gets interrupted before it can run.const lossy = Effect.gen(function* () { const fiber = yield* Effect.forkChild(slowWork) // returning here without yield* Fiber.join(fiber)})
// ✓ Join the child — parent waits for the result and re-raises failure.const correct = Effect.gen(function* () { const fiber = yield* Effect.forkChild(slowWork) return yield* Fiber.join(fiber)})If you genuinely want fire-and-forget background work that outlives the parent, use Effect.forkDetach (covered in the daemon beat). Forking without joining inside an ordinary scope is almost always a bug.
Your turn read the lossy test and confirm the child's effect never lands. Then complete the partner test by inserting `yield* Fiber.join(...)` so the result is observed.
Composite Example: Race With Cleanup
// ┌─── Effect<string>// ▼const program = Effect.race( fetchFromCache(key), fetchFromDb(key),)If the cache wins, the in-flight DB query is interrupted and its connection released, for free, because the loser’s scope closes.
Rules of thumb
- Fork only what should outlive the caller. For sequential work, stay in
gen— forking adds bookkeeping you don’t need. - Every fork needs a join or interrupt. A forked fiber that no-one waits
on or cancels is a leak. Prefer
Effect.forkScopedto tie a fiber’s lifetime to aScope. - Fibers are cheap. Spawn thousands. The cost model is closer to async tasks than to OS threads.
Common Pitfalls
1, Using Fiber.join when failure is expected. join re-raises; the calling fiber fails. For observation, use Fiber.await.
2, Confusing fork and forkDaemon. A forked fiber is interrupted when its parent completes. If you want background work that survives the request scope, use forkDaemon, but then you are responsible for shutdown.
3, Reaching for Effect.forkChild when Effect.all would do. For N independent tasks where you want all results, Effect.all with { concurrency: "unbounded" } is the higher-level API. Use explicit fibers when you need fine-grained control.
4, Forgetting that interruption is cooperative. A fiber stuck in a tight synchronous loop with no yield points cannot be interrupted. Insert Effect.yieldNow or split the work.
continues immediately after `fork` and only blocks at `join`. - [ ] Switch to `Fiber.await` and inspect the `Exit` — failure should be data, not a thrown error. Step 37 builds on this distinction. - [ ] Use `Fiber.interrupt` on a long-running fiber and observe the `Exit` carries an `Interrupt` cause, not a `Fail`. - [ ] Race two effects with `Effect.race` and confirm the loser is *interrupted*, not abandoned. - [ ] Finish with `Effect.forkDetach` — the daemon must be interrupted explicitly because it is not bound to the parent scope.
See Also
- Previous,
35-concurrency: the high-levelEffect.all/forEachAPI. - Next,
37-interruption: cancellation, finalizers, andExitcauses. - Related,
38-semaphore: cap the number of fibers running at once. - Related,
48-cause-and-sandbox: inspecting interruption inExitvalues.
Run the tests: pnpm test 36-fibers
The full test suite
The index.test.ts for this lesson, verbatim from steps/36-fibers/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Fiber, Ref } from "effect"
// =============================================================================// 40 - Fibers: Lightweight, Supervised Concurrency// =============================================================================//// Effect.forkChild Effect<A, E, R> => Effect<Fiber<A, E>, never, R>// Effect.forkDetach fiber bound to the global scope, survives the parent// Fiber.join wait for result, re-raise failure// Fiber.await wait for Exit, observe success/failure as a value// Fiber.interrupt cancel safely, finalizers run// Effect.race first success wins, loser is interrupted
describe("40 - Fibers", () => { // @beat // id: fork-and-join // shape: one-todo // title: Fork a child fiber and join its result // teaches: Effect.forkChild starts a child fiber; Fiber.join waits for the result and re-raises failure. // hint: yield* Effect.forkChild(effect) returns a Fiber; yield* Fiber.join(fiber) collects the result describe("Effect.forkChild and Fiber.join", () => { it("forks a child fiber and joins its result", async () => { const program = /* @stub */ Effect.gen(function* () { const fiber = yield* Effect.forkChild(Effect.succeed(42)) return yield* Fiber.join(fiber) }) /* @end */
expect(await Effect.runPromise(program)).toBe(42) })
it("runs the child concurrently with the parent", async () => { const log: Array<string> = []
await Effect.runPromise( Effect.gen(function* () { const fiber = yield* Effect.forkChild(Effect.sync(() => log.push("background"))) log.push("main") yield* Fiber.join(fiber) }), )
expect(log).toContain("main") expect(log).toContain("background") }) })
// @end-beat
// @beat // id: await-and-interrupt // shape: multi-todo // title: Observe Exit and cancel safely // teaches: Fiber.await returns an Exit (no throw on failure); Fiber.interrupt cancels cooperatively and runs finalizers. // hint: Fiber.await for branching on _tag; Fiber.interrupt to cancel a sleeping fiber describe("Fiber.await", () => { it("returns an Exit without throwing on failure", async () => { const program = /* @stub */ Effect.gen(function* () { const ok = yield* Effect.forkChild(Effect.succeed(1)) const bad = yield* Effect.forkChild(Effect.fail("oops")) return { ok: (yield* Fiber.await(ok))._tag, bad: (yield* Fiber.await(bad))._tag, } }) /* @end */
const result = await Effect.runPromise(program) expect(result.ok).toBe("Success") expect(result.bad).toBe("Failure") }) })
describe("Fiber.interrupt", () => { it("cancels a running fiber and produces a Failure Exit", async () => { const program = /* @stub */ Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.sleep("10 seconds").pipe(Effect.as("done")), ) yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber) return exit._tag }) /* @end */
expect(await Effect.runPromise(program)).toBe("Failure") }) }) // @end-beat
// @beat // id: race // shape: one-todo // title: Race two effects, interrupt the loser // teaches: Effect.race returns the first success; the losing fiber is interrupted and its scope closes — not abandoned like Promise.race. // hint: Effect.race(slow, fast) returns "fast" and interrupts the slow branch describe("Effect.race", () => { it("returns the first success and interrupts the loser", async () => { const program = /* @stub */ Effect.race( Effect.sleep("100 millis").pipe(Effect.as("slow")), Effect.succeed("fast"), ) /* @end */
expect(await Effect.runPromise(program)).toBe("fast") }) }) // @end-beat
// @beat // id: daemon // shape: read-run // title: forkDaemon outlives the parent scope // teaches: Effect.forkDetach binds the child to the global scope — a regular fork inside Effect.scoped is interrupted when the scope closes; a daemon fiber survives and can be joined afterwards. // hint: read both tests — scoped closes and interrupts the regular fork; the daemon's Fiber.join still resolves describe("Effect.forkChild vs Effect.all", () => { it("Effect.all is the high-level API for parallel results", async () => { const result = await Effect.runPromise( Effect.all([Effect.succeed(1), Effect.succeed(2), Effect.succeed(3)], { concurrency: "unbounded", }), )
expect(result).toEqual([1, 2, 3]) })
it("fork/join gives fine-grained control — start now, join later", async () => { const program = Effect.gen(function* () { const a = yield* Effect.forkChild(Effect.succeed(10)) const b = yield* Effect.forkChild(Effect.succeed(20)) return (yield* Fiber.join(a)) + (yield* Fiber.join(b)) })
expect(await Effect.runPromise(program)).toBe(30) }) })
describe("Effect.forkDetach", () => { it("daemon fiber survives the scope that spawned it and can be joined later", async () => { // A regular fork inside Effect.scoped would be interrupted when the scope closes. // A forkDaemon is attached to the global scope instead, so it outlives its parent. const result = await Effect.runPromise( Effect.gen(function* () { const completed = yield* Ref.make(false)
const daemonFiber = yield* Effect.scoped( Effect.gen(function* () { return yield* Effect.forkDetach( Effect.gen(function* () { yield* Effect.sleep("5 millis") yield* Ref.set(completed, true) }), ) }), )
// The scope has closed — the daemon is still running. Join to wait for it. yield* Fiber.join(daemonFiber) return yield* Ref.get(completed) }), )
expect(result).toBe(true) })
it("forkDaemon is the choice when a background task must outlive the current scope", async () => { // Contrast: with a regular fork you'd join *inside* the scope; with forkDaemon // the fiber is bound to the global scope and can be observed from anywhere. const log: Array<string> = []
const program = Effect.gen(function* () { const fiber = yield* Effect.forkDetach( Effect.gen(function* () { log.push("daemon start") yield* Effect.sleep("5 millis") log.push("daemon end") }), ) log.push("parent end") return fiber })
const daemonFiber = await Effect.runPromise(program) await Effect.runPromise(Fiber.join(daemonFiber))
expect(log).toContain("parent end") expect(log).toContain("daemon end") }) }) // @end-beat
// @beat // id: forget-to-join // shape: multi-todo // title: Fork starts the fiber — join collects its result // teaches: Effect.forkChild starts a child fiber that is interrupted when the parent scope ends. Without Fiber.join, the parent cannot receive the child's result and the child may be interrupted before it finishes. // hint: yield* Effect.forkChild(effect) returns a Fiber — yield* Fiber.join(fiber) waits for its result and returns the value
describe("fork without join", () => { it("a forked fiber is interrupted when the parent exits without joining (read this)", async () => { const log: Array<string> = []
await Effect.runPromise( Effect.gen(function* () { yield* Effect.forkChild( Effect.gen(function* () { yield* Effect.sleep("10 seconds") // long — would take forever log.push("background") // never reached: parent exits first }), ) // Parent returns here — structured concurrency interrupts the child }), )
// Child was interrupted before appending to log expect(log).toEqual([]) })
it("Fiber.join keeps the parent alive until the child completes", async () => { const log: Array<string> = []
await Effect.runPromise( /* @stub */ Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.sync(() => { log.push("background") }), ) yield* Fiber.join(fiber) }) /* @end */, )
expect(log).toEqual(["background"]) }) }) // @end-beat})