07·01Concurrency

Effect models concurrency with **fibers**, lightweight virtual threads, and ties their lifetime to the surrounding scope. `Effect.all` runs a fixed collection, `Effect.forEach` maps over a dynamic one, `Effect.forkChild`/`Fiber.join` give explicit control, and

35 - Concurrency

Previous · Next

Effect models concurrency with fibers, lightweight virtual threads, and ties their lifetime to the surrounding scope. Effect.all runs a fixed collection, Effect.forEach maps over a dynamic one, Effect.forkChild/Fiber.join give explicit control, and Effect.race picks the first success.

The runtime cleans up automatically when a scope ends, so unfinished fibers never leak.

What you’ll work through

index.test.ts covers the four high-level operators in order: Effect.all, Effect.forEach, Effect.forkChild/Fiber.join, and Effect.race. The interesting test is the concurrency option — a few beats let you flip it between "unbounded" and a fixed number and watch the wall-clock time change.

By the end you should be able to choose the right operator for “I have N things to do at once” without reaching for the docs.


Fibers and Structured Concurrency

A fiber is the unit of concurrent work. Forking is non-blocking; the parent continues immediately and the fiber runs in the background.

Joining is the synchronisation point. When the parent scope ends, by returning, by failing, by being interrupted, every child fiber is interrupted in turn.

parent scope
│
├─ Effect.forkChild(taskA) ── fiber A ── running ─── join → result A
│
├─ Effect.forkChild(taskB) ── fiber B ── running ─── join → result B
│
│ (if the parent ends before joining)
│ ↓
│ fiber A and fiber B are interrupted automatically
└─────────────────────────────────────────────────────

This is structured concurrency: fiber lifetimes follow lexical scope, the way local variables do. No leaks, no orphaned work.


Effect.all

Runs a tuple or record of effects and returns the corresponding tuple or record of results. Sequential by default, pass a concurrency option for parallelism.

import { Effect } from "effect"
const fetchUser = Effect.succeed({ id: 1 })
const fetchPosts = Effect.succeed([{ title: "Hello" }])
// ┌─── Effect<[{ id: number }, { title: string }[]], never, never>
// ▼
const sequential = Effect.all([fetchUser, fetchPosts])
const parallel = Effect.all([fetchUser, fetchPosts], {
concurrency: "unbounded",
})

Details

The concurrency option takes a number (the bound), "unbounded" (no limit), or "inherit" (use the surrounding scope’s limit). Results stay in the original input order regardless of which fiber finished first.

When to Use

When the set of effects is fixed and known statically. For dynamic collections see Effect.forEach.

Your turn add `{ concurrency: "unbounded" }` so the three sleeping tasks run in parallel.


Effect.forEach

Maps a function returning an effect over an iterable, collecting results. Same concurrency options as Effect.all.

import { Effect } from "effect"
declare const fetchItem: (id: number) => Effect.Effect<string>
// ┌─── Effect<string[], never, never>
// ▼
const program = Effect.forEach([1, 2, 3, 4, 5], fetchItem, {
concurrency: 2,
})

Details

A bounded concurrency limit is the production-safe default for hitting external services. The limit caps in-flight work; everything else queues until a slot opens up.

When to Use

When the collection size is dynamic, when a concurrency cap should protect a downstream service, or when ordering of results by input is required.

Your turn call `Effect.forEach` with `{ concurrency: 3 }` so at most three tasks run at once.


Effect.forkChild and Fiber.join

Effect.forkChild returns a RuntimeFiber<A, E> without blocking. Fiber.join waits for the fiber and produces its result.

import { Effect, Fiber } from "effect"
const program = Effect.gen(function* () {
const fiber = yield* Effect.forkChild(
Effect.sleep("20 millis").pipe(Effect.as(42))
)
// Other work runs here in parallel with the fiber...
const local = "done locally"
const value = yield* Fiber.join(fiber)
return { local, value }
})

Details

A forked fiber lives until joined, interrupted, or the surrounding scope ends. Fiber.interrupt cancels it explicitly.

For a fiber that must outlive its parent scope, use Effect.forkDetach, but prefer structured concurrency wherever possible.

When to Use

When fine-grained control is needed: starting work before it is consumed, doing other work between fork and join, or holding the fiber to interrupt it conditionally.

Your turn join the fiber with `Fiber.join(a)` so the parent waits for its result.


Effect.race

Runs two effects concurrently and returns the result of the first to succeed. The loser is interrupted.

If both fail, the race fails with both errors combined.

import { Effect } from "effect"
const slow = Effect.sleep("200 millis").pipe(Effect.as("slow"))
const fast = Effect.sleep("20 millis").pipe(Effect.as("fast"))
// ┌─── Effect<"slow" | "fast", never, never>
// ▼
const winner = slow.pipe(Effect.race(fast))
// "fast"

