Schedule Cookbook
Use this cookbook when you need to define a Schedule value. The examples are
ordered from small single-purpose policies to larger real-world policies that
combine timing, input classification, output shaping, and observation.
This cookbook intentionally defines schedules only. It does not apply them with
Effect.retry, Effect.repeat, streams, or channels.
Before Choosing A Schedule
Section titled “Before Choosing A Schedule”Schedule.recurs(n)counts recurrences after the first run.Schedule.spacedwaits after each completed run;Schedule.fixeduses an aligned cadence;Schedule.windowedrecurs on window boundaries.Schedule.durationperforms exactly one recurrence after the duration.Schedule.duringis an elapsed-time budget, not a delay by itself.- Schedule output is policy output. Use
Schedule.passthrough,Schedule.delays,Schedule.map, orSchedule.reducewhen the output shape matters. Schedule.bothcontinues only while both schedules continue.Schedule.eithercontinues while either schedule can continue.Schedule.jitteredspreads callers out. It does not add a recurrence limit.Schedule.addDelayadds extra delay based on schedule output.Schedule.modifyDelayreplaces or adjusts the selected delay.- Leave unbounded schedules to explicitly owned background work.
Choose By Problem Shape
Section titled “Choose By Problem Shape”| Problem shape | Start with |
|---|---|
| Bounded retry | Schedule.exponential or Schedule.fibonacci, then Schedule.recurs and optionally Schedule.during |
| Poll latest status | Schedule.spaced or Schedule.fixed, then Schedule.setInputType, Schedule.passthrough, and Schedule.while |
| Inspect selected delays | Schedule.delays, then Schedule.map |
| Adapt delay from output | Schedule.addDelay |
| Replace or cap selected delay | Schedule.modifyDelay |
| Run phases in sequence | Schedule.andThen |
| Preserve phase in output | Schedule.andThenResult |
| Continue while both policies continue | Schedule.both |
| Continue while either policy continues | Schedule.either |
| Keep input or output history | Schedule.collectInputs, Schedule.collectOutputs, or Schedule.collectWhile |
| Maintain a running aggregate | Schedule.reduce |
| Observe decisions without changing output | Schedule.tap, Schedule.tapInput, or Schedule.tapOutput |
| Build a local state machine | Schedule.unfold |
Table Of Contents
Section titled “Table Of Contents”- Single-Policy Schedules
- Shape Schedule Outputs
- Combine Policies
- Work With Inputs
- Accumulate State
- Adapt Delays
- Observe Schedule Decisions
- Build Local State Machines
- Realistic Policies
Single-Policy Schedules
Section titled “Single-Policy Schedules”Retry A Profile Fetch Three Times
Section titled “Retry A Profile Fetch Three Times”Goal: Create a retry policy for loading a user profile that allows at most 3 recurrences and outputs the recurrence count.
import { Schedule } from "effect"
const profileRetry = Schedule.recurs(3)Poll A Queue Once Per Second
Section titled “Poll A Queue Once Per Second”Goal: Create a cadence for polling queue depth about 1 second after each completed check.
import { Schedule } from "effect"
const queueDepthPolling = Schedule.spaced("1 second")Run A Heartbeat On An Aligned Cadence
Section titled “Run A Heartbeat On An Aligned Cadence”Goal: Create a heartbeat cadence that runs on aligned 30-second boundaries instead of waiting 30 seconds after each run finishes.
import { Schedule } from "effect"
const heartbeatCadence = Schedule.fixed("30 seconds")Warm A Cache Once After Deployment
Section titled “Warm A Cache Once After Deployment”Goal: Create a follow-up cache warmup that recurs exactly once after about 1 minute.
import { Schedule } from "effect"
const cacheWarmupFollowUp = Schedule.duration("1 minute")Keep A Health Probe Inside A Time Budget
Section titled “Keep A Health Probe Inside A Time Budget”Goal: Create a budget that allows health probes to continue only while about 1 minute has elapsed or less.
import { Schedule } from "effect"
const healthProbeBudget = Schedule.during("1 minute")Retry A Config Fetch With Exponential Backoff
Section titled “Retry A Config Fetch With Exponential Backoff”Goal: Create a retry policy for fetching remote configuration, starting with a 100 millisecond exponential backoff.
import { Schedule } from "effect"
const configFetchBackoff = Schedule.exponential("100 millis")Probe A Cold Replica With Fibonacci Backoff
Section titled “Probe A Cold Replica With Fibonacci Backoff”Goal: Create a gentler startup probe for a cold search replica, using Fibonacci backoff and taking the first 4 outputs.
import { Schedule } from "effect"
const searchReplicaWarmup = Schedule.fibonacci("100 millis").pipe( Schedule.take(4))Run A Worker Loop Forever
Section titled “Run A Worker Loop Forever”Goal: Create an unbounded worker-loop counter with no added delay.
import { Schedule } from "effect"
const workerLoopCounter = Schedule.foreverTrigger A Nightly Report
Section titled “Trigger A Nightly Report”Goal: Create a schedule for a nightly billing report at 02:00.
import { Schedule } from "effect"
const nightlyBillingReport = Schedule.cron("0 2 * * *")Sample Five-Minute Windows
Section titled “Sample Five-Minute Windows”Goal: Create a schedule that recurs on 5-minute window boundaries.
import { Schedule } from "effect"
const fiveMinuteWindows = Schedule.windowed("5 minutes")Shape Schedule Outputs
Section titled “Shape Schedule Outputs”Echo Feature Flag Inputs
Section titled “Echo Feature Flag Inputs”Goal: Create a schedule for feature flag snapshots that immediately outputs each input unchanged and takes the first 3 samples.
import { Schedule } from "effect"
type FeatureFlagSnapshot = { readonly enabled: boolean }
const featureFlagSamples = Schedule.identity<FeatureFlagSnapshot>().pipe( Schedule.take(3))Label Retry Attempts
Section titled “Label Retry Attempts”Goal: Create a retry-attempt schedule that turns recurrence counts into labels
such as attempt-1, attempt-2, and attempt-3.
import { Schedule } from "effect"
const retryAttemptLabels = Schedule.recurs(3).pipe( Schedule.map((count) => `attempt-${count + 1}`))Report Selected Poll Delays
Section titled “Report Selected Poll Delays”Goal: Create a telemetry schedule that runs on an aligned 2-second cadence, outputs selected delays, maps each delay to milliseconds, and takes 3 outputs.
import { Duration, Schedule } from "effect"
const pollDelayReport = Schedule.fixed("2 seconds").pipe( Schedule.delays, Schedule.map((delay) => ({ millis: Duration.toMillis(delay) })), Schedule.take(3))Explanation: Schedule.delays changes the schedule output to the selected
delay. Use it when observability or downstream policy needs the actual delay
rather than the cadence counter.
Report Elapsed Runtime
Section titled “Report Elapsed Runtime”Goal: Create a runtime sampler that outputs elapsed time in milliseconds and takes 4 samples.
import { Duration, Schedule } from "effect"
const elapsedRuntimeReport = Schedule.elapsed.pipe( Schedule.map((elapsed) => ({ millis: Duration.toMillis(elapsed) })), Schedule.take(4))Combine Policies
Section titled “Combine Policies”Add Jitter To Webhook Backoff
Section titled “Add Jitter To Webhook Backoff”Goal: Create a webhook retry backoff that starts at 200 milliseconds, adds jitter, outputs selected delays, and takes 3 outputs.
import { Schedule } from "effect"
const jitteredWebhookBackoff = Schedule.exponential("200 millis").pipe( Schedule.jittered, Schedule.delays, Schedule.take(3))Stop Deployment Hook Retries By Count And Time
Section titled “Stop Deployment Hook Retries By Count And Time”Goal: Create a deployment hook retry budget that uses jittered exponential backoff, allows at most 5 recurrences, and also stops after about 20 seconds.
import { Schedule } from "effect"
const deploymentHookRetryBudget = Schedule.exponential("200 millis").pipe( Schedule.jittered, Schedule.both(Schedule.recurs(5)), Schedule.both(Schedule.during("20 seconds")))Explanation: the resulting output is nested because Schedule.both preserves
both schedule outputs. Use Schedule.map when a smaller output is needed.
Continue While Either Probe Is Active
Section titled “Continue While Either Probe Is Active”Goal: Create a service readiness policy that continues while either 2 immediate warmup probes or a slower 500 millisecond probe schedule still wants to recur.
import { Schedule } from "effect"
const readinessWarmupOrSlowProbe = Schedule.recurs(2).pipe( Schedule.either(Schedule.spaced("500 millis").pipe(Schedule.take(5))))Explanation: Schedule.either keeps recurring while at least one side can
continue. Its output preserves both sides, so map the result if callers should
see a smaller shape.
Warm Up Fast, Then Settle Into Maintenance
Section titled “Warm Up Fast, Then Settle Into Maintenance”Goal: Create a cache invalidation sequence that runs 2 quick recurrences 100 milliseconds apart, then 3 slower recurrences 30 seconds apart, then stops.
import { Schedule } from "effect"
const cacheInvalidationSequence = Schedule.spaced("100 millis").pipe( Schedule.take(2), Schedule.andThen(Schedule.spaced("30 seconds").pipe(Schedule.take(3))))Preserve Warmup And Steady Phases
Section titled “Preserve Warmup And Steady Phases”Goal: Create a retry classifier with a fast exponential phase and a steady Fibonacci phase, preserving the phase in the output.
import { Result, Schedule } from "effect"
const phasedRetryClassifier = Schedule.exponential("100 millis").pipe( Schedule.take(2), Schedule.andThenResult(Schedule.fibonacci("500 millis").pipe(Schedule.take(3))), Schedule.map((result) => Result.match(result, { onFailure: (delay) => ({ phase: "steady", delay }), onSuccess: (delay) => ({ phase: "fast", delay }) }) ))Explanation: Schedule.andThenResult keeps phase information in the output.
The first schedule is represented by the success side, and the second schedule
is represented by the failure side.
Work With Inputs
Section titled “Work With Inputs”Poll Upload Progress Until Complete
Section titled “Poll Upload Progress Until Complete”Goal: Create an upload-progress schedule that waits about 1 second between checks, outputs the latest progress object, and continues only while the upload is incomplete.
import { Schedule } from "effect"
type UploadProgress = { readonly percent: number }
const uploadProgressUntilComplete = Schedule.spaced("1 second").pipe( Schedule.setInputType<UploadProgress>(), Schedule.passthrough, Schedule.while(({ input }) => input.percent < 100))Explanation: Schedule.setInputType tells TypeScript which input each step
receives. Schedule.passthrough makes the output the latest input, so a polling
schedule can return the final status instead of a counter.
Keep A History Of Retry Error Codes
Section titled “Keep A History Of Retry Error Codes”Goal: Create a schedule for string error-code inputs that checks about once per second and outputs all input codes seen so far.
import { Schedule } from "effect"
const retryErrorCodeHistory = Schedule.collectInputs( Schedule.spaced("1 second").pipe( Schedule.setInputType<string>() ))Explanation: Schedule.collectInputs is useful when the input stream is the
thing to audit, such as retry errors or status samples.
Collect Deployment Phases Until Failure
Section titled “Collect Deployment Phases Until Failure”Goal: Create a schedule that outputs the collected deployment phases while the
input phase is not failed.
import { Schedule } from "effect"
type DeploymentPhase = { readonly phase: "created" | "deploying" | "verifying" | "failed"}
const deploymentPhaseHistory = Schedule.identity<DeploymentPhase>().pipe( Schedule.collectWhile(({ input }) => input.phase !== "failed"))Explanation: Schedule.collectWhile accumulates accepted outputs and stops
collecting when the predicate is false. This is cheaper to read than manually
combining identity, while, and reduce for simple histories.
Collect Heartbeat Counts
Section titled “Collect Heartbeat Counts”Goal: Create a schedule that recurs forever, collects recurrence counts produced so far, and takes only the first 3 collected outputs.
import { Schedule } from "effect"
const heartbeatCountHistory = Schedule.collectOutputs(Schedule.forever).pipe( Schedule.take(3))Explanation: Schedule.collectOutputs collects the schedule output, not the
input. Add Schedule.take or another bound when collecting from an unbounded
schedule.
Accumulate State
Section titled “Accumulate State”Accumulate Request Latency Stats
Section titled “Accumulate Request Latency Stats”Goal: Create a schedule for latency samples that inspects the first 5 inputs and
outputs running min, max, total, and count fields.
import { Schedule } from "effect"
const requestLatencyStats = Schedule.identity<number>().pipe( Schedule.take(5), Schedule.reduce( () => ({ min: Number.POSITIVE_INFINITY, max: 0, total: 0, count: 0 }), (state, latency) => ({ min: Math.min(state.min, latency), max: Math.max(state.max, latency), total: state.total + latency, count: state.count + 1 }) ))Explanation: Schedule.reduce is for a running aggregate. Prefer it over
collecting every value when callers only need a summary.
Count Recent Worker Failures
Section titled “Count Recent Worker Failures”Goal: Create a schedule for boolean failure samples that inspects the first 5 inputs and outputs the running count of failed samples.
import { Schedule } from "effect"
const recentWorkerFailureCount = Schedule.identity<boolean>().pipe( Schedule.take(5), Schedule.reduce(() => 0, (count, failed) => failed ? count + 1 : count))Adapt Delays
Section titled “Adapt Delays”Slow A Queue Consumer Under Backpressure
Section titled “Slow A Queue Consumer Under Backpressure”Goal: Create a queue backpressure schedule that outputs each queue snapshot, continues while the queue is not paused, adds 5 seconds of delay when depth is above 1000, adds 500 milliseconds otherwise, and takes 10 outputs.
import { Effect, Schedule } from "effect"
type QueueSnapshot = { readonly depth: number; readonly paused: boolean }
const queueBackpressureSchedule = Schedule.identity<QueueSnapshot>().pipe( Schedule.while(({ input }) => !input.paused), Schedule.addDelay((snapshot) => Effect.succeed(snapshot.depth > 1000 ? "5 seconds" : "500 millis")), Schedule.take(10))Explanation: Schedule.addDelay adds delay based on the current schedule
output. It is a good fit for input-driven pacing when the output is already the
latest input.
Cap WebSocket Reconnect Delays
Section titled “Cap WebSocket Reconnect Delays”Goal: Create a reconnect policy that uses jittered exponential backoff, caps selected delays at 5 seconds, allows at most 8 recurrences, and outputs the selected delay.
import { Duration, Effect, Schedule } from "effect"
const websocketReconnectDelays = Schedule.exponential("100 millis").pipe( Schedule.jittered, Schedule.modifyDelay((_, delay) => Effect.succeed(Duration.min(delay, Duration.seconds(5)))), Schedule.both(Schedule.recurs(8)), Schedule.delays)Explanation: Schedule.modifyDelay receives the selected delay and returns the
replacement delay. Use it for caps, floors, clamps, or provider-provided delay
hints.
Observe Schedule Decisions
Section titled “Observe Schedule Decisions”Log Heartbeat Inputs
Section titled “Log Heartbeat Inputs”Goal: Create a heartbeat schedule that runs on an aligned 10-second cadence, logs each service id input, outputs the input unchanged, and takes 2 outputs.
import { Console, Schedule } from "effect"
type HeartbeatStatus = { readonly id: string }
const heartbeatInputLogs = Schedule.fixed("10 seconds").pipe( Schedule.setInputType<HeartbeatStatus>(), Schedule.tapInput((input) => Console.log(`heartbeat:${input.id}`)), Schedule.passthrough, Schedule.take(2))Record Backoff Delays
Section titled “Record Backoff Delays”Goal: Create a retry schedule that uses Fibonacci backoff, takes 5 outputs, and logs each selected delay without changing the schedule output.
import { Console, Schedule } from "effect"
const loggedBackoffDelays = Schedule.fibonacci("200 millis").pipe( Schedule.take(5), Schedule.tapOutput((delay) => Console.log(delay)))Log Attempt Metadata
Section titled “Log Attempt Metadata”Goal: Create a telemetry backoff that logs each attempt number and selected delay in milliseconds without changing the schedule output.
import { Console, Duration, Schedule } from "effect"
const telemetryBackoffPolicy = Schedule.exponential("250 millis").pipe( Schedule.take(5), Schedule.tap(({ attempt, output }) => Console.log(`attempt-${attempt}: ${Duration.toMillis(output)}ms`)))Explanation: use tapInput when the input matters, tapOutput when only the
output matters, and tap when metadata such as attempt number or selected
duration matters. None of these operators changes the schedule output.
Build Local State Machines
Section titled “Build Local State Machines”Track Scheduler Ticks
Section titled “Track Scheduler Ticks”Goal: Create a local scheduler tick state that starts at 1, increments after
each output, maps each output to a tick object, and takes 4 outputs.
import { Effect, Schedule } from "effect"
const schedulerTickState = Schedule.unfold(1, (n) => Effect.succeed(n + 1)).pipe( Schedule.map((tick) => ({ tick })), Schedule.take(4))Explanation: Schedule.unfold builds a schedule from local state. Use it when
the next output depends on private scheduler state rather than on the latest
input.
Cycle A Maintenance Phase Machine
Section titled “Cycle A Maintenance Phase Machine”Goal: Create a local phase machine for maintenance work. Start at warming,
move to active, then cooling, then back to active, and take 6 outputs.
import { Effect, Schedule } from "effect"
type MaintenancePhase = "warming" | "active" | "cooling"
const maintenancePhaseMachine = Schedule.unfold<MaintenancePhase>( "warming", (phase) => { switch (phase) { case "warming": return Effect.succeed("active") case "active": return Effect.succeed("cooling") case "cooling": return Effect.succeed("active") } }).pipe( Schedule.take(6))Realistic Policies
Section titled “Realistic Policies”Retry An HTTP Gateway With A Delay Envelope
Section titled “Retry An HTTP Gateway With A Delay Envelope”Goal: Create an HTTP gateway retry schedule. Retry only network failures, status 429, and status 500, 502, or 503. Use jittered exponential backoff starting at 100 milliseconds, cap selected delays at 2 seconds, allow at most 6 recurrences, and output the selected delay.
import { Duration, Effect, Schedule } from "effect"
type GraphqlGatewayError = | { readonly _tag: "Network" } | { readonly _tag: "HttpStatus"; readonly status: number } | { readonly _tag: "BadRequest" }
const isRetryableGraphqlGatewayError = ( error: GraphqlGatewayError): boolean => error._tag === "Network" || (error._tag === "HttpStatus" && (error.status === 429 || error.status === 500 || error.status === 502 || error.status === 503))
const graphqlGatewayRetry = Schedule.exponential("100 millis").pipe( Schedule.jittered, Schedule.setInputType<GraphqlGatewayError>(), Schedule.modifyDelay((_, delay) => Effect.succeed(Duration.min(delay, Duration.seconds(2)))), Schedule.both(Schedule.recurs(6)), Schedule.while(({ input }) => isRetryableGraphqlGatewayError(input)), Schedule.delays)Poll A Rollout With A Deadline
Section titled “Poll A Rollout With A Deadline”Goal: Create a rollout watcher that starts from an aligned 1-second cadence, jitters the selected delay, outputs the latest status, continues only while the rollout is running, and stops after about 2 minutes.
import { Schedule } from "effect"
type RolloutStatus = { readonly state: "running" | "succeeded" | "failed"}
const rolloutStatusWatcher = Schedule.fixed("1 second").pipe( Schedule.setInputType<RolloutStatus>(), Schedule.passthrough, Schedule.jittered, Schedule.both(Schedule.during("2 minutes")), Schedule.while(({ input }) => input.state === "running"), Schedule.map(([status]) => status))Respect A Provider Retry-After Header
Section titled “Respect A Provider Retry-After Header”Goal: Create a provider retry schedule. Retry status 429, 500, and 503. Use
exponential backoff starting at 1 second. When retryAfter is present, use it as
a lower bound. Cap selected delays at 1 minute, allow at most 6 recurrences, and
output the selected delay.
import { Duration, Effect, Schedule } from "effect"
type PushProviderResponse = { readonly status: 429 | 500 | 503 | 400 readonly retryAfter: Duration.Duration | undefined}
const pushNotificationProviderRetry = Schedule.exponential("1 second").pipe( Schedule.setInputType<PushProviderResponse>(), Schedule.passthrough, Schedule.modifyDelay((response, delay) => Effect.succeed( Duration.min( response.retryAfter === undefined ? delay : Duration.max(delay, response.retryAfter), Duration.minutes(1) ) ) ), Schedule.both(Schedule.recurs(6)), Schedule.while(({ input }) => input.status === 429 || input.status === 500 || input.status === 503), Schedule.delays)Poll An OAuth Device Code Flow
Section titled “Poll An OAuth Device Code Flow”Goal: Create an OAuth device-code polling schedule. Poll every 5 seconds, add
another 5 seconds for slow_down, output the latest input, continue only for
authorization_pending and slow_down, and stop after about 15 minutes.
import { Effect, Schedule } from "effect"
type OAuthDeviceCodeStatus = { readonly error: | "authorization_pending" | "slow_down" | "access_denied" | "expired_token"}
const oauthDeviceCodePolling = Schedule.spaced("5 seconds").pipe( Schedule.setInputType<OAuthDeviceCodeStatus>(), Schedule.passthrough, Schedule.addDelay((status) => Effect.succeed(status.error === "slow_down" ? "5 seconds" : "0 millis")), Schedule.both(Schedule.during("15 minutes")), Schedule.while(({ input }) => input.error === "authorization_pending" || input.error === "slow_down"), Schedule.map(([status]) => status))Escalate Incident Notifications In Phases
Section titled “Escalate Incident Notifications In Phases”Goal: Create an incident escalation cadence that emits 3 recurrences spaced 1 minute apart, then 3 recurrences spaced 5 minutes apart, then switches to an aligned 15-minute cadence.
import { Schedule } from "effect"
const incidentEscalationCadence = Schedule.spaced("1 minute").pipe( Schedule.take(3), Schedule.andThen(Schedule.spaced("5 minutes").pipe(Schedule.take(3))), Schedule.andThen(Schedule.fixed("15 minutes")))Run Maintenance After A Warmup
Section titled “Run Maintenance After A Warmup”Goal: Create a maintenance schedule that performs one warmup recurrence after about 30 seconds, then switches to a cron schedule that recurs every day at 03:00.
import { Schedule } from "effect"
const maintenanceCronAfterWarmup = Schedule.duration("30 seconds").pipe( Schedule.andThen(Schedule.cron("0 3 * * *")))