Schedule
A Schedule<Output, Input, Error, Env> is a stateful policy that, on each step,
decides whether to recur and after what delay. The type parameters describe what
flows through it:
Output— the value the schedule emits each step (often a counter or aDuration).Input— the value fed into the schedule each step. ForEffect.retrythis is the error; forEffect.repeatit is the success value.Error/Env— anything the schedule itself may fail with or require (most schedules need neither).
You rarely build a schedule from scratch. You start from a constructor and layer
on combinators with .pipe(...).
import { Duration, Effect, Schedule } from "effect"
// Start from constructors — each is a plain value you can name and reuse.const fiveTimes = Schedule.recurs(5) // recur 5 times, no delayconst every30s = Schedule.spaced("30 seconds") // fixed 30s gap between runsconst backoff = Schedule.exponential("200 millis") // 200ms, 400ms, 800ms, ...
// Compose them. This policy retries with exponential backoff, caps the delay// at 10 seconds, adds random jitter, and stops after at most 6 attempts.const policy = Schedule.exponential("200 millis").pipe( Schedule.either(Schedule.spaced("10 seconds")), // delay = min(backoff, 10s) Schedule.jittered, // multiply each delay by a random factor Schedule.both(Schedule.recurs(6)), // stop once either side stops Schedule.tapOutput((delay: Duration.Duration) => Effect.logDebug(`next attempt in ${Duration.toMillis(delay)}ms`) ))Constructors
Section titled “Constructors”Every constructor below returns a Schedule whose Output is shown in the
comment. Duration.Input accepts strings like "200 millis", "30 seconds", a number
of milliseconds, a bigint of nanoseconds, a Duration, or a
[seconds, nanos] high-resolution tuple.
import { Schedule } from "effect"
Schedule.recurs(5) // Output: number — recur 5 more times (0,1,2,3,4)Schedule.forever // Output: number — recur forever (0,1,2,...)Schedule.spaced("1 second") // Output: number — fixed gap measured from the end of each runSchedule.fixed("1 second") // Output: number — fixed cadence measured from the start of each runSchedule.exponential("100 millis") // Output: Duration — 100, 200, 400, 800, ...Schedule.exponential("100 millis", 3) // grow by a custom factor (×3) instead of ×2Schedule.fibonacci("100 millis") // Output: Duration — 100, 100, 200, 300, 500, ...Schedule.windowed("5 seconds") // Output: number — align recurrences to fixed wall-clock windowsSchedule.duration("250 millis") // Output: Duration — recur once after a single fixed delayspaced vs fixed is a common point of confusion. spaced waits the given
duration after the previous run finishes, so a slow effect pushes the next run
later. fixed recurs on a steady cadence regardless of how long each run takes —
if a run overruns the window, the next fires immediately.
Combining schedules
Section titled “Combining schedules”both and either merge two schedules by combining their continue decisions
and taking the longer of the two delays:
Schedule.both(a, b)continues only while both continue — use it to layer a stop condition (likerecurs) onto a delay pattern.Schedule.either(a, b)continues while either continues — use it as a fallback, or, as above, to cap a growing backoff against a constant ceiling.
import { Schedule } from "effect"
// Exponential backoff, but never more than 6 attempts.const cappedAttempts = Schedule.both( Schedule.exponential("250 millis"), Schedule.recurs(6))
// Keep retrying until BOTH the spacing and the count schedules give up.const keepTrying = Schedule.either( Schedule.spaced("2 seconds"), Schedule.recurs(3))Schedule.andThen(first, second) runs first to completion, then switches to
second — handy for “retry fast a few times, then back off slowly”:
import { Schedule } from "effect"
const fastThenSlow = Schedule.andThen( Schedule.exponential("100 millis").pipe(Schedule.take(3)), Schedule.spaced("5 seconds"))Transforming delays and outputs
Section titled “Transforming delays and outputs”import { Duration, Effect, Schedule } from "effect"
const tuned = Schedule.exponential("100 millis").pipe( // Add extra fixed delay on top of whatever the schedule computed. Schedule.addDelay(() => Effect.succeed("50 millis")), // Replace the delay entirely. The callback returns an Effect<Duration.Input>, // so you can compute the new delay (here, capped at 5 seconds). Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.min(delay, Duration.seconds(5))) ), // Apply random jitter so concurrent clients don't retry in lockstep. Schedule.jittered, // Keep only the first 8 recurrences. Schedule.take(8))addDelayadds to the computed delay;modifyDelayreplaces it.jitteredmultiplies each delay by a random factor (between 0.8× and 1.2×) to avoid the thundering herd problem where many clients retry at the same instant.take(n)stops the schedule afternoutputs.
Stopping and inspecting
Section titled “Stopping and inspecting”Schedule.while continues only while a predicate holds. The predicate receives
the full step Metadata — including the input, output, attempt count, and
elapsed time — and may return a boolean or an Effect<boolean>. To type the
input (for example, an error you want to inspect), declare it with
setInputType:
import { Effect, Schema, Schedule } from "effect"
class HttpError extends Schema.TaggedErrorClass<HttpError>()("HttpError", { message: Schema.String, status: Schema.Number, retryable: Schema.Boolean}) {}
// Retry with backoff, but only while the failure is marked retryable, and only// for the first 30 seconds of attempts.const retryRetryable = Schedule.exponential("200 millis").pipe( Schedule.setInputType<HttpError>(), Schedule.while(({ input, elapsed }) => input.retryable && elapsed < 30_000))For observability, tapInput and tapOutput run an effect on each step’s input
or output without changing it — ideal for logging or metrics:
import { Duration, Effect, Schema, Schedule } from "effect"
class HttpError extends Schema.TaggedErrorClass<HttpError>()("HttpError", { message: Schema.String, status: Schema.Number}) {}
const instrumented = Schedule.exponential("200 millis").pipe( Schedule.setInputType<HttpError>(), Schedule.tapInput((error) => Effect.logDebug(`retrying after ${error.status}: ${error.message}`) ), Schedule.tapOutput((delay: Duration.Duration) => Effect.logDebug(`next retry in ${Duration.toMillis(delay)}ms`) ))These schedules are just values — pass them to Effect.retry or Effect.repeat
as shown in Repetition & Retry. For
calendar-driven recurrence, see Cron.