Cooperative SchedulerLoading saved progress…

Cooperative Scheduler

A cooperative scheduler runs a queue of tasks in short time slices, handing control back to the event loop between slices so a long batch of work never blocks the main thread. It is the idea behind the browser's native scheduler.postTask and React's concurrent rendering: do a little work, let the page breathe, then pick up where you left off. You will build a small version of it that accepts tasks one at a time and hands each caller a promise for that task's result.

Signature

function cooperativeScheduler(options?: { budgetMs?: number }): {
  // Enqueue fn. It runs asynchronously (never inline) in FIFO order.
  // Returns a Promise that fulfils with fn()'s return value, or
  // rejects with whatever fn threw.
  postTask<T>(fn: () => T): Promise<T>;
  // How many tasks are still waiting to run.
  size(): number;
};

Examples

const { postTask, size } = cooperativeScheduler();

let ran = false;
const p = postTask(() => { ran = true; return 'done'; });

console.log(ran);       // false — postTask never runs fn inline
console.log(size());    // 1 — the task is queued, waiting for a later tick
console.log(await p);   // 'done' — settles once the slice runs the task
console.log(ran);       // true
const s = cooperativeScheduler({ budgetMs: 5 });
const order = [];

const a = s.postTask(() => { order.push('a'); return 1; });
const b = s.postTask(() => { order.push('b'); throw new Error('boom'); });
const c = s.postTask(() => { order.push('c'); return 3; });

await a;                          // 1
await b.catch((e) => e.message); // 'boom' — only b's promise rejects
await c;                          // 3 — b throwing did not skip c
console.log(order);              // ['a', 'b', 'c'] — FIFO, all three ran

Notes

  • Asynchronous, alwayspostTask must not call fn during the postTask call itself. The task runs on a later tick, scheduled with setTimeout(fn, 0).
  • FIFO — tasks run in the order they were posted, even when the drain spans several slices.
  • One slice, then yield — run tasks back to back while the elapsed time in the current slice stays under budgetMs (default 5); once it reaches the budget, hand control back to the event loop and resume in the next slice.
  • Errors are isolated — if a task throws, reject only that task's promise. Every other task in the queue still runs.
  • Portability — schedule the next slice with setTimeout(fn, 0) and measure elapsed time with Date.now(), so the scheduler works in Node, jsdom, and the sandbox. Faster primitives exist in a real browser (see the solution).
  • Out of scope — no priorities, no cancellation, no clear(). Just FIFO draining in time slices.

FAQ

Why schedule the next slice with setTimeout(fn, 0) instead of running the tasks directly?
setTimeout returns control to the event loop between slices, so the browser can paint and handle input; running the whole queue inline would block the main thread until every task finished.
Why check the time budget after a task runs instead of before?
Checking after each task guarantees at least one task runs per slice, so a single task that is longer than the budget still completes rather than the queue wedging forever.
What happens if one task throws?
Only that task's promise rejects. The scheduler catches the error, moves on, and every other task in the queue still runs and settles normally.
Loading editor…