Details

A single failure does not end the race, only a success does. With one fast failure and one slow success, race returns the success.

If both effects fail, the resulting Cause is a parallel combination of both errors.

When to Use

Primary/fallback with the same return type, hedged requests across regions, or a deadline implemented as Effect.race(operation, Effect.sleep(deadline).pipe(Effect.as(fallback))).

@see Effect.raceAll for racing more than two effects.

Your turn race `slow` against `fast` so the faster one wins.


A Full Example

A bounded forEach that captures the maximum number of in-flight fibers. The cap protects the downstream service; results stay in input order.

import { Effect } from "effect"
let inFlight = 0
let maxInFlight = 0
const work = (n: number) =>
Effect.gen(function* () {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
yield* Effect.sleep("10 millis")
inFlight -= 1
return n * 2
})
// ┌─── Effect<number[], never, never>
// ▼
const program = Effect.forEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], work, {
concurrency: 3,
})
const results = await Effect.runPromise(program)
// results === [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// maxInFlight <= 3

Common Pitfalls

1, Effect.all is sequential by default. Omitting the concurrency option produces a sequential effect that looks parallel but is not. Add { concurrency: "unbounded" } for full parallelism or a number for a bounded cap.

2, Fiber.join without Effect.forkChild. Forking is what starts the work concurrently. Without it, calling Fiber.join on a non-existent fiber makes no sense, the program is sequential.

3, Assuming forked fibers outlive their parent. Fibers from Effect.forkChild are interrupted when the parent scope ends. Use Effect.forkDetach only when an outliving fiber is genuinely needed, and manage its lifetime explicitly.

4, Unbounded parallelism on external services. concurrency: "unbounded" over a thousand items fires a thousand simultaneous requests. Pick a number that matches the service’s capacity.


`"unbounded"`, a number, and the default. The wall-clock assertion is the point: each setting changes the runtime, not the result. - [ ] Then `Effect.forEach` with a dynamic collection. Confirm that `concurrency: 2` lets only two items run at once. - [ ] `Effect.forkChild` + `Fiber.join` for explicit control. Notice the parent fiber continues immediately after `fork`. - [ ] `Effect.race` — the winner returns; the loser is *interrupted*, not just abandoned. Step 37 picks up interruption directly.

See Also

  • Previous, 34-streams: pipelines that internally use concurrency for parallel processing.
  • Next, 36-fibers: low-level fiber operations underneath these high-level operators.
  • Later, 36-fibers: deep dive into fiber primitives and interruption.
  • Later, 38-semaphore: finer-grained concurrency control.

Run the tests: pnpm test 35-concurrency


The full test suite

