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.
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
interface Scheduler {
request(flush: () => void): void;
cancel?(flush: () => void): void;
}
request(flush)asks forflushto be called later. ADirtyChannelpasses the same function every time and callsrequestonly 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
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
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
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
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
Implement request, and cancel if you can. Requirements:
- Call each requested
flushfunction; a channel never requests again until its flush has run or been cancelled. - Coalesce: keep requested functions in a
Setso repeated requests of the same function run once. - Do not call
flushre-entrantly from inside another flush, unless you intend synchronous delivery asSyncSchedulerdoes. - Isolate failures: if one flush throws, still run the others, then rethrow.
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.