Skip to content

Racing and Timeouts

Racing runs several effects concurrently and keeps the first result, interrupting the rest. It’s the building block behind timeouts, hedged requests, and “whichever replica answers first” patterns. Because Effect’s interruption is safe, the losers are cancelled cleanly — their finalizers run, their resources are released.

import { Effect } from "effect"
// Two replicas of the same query; we only want the faster answer.
const replicaA = Effect.succeed("from A").pipe(Effect.delay("200 millis"))
const replicaB = Effect.succeed("from B").pipe(Effect.delay("100 millis"))
const program = Effect.gen(function*() {
// Run both concurrently; the first to *succeed* wins and the loser is
// interrupted. Here B is faster, so the result is "from B".
const result = yield* Effect.race(replicaA, replicaB)
yield* Effect.log(result)
})
Effect.runFork(program)

Effect.race(a, b) returns the result of whichever effect succeeds first and interrupts the other. A failure does not win the race: if one effect fails, race waits for the other. Only if both fail does the race fail, with a combined cause holding both errors.

import { Effect } from "effect"
const fails = Effect.fail("a failed").pipe(Effect.delay("100 millis"))
const wins = Effect.succeed("b").pipe(Effect.delay("200 millis"))
// `a` fails first, but failure doesn't win — so the result is "b".
const program = Effect.race(fails, wins)

If you want the first to settle regardless of success or failure, see raceFirst below, or wrap each side in Effect.result to turn its failure into a value that can win the race.

Effect.raceAll is the n-ary form: it takes an iterable of effects, returns the first success, and interrupts the rest. If every effect fails, it fails with a combined cause holding all of their errors.

import { Effect } from "effect"
const mirror = (name: string, ms: number) =>
Effect.succeed(name).pipe(Effect.delay(`${ms} millis`))
const program = Effect.gen(function*() {
// Hit three mirrors at once; take whichever responds first.
const fastest = yield* Effect.raceAll([
mirror("us-east", 150),
mirror("eu-west", 90),
mirror("ap-south", 220)
])
yield* Effect.log(fastest) // "eu-west"
})
Effect.runFork(program)

Unlike race, Effect.raceFirst(a, b) returns the result of whichever effect completes first, whether it succeeds or fails. Use it when the first outcome is what you care about, not the first success.

import { Effect } from "effect"
const failFast = Effect.fail("nope").pipe(Effect.delay("100 millis"))
const succeedSlow = Effect.succeed("ok").pipe(Effect.delay("200 millis"))
// `failFast` settles first, so the whole race fails with "nope".
const program = Effect.raceFirst(failFast, succeedSlow)

A timeout is just a race against a timer, and Effect.timeout packages it up. After the duration elapses, the underlying effect is interrupted (running its finalizers) and the timeout case is surfaced.

import { Effect } from "effect"
const slowCall = Effect.succeed("response").pipe(Effect.delay("5 seconds"))
const program = Effect.gen(function*() {
// Fail with a TimeoutError if `slowCall` doesn't finish within 2 seconds.
// The call is interrupted when the timeout fires.
const response = yield* slowCall.pipe(Effect.timeout("2 seconds"))
yield* Effect.log(response)
})
Effect.runFork(program)

Effect.timeout adds a Cause.TimeoutError to the effect’s error channel. You then handle it like any other typed error (see Error Management):

import { Cause, Effect } from "effect"
const program = Effect.succeed("response").pipe(
Effect.delay("5 seconds"),
Effect.timeout("2 seconds"),
// Recover from the timeout specifically.
Effect.catchTag("TimeoutError", () => Effect.succeed("fallback response"))
)

Choose the variant that matches how you want the timeout case represented:

| API | Timeout becomes | Use when | | --- | --- | --- | | Effect.timeout(d) | a typed TimeoutError failure | the timeout is an error you’ll handle or report | | Effect.timeoutOption(d) | success with Option.none() | a timeout means “no result”, not a failure | | Effect.timeoutOrElse({ duration, orElse }) | the result of a fallback effect | you have a sensible default or alternative to run |

import { Effect, Option } from "effect"
const slow = Effect.succeed("value").pipe(Effect.delay("5 seconds"))
// `timeoutOption`: absence instead of failure.
const asOption = slow.pipe(Effect.timeoutOption("1 second"))
// ^? Effect<Option<string>, never>
// `timeoutOrElse`: run a fallback when time runs out.
const withFallback = slow.pipe(
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.succeed("default value")
})
)

In every operator here, the effects that don’t win are interrupted, not left running in the background. That interruption is back-pressured by default: the race resumes only once the losers have finished cleaning up, which keeps resource usage predictable. If a loser’s cleanup is itself slow and you don’t want to wait on it, you can detach it — but for the common case, the default behavior is exactly what you want.