DEV Community

Max Khramtsov
Max Khramtsov

Posted on

Scheduling in Effect: Understanding and Implementing

In this article, I want to talk about schedules in Effect and, for better understanding and fun, implement my own with Promises.

A Schedule is an abstraction for managing the repetition of effects, determining how many times and at what intervals an operation should be executed or restarted. In tools like Effect, schedules are often used to handle retry logic in error handling or to simply repeat effects.

In Effect, schedules consist of the following components:

  • Schedule: Defines the initial state and a function that, given the current state and input, returns a triple: [new state, output value, decision].
  • Decision: Indicates whether to continue execution or terminate, and whether to resume immediately or after a delay.
  • Driver: Essentially a stateful iterator that manages the schedule’s state while exposing only input → output logic externally.

To better understand how this works, I decided to create a simplified model using Promises. Below is a TypeScript example.

interface Schedule<Out, In = unknown, S = any> {
  readonly initial: S;
  readonly step: (input: In, currentState: S) => [newState: S, Out, Decision];
}

type Decision = 
  | { tag: "Continue"; delay: 0 | number } // will use 0 for sync execution
  | { tag: "Done" };

interface Driver<Out, In, S> {
  private state: S;
  constructor(readonly schedule: Schedule<Out, In, S>);
  next(input: In): Promise<Out>;
}
Enter fullscreen mode Exit fullscreen mode

PoC

To start, let's assemble a Proof of Concept to understand how everything will work together.

Schedule

Schedule — a regular class with a pipe method for convenience.

