[Blazor] await periodicTimer.WaitForNextTickAsync() – a good servant but a bad master

Beware of using await periodicTimer.WaitForNextTickAsync(). This method is appealing due to its asynchronous signature, making it easy to set up periodic tasks, which might tempt you to implement UI updates with it, especially in Blazor:

protected override async Task OnInitializedAsync()
{
    await StartTimerAsync();
}

private async Task StartTimerAsync()
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
    while (await timer.WaitForNextTickAsync())
    {
        // do some UI updates here
    }
}

Warning! While this approach doesn’t block the UI thread thanks to async-await, the issue is that the method calling such code never actually completes.

For instance, if StartTimerAsync() is called directly from OnInitializedAsync, OnParametersSetAsync, OnAfterRenderAsync, or an action callback, the parent method will never finish, leading to some unexpected consequences, like:

  • If called from an HxButton.OnClick button handler, the spinner remains stuck, never stopping.
    • The button also stays under single-click protection, disabled, and unusable.
  • If called from OnInitializedAsync, the first roundtrip won’t invoke OnParametersSet[Async], which won’t execute until a new roundtrip arrives.
  • Calling from OnParametersSetAsync leaves an unfinished task in ComponentBase.CallStateHasChangedOnAsyncCompletion() and requires handling to prevent multiple timers from starting, as OnParametersSetAsync is called repeatedly.
  • If called from OnAfterRenderAsync(bool firstRender), it could block the await base.OnAfterRenderAsync(firstRender) call, disrupting inherited functionality (especially crucial for firstRender = true, which only runs once).

So, PeriodicTimer.WaitForNextTickAsync() is more appropriate in scenarios where it’s safe for the calling code to continue indefinitely, such as in a Main method for a console application handling cyclical tasks or within BackgroundService.ExecuteAsync(). In general, however, the calling method should be allowed to complete. Instead, a traditional setup using Task.Run(..) is recommended, placing the timer (or even a regular Timer) on the ThreadPool without awaiting its completion in the current method (fire-and-forget). In Blazor, this requires manually invoking StateHasChanged() or possibly DispatchExceptionAsync().

Example:

public MyComponent : IDisposable
{
    private PeriodicTimer timer;

    protected override async Task OnInitializedAsync()
    {
        _ = Task.Run(StartTimerAsync);
    }

    private async Task StartTimerAsync()
    {
        timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
        while (await timer.WaitForNextTickAsync())
        {
            // do some UI updates here
            StateHasChanged(); // as needed
        }
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}

Don’t forget cleanup with timer.Dispose(), or the Timer will keep running even after the component is destroyed, leading to resource leaks.

See also the ASP.NET Core Blazor documentation:

ASP.NET Core Blazor synchronization context: Invoke component methods externally to update state

Leave a comment