Scheduler
The browser's answer to 'when should this run?' - a frame loop in step with the compositor, work that fills the gaps, and tasks that can say how urgent they are.
@inject Bit.Butil.Scheduler schedulerMDN reference
Support check
IsSupported / IsIdleCallbackSupported / IsPostTaskSupported / IsYieldSupported / IsInputPendingSupportedrequestAnimationFrame is universal. requestIdleCallback is nearly so. postTask, yield and isInputPending are newer and not everywhere - PostTask and Yield still work where they are missing, by falling back. During prerender/SSR every check returns false rather than throwing.
@inject Bit.Butil.Scheduler scheduler
var frames = await scheduler.IsSupported();
var idle = await scheduler.IsIdleCallbackSupported();
var postTask = await scheduler.IsPostTaskSupported();
var yields = await scheduler.IsYieldSupported();
var inputPending = await scheduler.IsInputPendingSupported();The next frame is requested before your callback is told about this one, so the loop runs at the browser's cadence rather than at the speed of the interop round trip. A callback slower than a frame causes frames to be skipped rather than queued - which is what an animation wants, and why the box below is positioned from the timestamp rather than from a frame count.
IAsyncDisposable
Bit.Butil.Scheduler scheduler
<div style="transform: translateX(@(_offset)px)">...</div>
{
private double _offset;
private ButilSubscription? _loop;
private async Task Start() =>
_loop = await scheduler.OnAnimationFrame(timestamp => InvokeAsync(() =>
{
// position from the timestamp, never from a count of calls
_offset = (Math.Sin(timestamp / 400) + 1) * 50;
StateHasChanged();
}));
// Stops the loop. A frame loop nobody disposes runs for as long as the page lives, whether or
// not anything is still watching it.
public async ValueTask DisposeAsync()
{
if (_loop is not null) await _loop.DisposeAsync();
}
}A single callback just before the next paint - for measuring an element after a change, or for batching a write so it lands with the frame rather than between two. The timestamp is the same value every callback in that frame receives, which is what keeps animations driven from different places in step.
await scheduler.RequestAnimationFrame(timestamp => InvokeAsync(() =>
{
// runs once, just before the next paint
}));Idle callbacks run when the browser has nothing better to do. TimeRemaining is a snapshot taken when the callback was dispatched, not a budget that updates - the real value falls as you use it, and 50ms is the ceiling the browser will ever report. Without a timeout, work scheduled on a page that is never idle never runs at all; with one, DidTimeout tells you it ran anyway on a busy page, which is the moment to do the least you can.
await scheduler.RequestIdleCallback(deadline => InvokeAsync(() =>
{
if (deadline.DidTimeout) { /* the page is busy - do the minimum */ }
else { /* up to deadline.TimeRemaining ms of slack */ }
}), timeout: TimeSpan.FromSeconds(2));Every setTimeout lands in one queue in the order it was scheduled. These land in three, and the browser drains them against its own rendering work: user-blocking ahead of rendering, background behind it. Posting all three at once shows the order the browser chooses rather than the order they were posted. A shared AbortSignal cancels one that has not run yet - the same signal that cancels your requests.
await scheduler.PostTask(() => Log("background"), SchedulerPriority.Background);
await scheduler.PostTask(() => Log("visible"), SchedulerPriority.UserVisible);
await scheduler.PostTask(() => Log("blocking"), SchedulerPriority.UserBlocking);
// cancellable, with the same signal that cancels a fetch
var handle = await abortController.Create();
var error = await scheduler.PostTask(work, delay: TimeSpan.FromSeconds(2), signal: handle!.Signal);A long loop in C# freezes the page as thoroughly as one in JavaScript. Yielding hands the thread back so the browser can paint or handle a click, then continues. IsInputPending is the cheaper question: keep working while nothing is waiting, and yield the moment something is. It returns false where the API is missing, which reads as 'nothing is waiting' on purpose - the safe answer is to keep working and yield on a schedule instead.
for (var i = 0; i < 200; i++)
{
DoAChunkOfWork(i);
// yield when the user is waiting, or every so often regardless
if (await scheduler.IsInputPending() || i % 20 == 0)
await scheduler.Yield();
}API reference
ValueTask<bool> IsSupported()ValueTask<bool> ...()ValueTask<ButilSubscription> RequestAnimationFrame(Action<double> onFrame)ValueTask<ButilSubscription> OnAnimationFrame(Action<double> onFrame)ValueTask<ButilSubscription?> RequestIdleCallback(Action<IdleDeadline> onIdle, TimeSpan? timeout = null)ValueTask<string?> PostTask(Action work, SchedulerPriority priority = SchedulerPriority.UserVisible, TimeSpan? delay = null, ButilAbortSignal? signal = null)ValueTask Yield()ValueTask<bool> IsInputPending()ValueTask DisposeAsync()record (bool DidTimeout, double TimeRemaining)enum { UserBlocking, UserVisible, Background }