Skip to content

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 a Duration).
  • Input — the value fed into the schedule each step. For Effect.retry this is the error; for Effect.repeat it 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 delay
const every30s = Schedule.spaced("30 seconds") // fixed 30s gap between runs
const 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`)
)
)

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 run
Schedule.fixed("1 second") // Output: number — fixed cadence measured from the start of each run
Schedule.exponential("100 millis") // Output: Duration — 100, 200, 400, 800, ...
Schedule.exponential("100 millis", 3) // grow by a custom factor (×3) instead of ×2
Schedule.fibonacci("100 millis") // Output: Duration — 100, 100, 200, 300, 500, ...
Schedule.windowed("5 seconds") // Output: number — align recurrences to fixed wall-clock windows
Schedule.duration("250 millis") // Output: Duration — recur once after a single fixed delay

spaced 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.

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 (like recurs) 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")
)
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)
)
  • addDelay adds to the computed delay; modifyDelay replaces it.
  • jittered multiplies 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 after n outputs.

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.

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.

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)

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 stops

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 duration

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
})

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, ...

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 boundaries

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"))

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 total

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 timezone

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 output

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, ...

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)
})

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 recurrences

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 10s

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 stops

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 instead

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 output

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 spacing

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 })

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 + 50ms

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
)
)

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"

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 millis

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 delays

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, 10

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 step

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]

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 / 2s

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 elapsed

Stops 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 stops

Other 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.

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}`)
)
)

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)}`))
)

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}`))
)

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 string

Type guard that returns true if a value is a Schedule.

import { Schedule } from "effect"
Schedule.isSchedule(Schedule.recurs(3)) // => true
Schedule.isSchedule({ foo: "bar" }) // => false

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.