Kristoffer Strube’s Blog

.NET, Blazor, and much more!

Cancelling long-running JSInterop calls

April 29, 2024
Cancelling long-running JSInterop calls

When making JSInterop calls in Blazor, the invocations can vary in duration depending on what function is invoked. If these invocations take a long time, we want to be able to cancel them if we no longer need to finish the task, i.e., if the user navigates away from the current page or actively cancels some action. It is possible to use the CancellationToken that we are all familiar with from async tasks in .NET, but this only makes it so that deserialization of the response is not handled if the invocation has already started. In this article, we will show how to cancel a JSInterop invocation in the middle of its invocation using the AbortController and AbortSignal types and present how the Blazor.DOM library makes this easy.


Parsing a CancellationToken to invocations

As developers who are used to using async-await, we will try to see if an async method takes a CancellationToken when we want the ability to cancel tasks. We see that the async invocation tasks for JSInterop actually take a CancellationToken parameter. But this won't work. Let's check the source code and see what the CancellationToken is used for. First, we check the parameter description for cancellationToken.

<param name="cancellationToken">
A cancellation token to signal the cancellation of the operation. Specifying this parameter will override any default cancellations such as due to timeouts
(<see cref="DefaultAsyncTimeout"/>) from being applied.
</param>

So, from the description, it seems like this makes it possible to cancel the invocation. But to go even deeper down the rabbit hole, let's see what it is used for. First, if the CancellationToken can be canceled, and this happens, it tries to set the state of the resulting task to canceled and cleans up the task and cancellation callback registration.

if (cancellationToken.CanBeCanceled)
{
    _cancellationRegistrations[taskId] = cancellationToken.Register(() =>
    {
        tcs.TrySetCanceled(cancellationToken);
        CleanupTasksAndRegistrations(taskId);
    });
}

Then it does the same if the CancellationToken had already been cancelled so that we don't need to start the invocation if it was immediately canceled.

if (cancellationToken.IsCancellationRequested)
{
    tcs.TrySetCanceled(cancellationToken);
    CleanupTasksAndRegistrations(taskId);

    return new ValueTask<TValue>(tcs.Task);
}

Then, it starts the invocation, which means we will have no more opportunities for the task to be stopped if a cancellation is requested before the JS function is done and calls back. If the source of the CancellationToken was canceled while the invocation happened, then the entire JS function will finish first. Then, when the method that receives the response is invoked, it checks whether the task callback has already been removed and exits early in that case.

if (!_pendingTasks.TryRemove(taskId, out var tcs))
{
    // We should simply return if we can't find an id for the invocation.
    // This likely means that the method that initiated the call defined a timeout and stopped waiting.
    return false;
}

This is great as we spared the work of deserializing the response, which could have been expensive, as we have seen in our previous post on Blazor performance. But this still doesn't enable us to cancel JS functions while they are running.

Cancelling using the AbortController and AbortSignal

To support canceling a JS function, we need to parse something into it that it can use to check whether it should be stopped early. JS has a standard equivalent to the CancellationToken and CancellationTokenSource types: the AbortSignal and AbortController types. If we create an AbortController, we can get its AbortSignal. Using the AbortController, we can then abort the operation, and using the AbortSignal, we can check whether the operation has been canceled. The introduction of this has a similar history to that of the CancellationToken as it was not introduced when JS first got support for the async-await pattern. It was instead introduced later when we discovered that some operations would be nice to be able to abort. This also means that it is sadly not used in all APIs, as some APIs were standardized before its introduction. However, a lot of new APIs utilize it to cancel asynchronous operations.

One API that utilizes the AbortSignal is the Fetch API. The fetch API standardizes ways to fetch resources in JavaScript. In Blazor WASM, the HttpClient is implemented so that it uses this API to make its requests, but we can also use the API directly without this abstraction. In JS, we can write the following to make a simple GET request:

async function getCharacterInfo(characterNumber) {
    let response = await fetch("https://api.sampleapis.com/futurama/characters/" + characterNumber);
    let json = response.json();
    return json;
}