class Schedule<out Out, in In = unknown, in out S = any> {
  constructor( 
    readonly initial: S,
    readonly step: (input: In, state: S) => [state: S, out: Out, Decision],
  ) {}
  pipe<B>(f: (value: this) => B) {
    return f(this);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's write the simplest Schedule for generating infinite sequences from a single value — unfold. (similar to the inverse of Array.reduce)

const unfold = <A>(initial: A, f: (a: A) => A): Schedule<A, unknown, A> =>
  new Schedule<A, unknown, A>(initial, (_, state) => [
    f(state), // new state
    state,    // output value
    { tag: "Continue", delay: 0 }, // immediate continuation
  ]);
Enter fullscreen mode Exit fullscreen mode

And use it for count (0-1-2-3-...). We initialize it with the starting value 0 and provide a stepping function:

const count = unfold(0, (x) => x + 1);
Enter fullscreen mode Exit fullscreen mode

Driver

The driver stores the current state returned by the Schedule and feeds it back on the next iteration. It can reset to its initial state since that information is embedded in the schedule itself. The repeat function loops, calling driver.next and executing the effect until a terminal signal is received.


class Driver<Out, In, S> {
  private state: S;
  private readonly schedule: Schedule<Out, In, S>;

  constructor(schedule: Schedule<Out, In, S>) {
    this.schedule = schedule;
    this.state = schedule.initial;
  }

  async next(input: In) {
    const [state, out, decision] = this.schedule.step(input, this.state);

    this.state = state;

    if (decision.tag === "Done") {
      return null;
    } else {
      await Driver.sleep(decision.delay);
    }

    return out;
  }

  private static sleep = (duration: number) =>
    new Promise((res) => setTimeout(() => res(void 0), duration));
}
Enter fullscreen mode Exit fullscreen mode

Repeating

So, let's finally write a function to repeat an action according to a schedule. It takes the action and the schedule as arguments, and internally creates a driver to coordinate the execution of the task with the schedule.

const repeat = async <T,>(
  task: () => Promise<T>,
  schedule: Schedule<any, T>,
) => {
  const driver = new Driver(schedule);

  while (true) {
    const result = await task();
    const next = await driver.next(result);

    if (next === null) {
      return result;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Result

Now, let's put everything together and run it.

const logTask = () => console.log("logTask")

repeat(
  logTask(0), 
  count
);
// "logTask"
// "logTask"
// ... infinity ...
Enter fullscreen mode Exit fullscreen mode

Here we have figured out the mechanism for executing and applying schedules!
Now, let’s dive into composing schedules to create more complex ones from smaller, simpler pieces.

Fibonacci Scheduler

Let’s define a Schedule that fires five times with Fibonacci sequence intervals: 0, 1, 1, 2, and 3 seconds.

const fibonacci = unfold<[number, number]>(
  [0, 1], 
  ([prev, cur]) => [cur, cur + prev]
);
Enter fullscreen mode Exit fullscreen mode

Let’s now write a map function that transforms a schedule’s output by applying a given function to its results. We will use it on order to extract second member from tuple generated by fibonacci function.

const map = <Out, Out1>(f: (out: Out) => Out1) =>
  <In, S>(schedule: Schedule<Out, In, S>) =>
    new Schedule<Out1, In, S>(schedule.initial, (input, state) => {
      const [_state, _out, _decision] = schedule.step(input, state);
      return [_state, f(_out), _decision]; // f(_out) - mapping
    });
Enter fullscreen mode Exit fullscreen mode

Other Combinators

Most other combinators follow a similar structure:

  • They return a new Schedule
  • They pass through the initial state
  • They modify the step function in some way

For example, the check takes a Schedule and a predicate to be applied to its result. If the predicate returns false, it changes the Decision of the original schedule to Done.

const check =
  <Out, In>(predicate: (in_: In, out: Out) => boolean) =>
  <S,>(schedule: Schedule<Out, In, S>) =>
    new Schedule<Out, In, S>(schedule.initial, (input, state) => {
      const [_state, _out, _decision] = schedule.step(input, state);

      if (_decision.tag === "Continue") {
        if (predicate(input, _out)) {
          return [_state, _out, _decision] as const;
        } else {
          return [_state, _out, { tag: "Done" }] as const; // Done
        }
      }

      return [_state, _out, _decision] as const;
    });
Enter fullscreen mode Exit fullscreen mode

By combining count and check, we can create recurs — a schedule that will run only a specified number of times.

const recurs = (n: number) => count
  .pipe(check((_, out) => out + 1 <= n));
Enter fullscreen mode Exit fullscreen mode

The intersect combinator takes two schedules and creates a new one by processing their results. It stops if either schedule decides to terminate. If both emit values, the smallest delay is chosen.

const intersect =
  <Out, In, S>(scheduleA: Schedule<Out, In, S>) =>
  <Out1, In1, S1>(scheduleB: Schedule<Out1, In1, S1>) =>
    new Schedule<[Out, Out1], In & In1, [S, S1]>(
      [scheduleA.initial, scheduleB.initial] as const,
      (input, [stateA, stateB]) => {
        const [_stateA, _outA, _decisionA] = scheduleA.step(input, stateA);
        const [_stateB, _outB, _decisionB] = scheduleB.step(input, stateB);

        let _decision: Decision = { tag: "Done" };

        if (_decisionA.tag === "Continue" && _decisionB.tag === "Continue") {
          _decision = {
            tag: "Continue",
            delay:
              _decisionA.delay !== 0
                ? _decisionB.delay !== 0
                  ? Math.min(_decisionA.delay, _decisionB.delay)
                  : _decisionA.delay
                : _decisionB.delay !== 0
                ? _decisionB.delay
                : 0,
          };
        }

        return [[_stateA, _stateB], [_outA, _outB], _decision] as const;
      },
    );
Enter fullscreen mode Exit fullscreen mode

Great! Now, let’s add time delays to schedules, which is one of the key features of scheduling systems. Actually, this function differs from map only in that it is focused on a different aspect of the step function - Decision.

const addDelay =
  <Out,>(f: (out: Out, timer: number) => number) =>
  <In, S>(schedule: Schedule<Out, In, S>) =>
    new Schedule<Out, In, S>(schedule.initial, (input, state) => {
      const [_state, _out, _decision] = schedule.step(input, state);

      if (_decision.tag === "Continue") {
        return [
          _state,
          _out,
          { tag: "Continue", delay: f(_out, _decision.delay) },
        ] as const;
      }

      return [_state, _out, _decision] as const;
    });
Enter fullscreen mode Exit fullscreen mode

Let's now put everything together

const delayedFibonacci = fibonacci
  .pipe(map((x) => x[1])) // get fibonacci element
  .addDelay(x => x * 1000) // and interpreter it as delay in seconds
const fibonacciScheduler = delayedFibonacci
  .pipe(intersect(recurs(5))) // take only 5 first steps
Enter fullscreen mode Exit fullscreen mode

Full code in playground - https://tsplay.dev/NVBolN

Description of schedule

Image description

Since a schedule is simply a description of timing behavior, swapping out the Driver changes how it behaves. We could, for example, ignore delays entirely or integrate this into libraries like RxJS.

The real power of Schedule is its composability. Even in this basic example, we’ve created a fairly sophisticated schedule from a simple unfold combined with mappings.

Instead of the conclusion

I enjoy digging into the inner workings of features because it deepens my understanding not just of specific technologies but also of transferable design patterns and problem-solving techniques. Writing this schedule from scratch wouldn't have been easy without exploring the internals — but now it's clear that everything revolves around a step function.

Top comments (0)