Skip to content

@absoluteui/services/scheduler

Idle and frame scheduling, chunked work that keeps the page responsive, and countdowns.

import { ... } from '@absoluteui/services/scheduler';

Keeps the main thread responsive while your code does real work: split long loops into time-sliced chunks, yield to input and rendering, run low-priority work when the browser is idle, and use timers that do not drift or overlap. Reach for it when a large import, a report calculation, or a list render makes the page freeze, or when you poll, count down a session, or animate. Every function falls back to plain timers on the server.

How do I process a large list without freezing the page?

Section titled “How do I process a large list without freezing the page?”

runChunked maps items in slices of about budgetMs (default 8 ms) and yields between slices, so clicks and typing are handled promptly. Results keep their order; pass a signal to stop between items.

import { runChunked } from '@absoluteui/services/scheduler';
const employees = Array.from({ length: 20_000 }, (_, i) => ({ id: i + 1, salary: 50_000 + (i % 40) * 1_000 }));
const controller = new AbortController();
const withBonus = await runChunked(employees, (e) => ({ ...e, bonus: Math.round(e.salary * 0.08) }), { budgetMs: 8, signal: controller.signal });
withBonus.length; // => 20000
withBonus[0]; // => { id: 1, salary: 50000, bonus: 4000 }

Call await yieldToMain() every so often. It uses scheduler.yield() where available, so your work resumes before other queued tasks.

import { yieldToMain } from '@absoluteui/services/scheduler';
const list = document.createElement('ul');
const names = Array.from({ length: 2_000 }, (_, i) => `Employee ${String(i + 1)}`);
let last = performance.now();
for (const name of names) {
list.append(Object.assign(document.createElement('li'), { textContent: name }));
if (performance.now() - last > 8) {
await yieldToMain();
last = performance.now();
}
}
list.childElementCount; // => 2000

For thousands of rows, prefer a virtualized grid over rendering every row.

createTaskScheduler runs tasks one after another and yields whenever the budget is spent. Each schedule call resolves with that task’s result.

import { createTaskScheduler } from '@absoluteui/services/scheduler';
const tasks = createTaskScheduler({ budgetMs: 8 });
const widgets = ['headcount', 'attrition', 'open-positions'];
const hydrated = await Promise.all(widgets.map((w) => tasks.schedule(() => `${w} ready`)));
hydrated; // => ['headcount ready', 'attrition ready', 'open-positions ready']
tasks.pending; // => 0

How do I run work when the browser is idle?

Section titled “How do I run work when the browser is idle?”

scheduleIdle suits analytics, prefetching, or cleaning caches. Check timeRemaining() and stop when it runs low; the timeout guarantees the work runs eventually.

import { scheduleIdle } from '@absoluteui/services/scheduler';
const auditEvents = ['viewed:42', 'viewed:43', 'exported:payroll'];
const sent: string[] = [];
const handle = scheduleIdle((deadline) => {
while (deadline.timeRemaining() > 1 && auditEvents.length > 0) sent.push(auditEvents.shift() ?? '');
}, { timeout: 2_000 });
// handle.cancel() if the page is torn down first

How do I poll every 30 seconds without overlapping requests?

Section titled “How do I poll every 30 seconds without overlapping requests?”

interval stays aligned to the start time, never starts a tick while an async callback is still running, and can pause while the tab is hidden.

import { interval } from '@absoluteui/services/scheduler';
const controller = new AbortController();
interval(async () => {
const r = await fetch('/api/approvals/pending', { signal: controller.signal });
if (r.ok) console.info('Pending approvals', await r.json());
}, 30_000, { immediate: true, pauseWhenHidden: true, signal: controller.signal, onError: (e) => { console.warn(e); } });
// controller.abort() stops polling when the user leaves the page

countdown counts to an absolute deadline and is recomputed from the clock on every tick, so throttled background tabs do not drift. reset extends it when the user chooses to stay signed in (WCAG 2.2.1).

import { countdown } from '@absoluteui/services/scheduler';
const warning = document.createElement('p');
warning.setAttribute('role', 'status');
const session = countdown(Date.now() + 15 * 60_000, (ms) => {
if (ms <= 120_000 && ms % 60_000 < 1_000) warning.textContent = `You will be signed out in ${String(Math.ceil(ms / 60_000))} minutes.`;
}, { onComplete: () => { location.assign('/signed-out'); } });
const stay = document.createElement('button');
stay.textContent = 'Stay signed in';
stay.addEventListener('click', () => { session.reset(Date.now() + 15 * 60_000); });
  • Chunking keeps the page responsive but does not make work faster; move heavy CPU work (large parses, crypto on big files) to a Web Worker.
  • Invalid timings throw: budgetMs must be positive (SVC_SCHEDULER_INVALID_BUDGET) and interval periods at least 1 ms (SVC_SCHEDULER_INVALID_MS).
import { interval, runChunked } from '@absoluteui/services/scheduler';
await runChunked([1, 2, 3], (n) => n * 2, { budgetMs: 0 }); // => throws SVC_SCHEDULER_INVALID_BUDGET
interval(() => undefined, 0); // => throws SVC_SCHEDULER_INVALID_MS