The above sample URL returns some information about a character from Futurama and is actually pretty fast, but we could imagine that it was a slow endpoint and that we would like to abort the fetch request mid-way in some cases. To do this we simple need to specify some options for the optional init parameter for the fetch method.

async function getCharacterInfo(characterNumber) {
    let abortController = new AbortController();
    let abortSignal = abortController.signal;

    let requestInit = {
        "signal": abortSignal
    };

    let response = await fetch("https://api.sampleapis.com/futurama/characters/" + characterNumber, requestInit);
    let json = response.json();
    return json;
}

In principle, the above is all we need. But currently, we cannot tell the AbortController that the action should be canceled as it is not exposed anywhere outside the function. There are multiple ways to achieve this. One way is to save the abort controllers in a map where the key is defined by some extra parameter like this:

window.abortControllers = {};

async function getCharacterInfo(characterNumber, abortControllerKey) {
    let abortController = new AbortController();
    window.abortControllers[abortControllerKey] = abortController;
    let abortSignal = abortController.signal;

    let requestInit = {
        "signal": abortSignal
    };

    let response = await fetch("https://api.sampleapis.com/futurama/characters/" + characterNumber, requestInit);
    let json = response.json();
    return json;
}

function abortFetch(abortControllerKey) {
    window.abortControllers[abortControllerKey].abort();
}

The above makes it possible to abort a fetch using some abortControllerKey to identify the operation that should be canceled. But this requires us to remember this arbitrary key and ensure that the key is unique. This also pollutes the global window with the abortControllers attribute that could potentially collide with attributes used by other libraries. One last problem with this solution is that the AbortControllers would never be garbage collected as they continue to be referenced in the abortControllers map. To solve that problem, we would need to make a separate third call to remove the reference to the AbortController once the operation is finished.

Let's instead use another approach that mitigates these problems. Instead of keeping the AbortController in a map, we could construct the AbortController outside the JS function and then parse it in as an IJSObjectReference. With this approach, our JS functions would look like this:

async function getCharacterInfo(characterNumber, abortController) {
    let abortSignal = abortController.signal;

    let requestInit = {
        "signal": abortSignal
    };

    let response = await fetch("https://api.sampleapis.com/futurama/characters/" + characterNumber, requestInit);
    let json = response.json();
    return json;
}

function createAbortController() {
    return new AbortController();
}

This could then be used from Blazor like this:

public partial class Index : IDisposable
{
    private string age = "";
    private int characterNumber = 1;
    private CancellationTokenSource? fetchCancellationTokenSource;

    [Inject]
    public required IJSRuntime JSRuntime { get; set; }

    public async Task UpdateCharacterAge()
    {
        age = "";

        // Get CancellationToken.
        fetchCancellationTokenSource = new CancellationTokenSource();
        CancellationToken token = fetchCancellationTokenSource.Token;

        // Create AbortController
        await using IJSObjectReference abortController = await JSRuntime.InvokeAsync<IJSObjectReference>("createAbortController");

        // Register that it should abort when the CancellationToken is canceled.
        token.Register(async () =>
        {
            await abortController.InvokeVoidAsync("abort");
        });

        // Make JS invocation.
        Character character = await JSRuntime.InvokeAsync<Character>("getCharacterInfo", token, characterNumber, abortController);

        // Reset the fetchCancellationTokenSource and set result.
        fetchCancellationTokenSource = null;
        age = character.Age;
    }

    public record Character(string Occupation, string Age);

    public void Dispose()
    {
        fetchCancellationTokenSource?.Cancel();
    }
}

This might seem like a lot of C# code, but I promise that it is less code than what would have been needed for the JS map-based solution that we saw the JS code for earlier. We use the AbortController and the CancellationTokenSource we discussed previously. They work nicely together. The AbortController ensures that we can exit the JS function early. The CancellationTokenSource ensures we don't use time to deserialize the AbortError that gets thrown when the JS function is aborted.

Using Blazor.DOM

