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 taking the longer of the two delays:

  • Schedule.both(a, b) continues only while both continue — use it to layer a stop condition (like recurs) 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")
)
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 { 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.