loading
Warning:
A frame loop is not a timer Frames stop entirely while the tab is in the background, and their spacing follows the display - 16.7ms at 60Hz, 8.3ms at 120Hz. Drive animation from the timestamp you are handed, never from a count of frames, or the same code runs at half speed on one machine and double on another.

Support check

IsSupported / IsIdleCallbackSupported / IsPostTaskSupported / IsYieldSupported / IsInputPendingSupported

requestAnimationFrame 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.

C#
@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();
Live sample
support check output
Results will appear here when you interact with the samples.

A frame loop

OnAnimationFrame

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.

Razor
@implements IAsyncDisposable
@inject Bit.Butil.Scheduler scheduler

<div style="transform: translateX(@(_offset)px)">...</div>

@code {
    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();
    }
}
Live sample
Frames 0 rendered, roughly 0 per second
frame loop output
Results will appear here when you interact with the samples.

One frame, once

RequestAnimationFrame

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.

C#
await scheduler.RequestAnimationFrame(timestamp => InvokeAsync(() =>
{
    // runs once, just before the next paint
}));
Live sample
single frame output
Results will appear here when you interact with the samples.

Work that fills the gaps

RequestIdleCallback / IdleDeadline

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.

C#
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));
Live sample
idle output
Results will appear here when you interact with the samples.

Tasks that say how urgent they are

PostTask / SchedulerPriority

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.

C#
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);
Live sample
task output
Results will appear here when you interact with the samples.

Letting the page breathe

Yield / IsInputPending

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.

C#
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();
}
Live sample
yield output
Results will appear here when you interact with the samples.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes requestAnimationFrame. Returns default (false) during prerender/SSR instead of throwing.
IsIdleCallbackSupported / IsPostTaskSupported / IsYieldSupported / IsInputPendingSupported
ValueTask<bool> ...()
The per-feature checks. PostTask and Yield still work where theirs are false, by falling back to a timeout and a macrotask.
RequestAnimationFrame
ValueTask<ButilSubscription> RequestAnimationFrame(Action<double> onFrame)
Runs once just before the next paint, with the frame's timestamp. Disposing before it arrives cancels it.
OnAnimationFrame
ValueTask<ButilSubscription> OnAnimationFrame(Action<double> onFrame)
Runs every frame until disposed. A callback slower than a frame causes frames to be skipped rather than queued.
RequestIdleCallback
ValueTask<ButilSubscription?> RequestIdleCallback(Action<IdleDeadline> onIdle, TimeSpan? timeout = null)
Runs once when the browser has time to spare. Null where requestIdleCallback is missing. Re-request from inside the callback to keep going.
PostTask
ValueTask<string?> PostTask(Action work, SchedulerPriority priority = SchedulerPriority.UserVisible, TimeSpan? delay = null, ButilAbortSignal? signal = null)
Posts to the browser's scheduler and completes when it has run. Null on success, or the reason it did not run.
Yield
ValueTask Yield()
Hands the thread back so the browser can paint or handle input, then continues.
IsInputPending
ValueTask<bool> IsInputPending()
Whether user input is waiting behind your work. False where the API is missing.
DisposeAsync
ValueTask DisposeAsync()
On scope/circuit teardown, cancels every frame loop and idle callback whose subscription was never disposed.
IdleDeadline
record (bool DidTimeout, double TimeRemaining)
TimeRemaining is a snapshot in milliseconds, not a budget that updates. DidTimeout means the page never went idle.
SchedulerPriority
enum { UserBlocking, UserVisible, Background }
Ahead of rendering, with it, or behind it. Ignored by the timeout fallback, which has one queue and no priorities.
An unhandled error has occurred. Reload 🗙