Finally! Cancel Web Workers' Work Without Terminating the Worker

José Pablo Ramírez VargasJosé Pablo Ramírez Vargas
Aug 26, 2024·
9 min read

If you have never worked with web workers, the title doesn't really evoke emotion. Am I right? But if you have, you know that as of today, there is no equivalent to what AbortController does for fetch. Well, let's change that.

Meet @wjfe/async-workers, my most recent NPM package for front-end development. This package's mission is twofold:

  1. To bring the asynchronous syntax to web workers (async/await).

  2. To provide a solution to the long-standing issue of telling a worker to stop working without terminating it.

Why terminating a worker is not desirable? Well, several reasons exist, but the #1 and most practical reason is data loss. It is a common practice to "initialize" a worker's state by passing data to it. If a worker is terminated, a new worker needs to be initialized, again.

Let's see if I can interest you in using this package.

Motivation

If you don't care about this, skip this section and go straight to the package features.

Workers are basically separate threads that are spawned to run a pre-defined set of statements. JavaScript disallows all forms of communication between the UI thread and the worker thread except for one: Asynchronous messaging.

The UI thread may send messages to the worker thread and vice versa. In turn, each of these threads may receive and process messages every time the thread becomes idle so the browser engine (usually V8) goes to its message pump and pumps the code that picks up on messages. All this is automatic.

While not a terrible thing, programming under the messaging model partitions code. I personally thought this to be a drag. I searched the NPM package world and I did find some packages that provide async/await syntax that masks this messaging system, but in the end I did not like enough any of the implementations I saw.

Moreover, I really wanted to explore how to cancel web worker work from the UI thread. There's nothing out there. The best one can do (without using my package) is to program the worker code as a module and use asynchronous programming to await at some point (the cancellation checkpoint).

Awaiting puts the continuation of the code at the end of the JavaScript engine's message queue (behind the task that receives messages, if its promise has resolved). This means that, upon awaiting the cancellation checkpoint, there's an opportunity to pick up any messages the UI thread may have sent, including a potential cancellation message.

One would react to a cancellation message by saving the intent to cancel to a module-level variable. Once the worker resumes (after the cancellation checkpoint), the module-level variable is checked. If the variable is set, we cancel the work, otherwise we continue working.

Let's see this in code:

// Cancellation flag.
let cancelWork = false;

function yield() {
    return new Promise((rs) => setTimeout(() => rs(), 0));
}

aync function isPrime(n: number) {
    for (let i = 2; i <= n / 2; ++i)) {
        await yield();
        if (cancelWork) {
            cancelWork = false;
            // Easier to throw a specialized error.  Simpler to check for.
            throw new WorkerCancelledError();
        }
        if (n % i === 0) {
            return false;
        }
    }
    return true;
}

self.onmessage = async (ev: MessageEvent) => {
    if (ev.data.cancel) {
        cancelWork = true;
    }
    else {
        try {
            const itIs = await isPrime(ev.data.testValue);
            self.postMessage({ testValue: ev.data.testValue, itIs });
        }
        catch (error) {
            if (error instanceof WorkerCancelledError) {
                self.postMessage({ cancelled: true });
            }
        }
    }
};

This should work just fine, but let's re-write without the cancellation code, just to determine the boilerplate's weight that had to be added:

function isPrime(n: number) {
    for (let i = 2; i <= n / 2; ++i)) {
        if (n % i === 0) {
            return false;
        }
    }
    return true;
}

self.onmessage = (ev: MessageEvent) => {
    const itIs = isPrime(ev.data.testValue);
    self.postMessage({ testValue: ev.data.testValue, itIs });
};

Damn. From 12 lines to 33 lines, which is 3 lines shy from a whooping 200% increase in lines of code, and this doesn't show the code for the WorkerCancelledError class.

This is, as far as I can tell, the only way to allow on-demand cancellation of a web worker's current execution without terminating the web worker. Am I wrong? Hit me in the Comments section.

The other motivation for creating my own NPM package was the fact that I couldn't find an async/await implementation that really spoke to me.

Package Features

After several weeks of investigation, testing and playing around, I came up with @wjfe/async-workers. This package provides the following:

  • Asynchronous syntax (async/await). The package does the messaging boilerplate for you and delivers an object that mainly provides a promise that rejects if the worker's work throws, or resolves if the worker's work succeeds.

  • Smart queueing. This is something that you wouldn't notice unless you program the worker asynchronously. If you post messages to a synchronous worker, messages are processed one after the other, in sequence, without the messages ever interrupting work from previous messages. However, an asynchronous worker would pick up other messages the second it awaits for something. Smart queueing ensures messages are posted one at a time and only after the previous task has completed.

  • Out-of-order tasks. This is how one can bypass smart queueing. If you've been paying attention, smart queueing defeats asynchronous cancellation or any other form of send-data-while-running pattern. Out-of-order tasks skip the queue.

  • CancellationSource. This is the equivalent of AbortController. An instance of CancellationSource provides a cancellation token that is sent to the worker as part of the payload. This cancellation source can signal the token without the need of posting messages. CancellationSource can cancel synchronous workers.

  • Synchronization objects. Besides CancellationSource, the package provides ManualResetEvent, AutoResetEvent, and coming soon, Semaphore.

CancellationSource

So I lured you all for this: Cancelling a web worker's work-in-progress without having to terminate the worker. How does one write such code? Let's re-write the example worker above using my NPM package:

import { CancellationSource, workerListener, type PostFn, type Token } from "@wjfe/async-workers";

function _isPrime(n: number, cancelToken?: Token) {
    for (let i = 2; i <= n / 2; ++i)) {
        // Seems that the past tense of "signal" is "signaled", one L.
        // Expect this mispelling to be corrected soon.
        CancellationSource.throwIfSignalled(cancelToken);
        if (n % i === 0) {
            return false;
        }
    }
    return true;
}

export const primeWorkerTasks = {
    isPrime(tesValue: number, _post: PostFn, cancelToken?: Token) {
        return _isPrime(testValue, cancelToken);
    }
};

// For variable typing.
export type PrimeWorkerTasks = typeof primeWorkerTasks;

self.onmessage = workerListener(primeWorkerTasks);

This is 17 lines of code. This covers all the usual boilerplate associated to:

  • Message handling.

  • Throwing an exception if the token has been signaled.

  • Catching the thrown exception.

  • Rejecting the promise on the UI thread using a CancelledMessage object.

This, however, requires that the UI thread side be programmed using the NPM package as well. This is shown in the next section.

But I suppose the question that have in mind is: How is this form of cancellation possible? The answer is simple and not-so-simple: CancellationSource and the other synchronization objects use Atomics on SharedArrayBuffer objects. If you're interested on the topic, post a request in the Comments section, as this article won't explain this implementation detail.

Asynchronous Syntax

Ok, looking good so far. Let's see about the other main feature: How the UI thread is coded using promises instead of message listeners.

Because I love Svelte so much, let's do a Svelte component. The following is a simple component that tells the user if a given number is a prime number:

<script lang="ts">
    import PrimeWorker from "../workers/primeWorker.js?worker";
    import { AsyncWorker, CancelledMessage, type WorkItem } from "@wjfe/async-workers";
    import { primeWorkerTasks, type PrimerWorkerTasks } from "../workers/primeWorker.js";

    type Props = {
        testValue: number;
    };

    let {
        testValue,
    }: Props = $props();

    let worker: AsyncWorker<PrimeWorkerTasks>;
    let workItem = $state.raw<WorkItem>();
    // Worker's lifecycle.  Can also be done with onMount().
    $effect.pre(() => {
        worker = new AsyncWorker(new PrimeWorker(), primeWorkerTasks);
        return () => {
            worker?.terminate();
        }
    });
    // Runs on changes of the testValue property.
    $effect(() => {
        // Cancel any potential previous ongoing work.
        workItem?.cancel();
        workItem = worker.enqueue.isPrime(testValue, { cancellable: true });
    });
</script>

<span class="prime-result">
    {#await workItem?.promise}
        Working, please wait...
    {:then isPrime}
        {testValue} is {isPrime ? '' : 'not '}a prime.
    {:catch reason}
        {#if reason instanceof CancelledMessage}
            Calculation cancelled.
        {:else}
            Oops!: {reason}
        {/if}
    {/await}
</span>

<style>
    .prime-result {
        ...
    }
</style>

I don't really want to write the non-promise equivalent for this, so just imagine it: You would have to do worker.postMessage() on the effect for the testValue property to cancel the previous work, if any, using the asynchronous method shown in the Motivation section. Once cancellation takes place, another worker.postMessage() to start the new calculation for the new testValue. Then, set up a listener with worker.onmessage to receive the calculation result, and also a listener with worker.onerror to catch any potential errors. All this to replace the 2 lines inside the $effect() above.

But it doesn't stay there. There would be no promise unless you code one. If you don't go the promise route, you could not take advantage of Svelte's await block, which would make coding this component a lot harder. The list of additional chores probably goes on. I made @wjfe/async-workers so I don't have to think about this, so don't make me think about this, please! :smile:

Synchronization Objects

The available synchronization objects in this package came to be because they were pretty much almost done by the time I finished implementing CancellationSource. As a matter of fact, CancellationSource extends ManualResetEvent.

Thread synchronization is not a topic I wish to cover in this article. If you would like me to do a piece on this topic, I'll be happy to do it. Ask for it in the Comments section. For now, I'll just say that ManualResetEvent objects don't reset after being awaited, so they are useful for things like pausing a web worker's work. On the other hand, AutoResetEvent objects automatically reset when a thread awakens by its token, making them useful for things like preventing/eliminating race conditions. Finally, the upcoming Semaphore object will be useful to throttle/restrict access to resources, such as capping the maximum simultaneous requests to the back-end API to avoid overwhelming it.

Final Thoughts

Using web workers is not much fun. Its API is very simple, but cumbersome at the very least. It forces you to partition your code (the code that starts the task in one place, and the one that does something after completion in another place). There are currently no frameworks or libraries that would welcome them natively, and one is probably cornered to write boilerplate code to make them play nice with our framework or library of choice.

This is where @wjfe/async-workers come into play, by adding a far simpler API to code with workers, which happens to play well with the author's favorite framework, Svelte. This, of course, doesn't mean that the package cannot be used elsewhere. On the contrary, this is a zero-dependency NPM package that should make your life easier regardless of your user interface framework/library of choice.

If you like what you have read, visit the GitHub repository and give it a star.

37
Subscribe to my newsletter

Read articles from José Pablo Ramírez Vargas directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

José Pablo Ramírez Vargas
José Pablo Ramírez Vargas