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)