In the above solution, we needed to write a function to construct an AbortController, and we needed to know that it had a function called abort used for aborting the AbortController. Another thing to point out is that we parsed the AbortController itself to the getCharacterInfo function when it really only needed the AbortSignal.

I've implemented wrappers for the relevant types used in the above sample in my library Blazor.DOM. So let's try to rewrite the above sample to be a bit more strongly typed and minimal. Let's first make some changes to the JS part.

async function getCharacterInfo(characterNumber, abortSignal) {
    let requestInit = {
        "signal": abortSignal
    };

    let response = await fetch("https://api.sampleapis.com/futurama/characters/" + characterNumber, requestInit);
    let json = response.json();
    return json;
}

As you can see, we now need a lot less JS. We like this. The main difference is that getCharacterInfo takes the abortSignal directly instead of the abortController. This is nice as we really shouldn't be able to access the controller in the function as we could then potentially abort the controller from within the function, giving unwanted side effects or errors in our C# code. Apart from this, we have also removed the method that constructed an AbortController as we will no longer need to call this. Now, let's see the C# part.

public partial class Index : IDisposable
{
    private string age = "";
    private int characterNumber = 1;
    private CancellationTokenSource? fetchCancellationTokenSource;

    [Inject]
    public required IJSRuntime JSRuntime { get; set; }

    public async Task UpdateCharacterAge()
    {
        age = "";

        // Get CancellationToken.
        fetchCancellationTokenSource = new CancellationTokenSource();
        CancellationToken token = fetchCancellationTokenSource.Token;

        // Create AbortController and AbortSignal
        await using AbortController abortController = await AbortController.CreateAsync(JSRuntime);
        await using AbortSignal abortSignal = await abortController.GetSignalAsync();

        // Register that it should abort when the CancellationToken is canceled.
        token.Register(async () =>
        {
            await abortController.AbortAsync();
        });

        // Make JS invocation.
        Character character = await JSRuntime.InvokeAsync<Character>("getCharacterInfo", token, characterNumber, abortSignal);

        // Reset the fetchCancellationTokenSource and set result.
        fetchCancellationTokenSource = null;
        age = character.Age;
    }

    public record Character(string Occupation, string Age);

    public void Dispose()
    {
        fetchCancellationTokenSource?.Cancel();
    }
}

It has not changed much, apart from the fact that we now use strongly-typed types from the KristofferStrube.Blazor.DOM NuGet package.

Other JS functions that can be aborted

We have now seen one sample of a JS function that can be aborted and understand how we can parse an AbortSignal to it to have the option to stop the JS function before it finishes. We also understand how to create this AbortSignal in C# and signal it to abort. So, to better understand how we can use this, let's see some other samples of JS functions to cancel.

Infinite loop

async function infiniteLoop(abortSignal) {
    while(!abortSignal.aborted) {
        console.log("Do some actual work forever in 1 second intervals.");
        await new Promise(r => setTimeout(r, 1000));
    }
}

If we have some infinite loop that ensures that some function is called forever with some interval, we also need to be able to stop the loop. For this, we can use that the AbortSignal has an attribute that tells us whether the signal has been aborted.

Piping ReadableStream

function compressReadableStream(readableStream, abortSignal) {
    let compressionStream = new CompressionStream("gzip");
    let compressed = readableStream.pipeThrough(compressionStream, { "signal": abortSignal });
    return compressed;
}

Another API that incorporates the AbortSignal is the Streams API. When we pipe some readable stream to a writable stream or through a transformer, we can parse options to the function, enabling us to abort it. This is a good use case as a ReadableStream could potentially be large or even infinite.

Conclusion

In this article, we explored the source code of JSInterop to see what it does when we parse a CancellationToken to it. We introduced the AbortController and AbortSignal types and used them together with the Fetch API to cancel a potentially long-lived operation. Then we saw how we can use the Blazor.DOM library to make some of this easier and simpler. In the end, we quickly took a look at some other scenarios where we could use AbortSignals. If you have any questions related to the article or use cases I did not cover, feel free to reach out to me.