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 combining their delays:
Schedule.both(a, b)continues only while both continue, and uses the maximum of the two delays — use it to layer a stop condition (likerecurs) onto a delay pattern.Schedule.either(a, b)continues while either continues, and uses the minimum of the two delays — 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 { 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.
Reference
Section titled “Reference”The reference below covers every notable public export of the Schedule module.
A few examples drive a schedule manually with Schedule.toStepWithSleep so you
can see the emitted outputs; in practice you hand a schedule to
Effect.retry/Effect.repeat rather than stepping it yourself.
Constructors
Section titled “Constructors”recurs
Section titled “recurs”Recurs the given number of times with no delay, emitting a 0-based counter. With
retry/repeat this allows one initial run plus times recurrences.
import { Schedule } from "effect"
Schedule.recurs(3) // Output: number — emits 0, 1, 2 then stops (delay 0)forever
Section titled “forever”Recurs forever with no delay, emitting an incrementing counter. It is exactly
spaced(Duration.zero).
import { Schedule } from "effect"
Schedule.forever // Output: number — emits 0, 1, 2, 3, ... never stopsspaced
Section titled “spaced”Recurs continuously, waiting duration after the previous run completes.
Emits a 0-based counter. A slow effect pushes the next run later.
import { Schedule } from "effect"
Schedule.spaced("1 second") // Output: number — wait 1s after each run; emits 0, 1, 2, ...Recurs on a steady cadence of interval, measured from the start of each run.
If a run overruns the window the next fires immediately; missed windows are not
replayed. Emits a 0-based counter.
import { Schedule } from "effect"
Schedule.fixed("1 second") // Output: number — fire every 1s regardless of run durationexponential
Section titled “exponential”Recurs forever, growing the delay as base * factor ** n (factor defaults to
2). Emits the current delay as a Duration.
import { Effect, Schedule } from "effect"
const program = Effect.gen(function* () { const step = yield* Schedule.toStepWithSleep( Schedule.exponential("100 millis").pipe(Schedule.take(4)) ) yield* step(undefined) // sleeps 100 millis yield* step(undefined) // sleeps 200 millis yield* step(undefined) // sleeps 400 millis yield* step(undefined) // sleeps 800 millis})fibonacci
Section titled “fibonacci”Recurs forever, increasing each delay by summing the previous two delays. Emits
the current delay as a Duration.
import { Schedule } from "effect"
Schedule.fibonacci("100 millis") // Output: Duration — 100, 100, 200, 300, 500, 800, ...windowed
Section titled “windowed”Divides the timeline into interval-long windows and sleeps until the next
window boundary on each recurrence (boundaries are relative to when the schedule
started). Emits a 0-based counter.
import { Schedule } from "effect"
Schedule.windowed("5 seconds") // Output: number — recurrences snap to 5s window boundariesduration
Section titled “duration”Recurs exactly once after a single fixed delay, then stops. Emits the
configured Duration. Use during to keep recurring until a duration elapses.
import { Console, Effect, Schedule } from "effect"
// Runs the effect once, then again after 1 second, then stops.Effect.repeat(Console.log("again"), Schedule.duration("1 second"))during
Section titled “during”Always recurs, but only while the total elapsed time is within duration.
Emits the elapsed time as a Duration. Use duration for a single delayed
recurrence.
import { Schedule } from "effect"
Schedule.during("5 seconds") // Output: Duration — keep recurring for up to 5 seconds totalelapsed
Section titled “elapsed”Always recurs with zero delay, emitting the total elapsed time since the
first step as a Duration. Combine it with another schedule (via both) to
attach elapsed time to that schedule’s pacing.
import { Schedule } from "effect"
// Pair a 1s cadence with the elapsed-time output.const withElapsed = Schedule.spaced("1 second").pipe(Schedule.both(Schedule.elapsed))// Output: [number, Duration] — e.g. [0, 0 millis], [1, 1000 millis], [2, 2000 millis]Recurs on a cron expression (or a parsed Cron), optionally in a timezone.
Emits the Duration until the next occurrence. May fail with CronParseError
when given an invalid expression. See Cron for the cron
syntax.
import { Schedule } from "effect"
Schedule.cron("30 2 * * *") // every day at 02:30 — Output: Duration (time until next run)Schedule.cron("0 9 * * 1", "America/New_York") // Mondays 09:00 in a timezoneidentity
Section titled “identity”Always recurs with zero delay, passing each input straight through as the output. Useful as a base for combinators that inspect inputs.
import { Schedule } from "effect"
Schedule.identity<string>() // Schedule<string, string> — echoes each input as its outputunfold
Section titled “unfold”Builds a custom schedule by unfolding state: it emits the current state and
computes the next state via an effectful next function. Recurs with zero delay
until limited (e.g. with take).
import { Effect, Schedule } from "effect"
Schedule.unfold(0, (n) => Effect.succeed(n + 1)) // Output: number — emits 0, 1, 2, 3, ...fromStep / fromStepWithMetadata
Section titled “fromStep / fromStepWithMetadata”Low-level escape hatch: build a schedule directly from a step function. The step
returns [output, delay] to continue, or Cause.done(finalOutput) to stop.
fromStepWithMetadata hands the step the full InputMetadata (attempt, elapsed,
input, …) instead of just now and input. Prefer the higher-level
constructors above unless you need fully custom control flow.
import { Cause, Duration, Effect, Schedule } from "effect"
const threeQuick = Schedule.fromStep( Effect.sync(() => { let count = 0 return (_now: number, _input: unknown) => count >= 3 ? Cause.done(count) // stop after 3 steps : Effect.succeed([count++, Duration.millis(100)] as [number, Duration.Duration]) }))toStep / toStepWithMetadata / toStepWithSleep
Section titled “toStep / toStepWithMetadata / toStepWithSleep”Low-level escape hatch in the other direction: turn a schedule into its step
function so you can drive it manually. toStep returns a (now, input) => step
where you supply the timestamp and handle the returned delay yourself;
toStepWithMetadata reads the Clock, sleeps for each delay, and yields full
Metadata; toStepWithSleep does the same but yields only the output.
import { Effect, Schedule } from "effect"
const program = Effect.gen(function* () { // Automatically sleeps for each computed delay; yields the output. const step = yield* Schedule.toStepWithSleep( Schedule.spaced("1 second").pipe(Schedule.take(2)) ) const a = yield* step("first") // => 0 (after sleeping 1s) const b = yield* step("second") // => 1 (after sleeping 1s)})Combining schedules
Section titled “Combining schedules”Continues only while both schedules continue, using the maximum of the
two delays, and emits a tuple [leftOutput, rightOutput].
import { Schedule } from "effect"
Schedule.both(Schedule.exponential("250 millis"), Schedule.recurs(6))// Output: [Duration, number] — exponential backoff, capped at 6 recurrenceseither
Section titled “either”Continues while either schedule continues, using the minimum of the two
delays, and emits a tuple [leftOutput, rightOutput].
import { Schedule } from "effect"
Schedule.either(Schedule.exponential("200 millis"), Schedule.spaced("10 seconds"))// Output: [Duration, number] — growing backoff, but each delay capped at 10sbothWith / eitherWith
Section titled “bothWith / eitherWith”Like both/either, but combine the two outputs with a custom function instead
of producing a tuple.
import { Schedule } from "effect"
Schedule.bothWith( Schedule.recurs(3), Schedule.recurs(5), (left, right) => left + right) // Output: number — sum of the two counters; stops when either side stopsbothLeft / bothRight
Section titled “bothLeft / bothRight”both, keeping only the left (self) output or only the right (other) output.
import { Schedule } from "effect"
// Pace with exponential backoff but limit attempts; keep the backoff Duration.Schedule.bothLeft(Schedule.exponential("100 millis"), Schedule.recurs(5))// Output: Duration
Schedule.bothRight(Schedule.recurs(5), Schedule.exponential("100 millis"))// Output: Duration — right output retained insteadeitherLeft / eitherRight
Section titled “eitherLeft / eitherRight”either, keeping only the left (self) output or only the right (other)
output.
import { Schedule } from "effect"
Schedule.eitherLeft(Schedule.exponential("100 millis"), Schedule.spaced("1 second"))// Output: Duration — continues while either side does; keeps the left output
Schedule.eitherRight(Schedule.exponential("100 millis"), Schedule.spaced("1 second"))// Output: number — keeps the right outputandThen
Section titled “andThen”Runs self to completion, then switches to other. The output is the union of
both outputs (Output | Output2); inputs are intersected.
import { Schedule } from "effect"
Schedule.andThen( Schedule.exponential("100 millis").pipe(Schedule.take(3)), // Output: Duration Schedule.spaced("5 seconds") // Output: number) // Output: Duration | number — fast backoff first, then steady spacingandThenResult
Section titled “andThenResult”Like andThen, but tags which phase produced each output via a Result:
outputs from self are emitted as Result.fail, and outputs from other as
Result.succeed. The output type is Result<Output2, Output>.
import { Result, Schedule } from "effect"
const phased = Schedule.andThenResult( Schedule.recurs(2), // phase 1 — emitted as Result.fail(number) Schedule.spaced("1 second") // phase 2 — emitted as Result.succeed(number))// match with: Result.match(output, { onFailure, onSuccess })Transforming delays & outputs
Section titled “Transforming delays & outputs”addDelay
Section titled “addDelay”Adds the Duration computed by an effectful function to the schedule’s existing
delay (it is summed, not replaced).
import { Effect, Schedule } from "effect"
Schedule.exponential("100 millis").pipe( Schedule.addDelay(() => Effect.succeed("50 millis"))) // each delay becomes the exponential value + 50msmodifyDelay
Section titled “modifyDelay”Replaces the delay with the Duration computed by an effectful function, which
receives both the output and the current delay.
import { Duration, Effect, Schedule } from "effect"
Schedule.exponential("100 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.min(delay, Duration.seconds(5))) // cap at 5s ))jittered
Section titled “jittered”Scales each delay by a random factor between 0.8× and 1.2×, preserving the
schedule’s outputs and stop behavior. Spreads out concurrent retries.
import { Schedule } from "effect"
Schedule.exponential("100 millis").pipe(Schedule.jittered)// e.g. ~80–120ms, ~160–240ms, ~320–480ms, ...Transforms each output value. The function may return a plain value or an
Effect.
import { Effect, Schedule } from "effect"
Schedule.recurs(3).pipe( Schedule.map((count) => Effect.succeed(`run #${count + 1}`))) // Output: string — "run #1", "run #2", "run #3"delays
Section titled “delays”Returns a schedule that emits the delay of each step (a Duration) as its
output, preserving the original stopping behavior.
import { Schedule } from "effect"
Schedule.delays(Schedule.exponential("100 millis").pipe(Schedule.take(3)))// Output: Duration — 100 millis, 200 millis, 400 millispassthrough
Section titled “passthrough”Returns a schedule that emits each input (instead of the original output), keeping the original delays and stop behavior.
import { Schedule } from "effect"
Schedule.passthrough(Schedule.exponential("100 millis").pipe(Schedule.take(3)))// Output: the input value seen at each step (Input type), with exponential delaysreduce
Section titled “reduce”Accumulates outputs into a running state with an effectful combine function,
emitting the running state each step.
import { Effect, Schedule } from "effect"
Schedule.reduce( Schedule.recurs(5), () => 0, (sum, count) => Effect.succeed(sum + count)) // Output: number — running sum of the recurs counter: 0, 1, 3, 6, 10collectInputs
Section titled “collectInputs”Follows self but emits all inputs seen so far as an array. Stops when
self stops.
import { Schedule } from "effect"
Schedule.collectInputs(Schedule.spaced("100 millis"))// Output: Array<Input> — grows by one input each stepcollectOutputs
Section titled “collectOutputs”Follows self but emits all outputs seen so far as an array. Stops when
self stops.
import { Schedule } from "effect"
Schedule.collectOutputs(Schedule.recurs(4))// Output: Array<number> — [0], [0,1], [0,1,2], [0,1,2,3]collectWhile
Section titled “collectWhile”Collects outputs into an array while the Metadata predicate holds (and stops
otherwise). Combines while and collectOutputs.
import { Effect, Schedule } from "effect"
Schedule.collectWhile( Schedule.exponential("100 millis"), (meta) => Effect.succeed(meta.attempt <= 5 && meta.elapsed < 2000)) // Output: Array<Duration> — collected delays while within 5 attempts / 2sStopping & filtering
Section titled “Stopping & filtering”Continues only while the Metadata predicate returns true (it may return a
boolean or an Effect<boolean>); stops on the first false. The predicate
sees input, output, attempt, elapsed, duration, and the timing fields.
import { Schedule } from "effect"
Schedule.spaced("1 second").pipe( Schedule.while(({ attempt, elapsed }) => attempt <= 10 && elapsed < 30_000)) // continue while under 10 attempts AND under 30s elapsedStops the schedule after n outputs, preserving the original outputs and
delays. Use recurs instead when you only need a bare counter with no delay.
import { Schedule } from "effect"
Schedule.exponential("100 millis").pipe(Schedule.take(3))// at most 3 outputs: 100ms, 200ms, 400ms — then stopsOther constructors also embed stop conditions: recurs stops after a count,
during stops after a total elapsed duration, duration stops after one
recurrence, and both/either stop when their combined continue rule says so.
Observability
Section titled “Observability”Runs an effect on the full Metadata of each step without changing the
schedule’s input or output.
import { Console, Schedule } from "effect"
Schedule.exponential("100 millis").pipe( Schedule.tap((meta) => Console.log(`attempt ${meta.attempt}, next delay ${meta.duration}`) ))tapInput
Section titled “tapInput”Runs an effect on each step’s input without changing it — ideal for logging the error being retried.
import { Console, Schedule } from "effect"
Schedule.exponential("100 millis").pipe( Schedule.tapInput((error: unknown) => Console.log(`retrying after: ${String(error)}`)))tapOutput
Section titled “tapOutput”Runs an effect on each step’s output without changing it.
import { Console, Schedule } from "effect"
Schedule.exponential("100 millis").pipe( Schedule.tapOutput((delay) => Console.log(`next delay: ${delay}`)))Typing & introspection
Section titled “Typing & introspection”setInputType
Section titled “setInputType”Declares the schedule’s Input type (without changing runtime behavior) so that
while/tap predicates can infer the input — typically the error type for a
retry policy.
import { Schema, Schedule } from "effect"
class HttpError extends Schema.TaggedErrorClass<HttpError>()("HttpError", { retryable: Schema.Boolean}) {}
Schedule.recurs(5).pipe( Schedule.setInputType<HttpError>(), Schedule.while(({ input }) => input.retryable) // `input` is HttpError)satisfiesInputType / satisfiesOutputType / satisfiesErrorType / satisfiesServicesType
Section titled “satisfiesInputType / satisfiesOutputType / satisfiesErrorType / satisfiesServicesType”Compile-time assertions that a schedule’s Input, Output, Error, or Env
type parameter extends a given type. They return the schedule unchanged and emit
a type error otherwise — useful as a guard in library code.
import { Schedule } from "effect"
declare const policy: Schedule.Schedule<number, string>
const checkInput = Schedule.satisfiesInputType<string>()const checked = checkInput(policy) // OK — Input is string// checkInput(Schedule.recurs(3)) // type error — Input is not stringisSchedule
Section titled “isSchedule”Type guard that returns true if a value is a Schedule.
import { Schedule } from "effect"
Schedule.isSchedule(Schedule.recurs(3)) // => trueSchedule.isSchedule({ foo: "bar" }) // => falseCurrentMetadata, Metadata & InputMetadata
Section titled “CurrentMetadata, Metadata & InputMetadata”Schedule.CurrentMetadata is a Context.Reference holding the current step’s
Metadata, provided to effects run between schedule steps by repeat, retry,
and the streaming scheduling APIs. The metadata shapes are:
InputMetadata<Input>:
input— the value fed into this step.attempt— 1-based step count.start— timestamp (ms) of the first step.now— timestamp (ms) of this step.elapsed— ms elapsed since the first step.elapsedSincePrevious— ms elapsed since the previous step.
Metadata<Output, Input> extends InputMetadata with:
output— the value emitted this step.duration— the computed delay before the next step.
These are the fields available to while, collectWhile, and tap predicates.
import { Effect, Schedule } from "effect"
// Read the current step metadata inside a repeated effect.const program = Effect.gen(function* () { const meta = yield* Schedule.CurrentMetadata yield* Effect.log(`attempt ${meta.attempt}, elapsed ${meta.elapsed}ms`)}).pipe(Effect.repeat(Schedule.recurs(3)))For driving these policies with effects, see Repetition & Retry. For calendar-driven recurrence and the cron expression syntax, see Cron.