# Schedulers

> The Scheduler interface and the four built-in schedulers (Sync, Manual, Microtask, RAF): when each flushes, fallbacks, how to choose and write your own.

Source: https://dirtytalk.42.dog/docs/engine/schedulers/

A scheduler decides when a `DirtyChannel` flush runs. `@dirtytalk/engine` ships four, and the `Scheduler` interface is small enough to implement yourself.

## The Scheduler interface

```ts
interface Scheduler {
  request(flush: () => void): void;
  cancel?(flush: () => void): void;
}
```

- `request(flush)` asks for `flush` to be called later. A `DirtyChannel` passes the same function every time and calls `request` only when no flush is already pending, so a scheduler sees at most one outstanding request per channel.
- `cancel(flush)` is optional. `DirtyChannel.dispose()` calls it when a flush is pending. Without it, a disposed channel's flush still runs but does nothing.

One scheduler instance can serve many channels. Each channel's flush is a distinct function.

## Built-in schedulers

| Scheduler            | Flushes                                                 | Dedupes requests | Has `cancel` |
| -------------------- | ------------------------------------------------------- | ---------------- | ------------ |
| `SyncScheduler`      | Immediately, inside `request`                           | No               | No           |
| `ManualScheduler`    | When you call `pump()`                                  | Yes              | Yes          |
| `MicrotaskScheduler` | At the end of the current microtask queue               | Yes              | Yes          |
| `RAFScheduler`       | On the next animation frame, or after 16 ms without rAF | Yes              | Yes          |

All four take no constructor arguments and have no options.

### SyncScheduler

```ts
import { DirtyChannel, SyncScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;
const channel = new DirtyChannel(space, new SyncScheduler());
```

Calls `flush` synchronously from `request`. Every `mark` therefore notifies subscribers before it returns, and there is no coalescing across marks. Errors thrown by a flush propagate out of `mark`. Use it in tests and where synchronous delivery is required.

### ManualScheduler

```ts
import { DirtyChannel, ManualScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;
const scheduler = new ManualScheduler();
const channel = new DirtyChannel(space, scheduler);

channel.mark(1);
scheduler.pump();
```

Collects requests until `pump()` is called. `pump()` runs the flushes requested so far in request order; requests made while pumping wait for the next `pump()`. Calling `pump()` with nothing pending does nothing. Duplicate requests of the same function run once. If flushes throw, every flush still runs; then a single error is rethrown as-is, or several as an `AggregateError` (`ManualScheduler: flush errors during drain`).

Use it in tests, replay and server-side rendering, where you control the timing.

### MicrotaskScheduler

```ts
import { DirtyChannel, MicrotaskScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;
const channel = new DirtyChannel(space, new MicrotaskScheduler());
```

Queues one `queueMicrotask` per window and runs all pending flushes in it. A request made during the drain starts a new microtask. `cancel` removes the flush from the pending set; the queued microtask cannot be revoked and simply finds nothing to run. Errors from flushes are rethrown after all have run (`MicrotaskScheduler: flush errors during drain`), which surfaces as an uncaught exception in the microtask, so give channels an `onError`.

Use it for state containers, where updates made in the same tick should produce one notification. Requires a global `queueMicrotask`.

### RAFScheduler

```ts
import { DirtyChannel, RAFScheduler } from '@dirtytalk/engine';
import type { Space } from '@dirtytalk/engine';

declare const space: Space<number>;
const channel = new DirtyChannel(space, new RAFScheduler());
```

Calls `requestAnimationFrame` once per window and runs all pending flushes in it. Errors are rethrown after all flushes have run (`RAFScheduler: flush errors during drain`).

**Environment fallback:** whether `globalThis.requestAnimationFrame` exists is checked once, when the scheduler is constructed. If it is not a function (Node, workers without rAF), the scheduler uses `setTimeout(drain, 16)` and `clearTimeout` instead. Create the scheduler after any rAF polyfill is installed.

`cancel` removes the flush and, if nothing else is pending, cancels the frame or timer. Use it for rendering and other work that should run at most once per frame. Browsers pause animation frames in background tabs, so flushes are delayed there.

## Choosing a scheduler

| Need                                                          | Use                                             |
| ------------------------------------------------------------- | ----------------------------------------------- |
| Deterministic tests, explicit control, SSR                    | `ManualScheduler`                               |
| Simplest test setup; callbacks must run before `mark` returns | `SyncScheduler`                                 |
| Batch all marks from one synchronous task                     | `MicrotaskScheduler`                            |
| Repaint or DOM work at most once per frame                    | `RAFScheduler`                                  |
| Anything else (idle callback, timer, framework batch)         | [Custom scheduler](#writing-a-custom-scheduler) |

## Writing a custom scheduler

Implement `request`, and `cancel` if you can. Requirements:

- Call each requested `flush` function; a channel never requests again until its flush has run or been cancelled.
- Coalesce: keep requested functions in a `Set` so repeated requests of the same function run once.
- Do not call `flush` re-entrantly from inside another flush, unless you intend synchronous delivery as `SyncScheduler` does.
- Isolate failures: if one flush throws, still run the others, then rethrow.

```ts
import { DirtyChannel } from '@dirtytalk/engine';
import type { Scheduler, Space } from '@dirtytalk/engine';

class TimeoutScheduler implements Scheduler {
  #pending = new Set<() => void>();
  #timer: ReturnType<typeof setTimeout> | null = null;

  constructor(private readonly ms: number) {}

  request(flush: () => void): void {
    this.#pending.add(flush);
    this.#timer ??= setTimeout(() => {
      this.#timer = null;
      const fns = this.#pending;
      this.#pending = new Set();
      for (const fn of fns) fn();
    }, this.ms);
  }

  cancel(flush: () => void): void {
    this.#pending.delete(flush);
  }
}

declare const space: Space<number>;
const channel = new DirtyChannel(space, new TimeoutScheduler(50), {
  onError: console.error,
});
```

This sketch does not isolate errors between flushes; with `onError` set on every channel, a flush does not throw.