countdown

Counts down to an absolute deadline (epoch ms), calling onTick(remainingMs) aligned to intervalMs. Computed from the clock on every tick, so throttled background timers never make it drift. Supports WCAG 2.2.1 Timing Adjustable: warn before a session expires and let users extend it via reset. Announce the remaining time sparingly (for example each minute) in a polite live region.

function countdown(deadline: number, onTick: (remainingMs: number) => void, options?: CountdownOptions): CountdownHandle;

Example:

const sessionExpiresAt = Date.now() + 15 * 60_000;
const warning = document.createElement('p');
const c = countdown(sessionExpiresAt, (ms) => { if (ms <= 120_000) warning.textContent = `Signing out in ${Math.ceil(ms / 1000)} s`; }, {
onComplete: () => location.assign('/signed-out'),
});
const stay = document.createElement('button');
stay.onclick = () => c.reset(Date.now() + 15 * 60_000);

createTaskScheduler

Serial queue of small main-thread tasks that yields to input and rendering when the time budget is spent.

function createTaskScheduler(options?: {
readonly budgetMs?: number;
}): TaskScheduler;

Example:

const tasks = createTaskScheduler({ budgetMs: 8 });
const widgets = [{ hydrate: () => 'chart' }, { hydrate: () => 'grid' }];
for (const w of widgets) void tasks.schedule(() => w.hydrate());

interval

Drift-corrected repeating timer: ticks stay aligned to start + n x ms instead of accumulating setTimeout lateness. Async callbacks never overlap (missed ticks are skipped).

function interval(fn: () => unknown, ms: number, options?: IntervalOptions): TaskHandle;

Example:

const controller = new AbortController();
const h = interval(() => fetch('/api/notifications'), 30_000, { pauseWhenHidden: true, signal: controller.signal });
controller.abort(); // stops polling

runChunked

Processes items in time-sliced chunks, yielding to the main thread whenever budgetMs is used up, so large loops do not cause long tasks (better INP). Resolves the mapped results in order.

function runChunked<T, R>(items: Iterable<T>, fn: (item: T, index: number) => R, options?: RunChunkedOptions): Promise<R[]>;

Example:

const records = [{ id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }];
const rows = await runChunked(records, (r) => `${r.id}: ${r.name}`, { budgetMs: 8 });
rows; // => ['1: Ada', '2: Grace']

scheduleFrame

Runs fn before the next paint (requestAnimationFrame), or after about 16 ms where unsupported.

function scheduleFrame(fn: (time: number) => void): TaskHandle;

Example:

const panel = document.createElement('div');
scheduleFrame(() => panel.classList.add('open'));

scheduleIdle

Runs fn when the browser is idle (requestIdleCallback), or after a short timeout where unsupported.

function scheduleIdle(fn: (deadline: IdleDeadlineLike) => void, options?: {
readonly timeout?: number;
}): TaskHandle;

Example:

const queue = ['audit-1', 'audit-2', 'audit-3'];
const sent: string[] = [];
scheduleIdle((d) => { while (d.timeRemaining() > 1 && queue.length > 0) sent.push(queue.shift() ?? ''); }, { timeout: 2000 });

yieldToMain

Yields to the event loop so input and rendering can run: scheduler.yield() when present (keeps priority), else a MessageChannel macrotask, else setTimeout(0).

function yieldToMain(): Promise<void>;

Example:

const rows = Array.from({ length: 1000 }, (_, i) => `Row ${i + 1}`);
const list = document.createElement('ul');
for (const [i, row] of rows.entries()) {
list.append(Object.assign(document.createElement('li'), { textContent: row }));
if ((i + 1) % 100 === 0) await yieldToMain();
}

CountdownHandle

Handle returned by {@link countdown}.

interface CountdownHandle extends TaskHandle {
readonly remaining: number;
reset(deadline: number): void;
}

CountdownOptions

Options for {@link countdown}.

interface CountdownOptions {
readonly signal?: AbortSignal;
readonly intervalMs?: number;
readonly onComplete?: () => void;
readonly now?: () => number;
}

IdleDeadlineLike

Deadline passed to idle callbacks.

interface IdleDeadlineLike {
readonly didTimeout: boolean;
timeRemaining(): number;
}

IntervalOptions

Options for {@link interval}.

interface IntervalOptions {
readonly signal?: AbortSignal;
readonly immediate?: boolean;
readonly pauseWhenHidden?: boolean;
readonly onError?: (error: unknown) => void;
}

RunChunkedOptions

Options for {@link runChunked}.

interface RunChunkedOptions {
readonly budgetMs?: number;
readonly signal?: AbortSignal;
}

TaskHandle

Cancellable scheduled callback.

interface TaskHandle {
cancel(): void;
}

TaskScheduler

Scheduler returned by {@link createTaskScheduler}.

interface TaskScheduler {
schedule<R>(task: () => R | Promise<R>): Promise<Awaited<R>>;
runChunked<T, R>(items: Iterable<T>, fn: (item: T, index: number) => R, options?: {
readonly signal?: AbortSignal;
}): Promise<R[]>;
readonly pending: number;
clear(): void;
}