The index.test.ts for this lesson, verbatim from steps/35-concurrency/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Effect, Fiber } from "effect"
// =============================================================================
// 20 - Concurrency
// =============================================================================
//
// Effect.all — runs a fixed collection; sequential by default.
// Effect.forEach — maps over a dynamic collection with concurrency control.
// Effect.forkChild — starts a fiber without blocking; returns RuntimeFiber.
// Fiber.join — waits for a fiber and returns its result.
// Effect.race — first success wins; loser is interrupted.
describe("20 - Concurrency", () => {
// @beat
// id: all
// shape: one-todo
// title: Run a fixed set of effects with Effect.all
// teaches: Effect.all runs a tuple sequentially by default; pass concurrency: "unbounded" or a number for parallelism.
// hint: Effect.all([task(1), task(2), task(3)], { concurrency: "unbounded" })
describe("Effect.all", () => {
it("runs effects sequentially by default", async () => {
const order: number[] = []
const task = (id: number) =>
Effect.sync(() => {
order.push(id)
return id
})
// ┌─── Effect<[number, number, number], never, never>
// ▼
const program = Effect.all([task(1), task(2), task(3)])
const results = await Effect.runPromise(program)
expect(results).toEqual([1, 2, 3])
expect(order).toEqual([1, 2, 3])
})
it("runs effects in parallel with concurrency: unbounded", async () => {
const task = (id: number) =>
Effect.gen(function* () {
yield* Effect.sleep("10 millis")
return id
})
const results = await Effect.runPromise(
/* @stub */ Effect.all([task(1), task(2), task(3)], { concurrency: "unbounded" }) /* @end */
)
// Results preserve input order regardless of finish order.
expect(results).toEqual([1, 2, 3])
})
it("respects a numeric concurrency limit", async () => {
let inFlight = 0
let maxInFlight = 0
const task = (id: number) =>
Effect.gen(function* () {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
yield* Effect.sleep("20 millis")
inFlight -= 1
return id
})
const items = Array.from({ length: 6 }, (_, i) => i + 1)
await Effect.runPromise(
Effect.all(
items.map((i) => task(i)),
{ concurrency: 2 }
)
)
expect(maxInFlight).toBeLessThanOrEqual(2)
})
})
// @end-beat
// @beat
// id: for-each
// shape: one-todo
// title: Map a dynamic collection with Effect.forEach
// teaches: Effect.forEach applies an effectful function to every element; a numeric concurrency cap protects downstream services and results stay in input order.
// hint: Effect.forEach(items, work, { concurrency: 3 })
describe("Effect.forEach", () => {
it("maps a function over an iterable", async () => {
const results = await Effect.runPromise(
Effect.forEach([1, 2, 3, 4, 5], (n) => Effect.succeed(n * 2), {
concurrency: "unbounded",
})
)
expect(results).toEqual([2, 4, 6, 8, 10])
})
it("preserves input order even with variable durations", async () => {
const results = await Effect.runPromise(
Effect.forEach(
[5, 4, 3, 2, 1],
(n) =>
Effect.gen(function* () {
yield* Effect.sleep(`${n * 10} millis`)
return n
}),
{ concurrency: "unbounded" }
)
)
expect(results).toEqual([5, 4, 3, 2, 1])
})
it("caps in-flight work with a bounded concurrency limit", async () => {
let inFlight = 0
let maxInFlight = 0
const results = await Effect.runPromise(
/* @stub */ Effect.forEach(
Array.from({ length: 10 }, (_, i) => i + 1),
(n) =>
Effect.gen(function* () {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
yield* Effect.sleep("10 millis")
inFlight -= 1
return n * 2
}),
{ concurrency: 3 }
) /* @end */
)
expect(results).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20])
expect(maxInFlight).toBeLessThanOrEqual(3)
})
})
// @end-beat
// @beat
// id: fork-join
// shape: one-todo
// title: Fork a fiber and join it
// teaches: Effect.forkChild starts a fiber without blocking; Fiber.join waits for its result. Forked fibers are interrupted when the parent scope ends.
// hint: yield* Effect.forkChild(...); yield* Fiber.join(fiber)
describe("Effect.forkChild and Fiber.join", () => {
it("fork starts a fiber; join waits for its result", async () => {
const result = await Effect.runPromise(
Effect.gen(function* () {
const a = yield* Effect.forkChild(Effect.succeed("A"))
const b = yield* Effect.forkChild(Effect.succeed("B"))
const aValue = yield* /* @stub */ Fiber.join(a) /* @end */
const bValue = yield* Fiber.join(b)
return `${aValue}-${bValue}`
})
)
expect(result).toBe("A-B")
})
it("lets the parent do other work while the fiber runs", async () => {
const result = await Effect.runPromise(
Effect.gen(function* () {
const fiber = yield* Effect.forkChild(
Effect.gen(function* () {
yield* Effect.sleep("20 millis")
return 42
})
)
// Other work happens here in parallel with the fiber.
const local = "done locally"
const value = yield* Fiber.join(fiber)
return { local, value }
})
)
expect(result).toEqual({ local: "done locally", value: 42 })
})
})
// @end-beat
// @beat
// id: race
// shape: one-todo
// title: First success wins with Effect.race
// teaches: Effect.race runs two effects concurrently; the first success returns and the loser is interrupted. A single failure does not end the race — only a success does.
// hint: slow.pipe(Effect.race(fast))
describe("Effect.race", () => {
it("returns the result of the first effect to succeed", async () => {
const slow = Effect.sleep("200 millis").pipe(Effect.as("slow"))
const fast = Effect.sleep("20 millis").pipe(Effect.as("fast"))
const result = await Effect.runPromise(/* @stub */ slow.pipe(Effect.race(fast)) /* @end */)
expect(result).toBe("fast")
})
it("a single failure does not end the race — only a success does", async () => {
const failing = Effect.fail("primary error")
const succeeding = Effect.succeed("fallback result")
const result = await Effect.runPromise(
failing.pipe(Effect.race(succeeding))
)
expect(result).toBe("fallback result")
})
})
// @end-beat
describe("structured concurrency", () => {
it("interrupts child fibers when the parent scope ends", async () => {
let childCompleted = false
const parent = Effect.gen(function* () {
yield* Effect.forkChild(
Effect.gen(function* () {
yield* Effect.sleep("10 seconds")
childCompleted = true
})
)
return "parent done"
})
const result = await Effect.runPromise(parent)
expect(result).toBe("parent done")
expect(childCompleted).toBe(false)
})
})
})
← PreviousStreamsNext →Fibers