DEV Community

Euan T
Euan T

Posted on • Originally published at euantorano.co.uk on

Implementing a cancellable asynchronous delay in JavaScript

While working on a React project recently, I had a need to update some state periodically with data from an API retrieved using fetch(). Coming from a C# background, the way I would approach this problem there would be something like the following:

private async Task FetchDataContinuouslyAsync(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await FetchDataAndSetStateAsync(cancellationToken);

        // now wait for 15 seconds before trying again
        await Task.Delay(15000, cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

Naturally, I went to approach the problem the same way in JavaScript. That's where I hit a snag though - there's no built in function analogous to Task.Delay().

This meant I had to come up with my own solution to the problem. Searching the internet yielded plenty of results where people were using setTimeout along with a Promise, but surprisingly few which supported early cancellation - and those that did tend to return a cancel function rather than observing a token for cancellation. As I was already using fetch() with an AbortController to cancel requests, I wanted to re-use that controller for cancellation.

Here's what I came up with:

/**
 * Return a promise that is resolved after a given delay, or after being cancelled.
 * 
 * @param  {number} duration The delay, in milliseconds.
 * @param  {AbortSignal|null} signal An optional AbortSignal to cancel the delay.
 * 
 * @return {Promise<void>} A promise that is either resolved after the delay, or rejected after the signal is cancelled.
 */
function asyncSleep(duration, signal) {
    function isAbortSignal(val) {
        return typeof val === 'object' && val.constructor.name === AbortSignal.name;
    }

    return new Promise(function (resolve, reject) {
        let timeoutHandle = null;

        function handleAbortEvent() {        
            if (timeoutHandle !== null) {
                clearTimeout(timeoutHandle);
            }

            reject(new DOMException('Sleep aborted', 'AbortError'));
        }

        if (signal !== null && isAbortSignal(signal)) {
            if (signal.aborted) {
                handleAbortEvent();
            }

            signal.addEventListener('abort', handleAbortEvent, {once: true});
        }

        timeoutHandle = setTimeout(function () {
            if (signal !== null && isAbortSignal(signal)) {
                signal.removeEventListener('abort', handleAbortEvent);
            }

            resolve();
        }, duration);
    });
}
Enter fullscreen mode Exit fullscreen mode

This function takes a delay in milliseconds as its first parameter, and an optional AbortSignal as its second parameter. It returns a Promise<void> which will resolve after the specified delay, or be rejected with an AbortError if cancellation is requested.

In the context of a React project, this can be used like the following within a useEffect hook:

useEffect(() => {
    const ac = new AbortController();

    async function fetchDataContinuously(abortController) {
        while (!abortController.signal.aborted) {
            try {
                await getData(abortController.signal);

                await asyncSleep(refreshInterval, abortController.signal);
            } catch (e) {
                if (e.name === 'AbortError') {
                    break;
                }

                console.error('Error continuously refreshing', e);
            }
        }
    }

    fetchDataContinuously(ac).catch(console.error);

    return () => {
        ac.abort();
    };
}, []);
Enter fullscreen mode Exit fullscreen mode

Of course, this could also be used with a traditional class based React component by simply aborting the AbortController in componentWillUnmount as well.

Top comments (0)