Using AbortController To Debounce setTimeout() Calls In JavaScript
After looking at using AbortController
to cancel fetch()
callsin modern JavaScript, I started to think about what else I could cancel. The next obvious thing to me is timers. Historically, if I wanted to cancel a timer, I would use the clearTimeout()
function. But, I'm enamored with this idea that multiple workflows can all be canceled using the same AbortSignal
instance. As such, I think it makes sense to experiment with replacing (or rather proxying) clearTimeout()
calls with an AbortSignal
as a means to cancel or debounce timers and intervals in JavaScript.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In JavaScript, when we invoke the setTimeout()
function, it returns a timeoutID
. This ID can then be passed into the clearTimeout()
function if we want to cancel the timer before it has invoked its callback. But, when dealing with the AbortController
, we no longer trade in "return values". Instead, we lean into Inversion-of-Control (IoC), and pass-around AbortSignal
instances that act a Pub/Sub mechanisms for cancellation.
Of course, the setTimeout()
function doesn't accept an AbortSignal
. So, we're going to have to create one ourselves:
setAbortableTimeout( callback, delayInMilliseconds, signal )
Unlike the native setTimeout()
function, which is a variadic function (one of indefinite arity) that can accept callback-invocation arguments, our proxy function only accepts 3 arguments; the third of which is our optional AbortSignal
instance. And, it returns nothing - all cancellation will be performed through the passed-in signal
argument.
To explore this concept, I've created a simple demo that has a single Button. When this button is clicked, it sets a timer for 1,000ms after which a message will be logged to the console. If the button is clicked multiple times in that 1000ms window, each previous timer will be canceled - via the AbortSignal
- and a new timer will be initialized.
Internally to the setAbortableTimeout()
function, we're going to bind to the abort
event on the signal
as a proxy to the internal clearTimeout()
call.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>
Using AbortController To Debounce setTimeout() Calls
</title>
</head>
<body>
<h1>
Using AbortController To Debounce setTimeout() Calls
</h1>
<button>
Click Me
</button>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/umbrella/3.3.0/umbrella-3.3.0.min.js"></script>
<script type="text/javascript" src="../../vendor/umbrella/jquery-compat.js"></script>
<script type="text/javascript" charset="utf-8">
// HISTORICALLY, if we wanted to DEBOUNCE A TIMER, we would store a reference to
// the timeoutID (the return value of the timer/interval functions). In this
// case, we're going to use the same exactly technique; only, instead of storing
// a timeoutID, we're storing a reference to an AbortController.
var abortController = null;
u( "button" ).click(
function handleClick() {
// HISTORICALLY, when debouncing clicks on the button, we would call the
// clearTimeout() function right before setting up the timer. In this
// case, since we're using the AbortController as the underlying control
// mechanism, we're going to call .abort() instead.
abortController?.abort();
abortController = new AbortController();
setAbortableTimeout(
function logTimeout() {
console.log( "Timer executed at %s \u{1F4AA}!", Date.now() );
},
1000,
abortController.signal
);
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
/**
* I create a timer that can be canceled using the optional AbortSignal.
*/
function setAbortableTimeout( callback, delayInMilliseconds, signal ) {
// When the calling context triggers an abort, we need to listen to for it so
// that we can turn around and clear the internal timer.
// --
// NOTE: We're creating a proxy callback to remove this event-listener once
// the timer executes. This way, our event-handler never gets invoked if
// there's nothing for it to actually do. Also note that the "abort" event
// will only ever get emitted once, regardless of how many times the calling
// context tries to invoke .abort() on its AbortController.
signal?.addEventListener( "abort", handleAbort );
// Setup our internal timer that we can clear-on-abort.
var internalTimer = setTimeout( internalCallback, delayInMilliseconds );
// -- Internal methods. -- //
function internalCallback() {
signal?.removeEventListener( "abort", handleAbort );
callback();
}
function handleAbort() {
console.warn( "Canceling timer (%s) via signal abort.", internalTimer );
clearTimeout( internalTimer );
}
}
</script>
</body>
</html>
As you can see, our high-level debouncing algorithm is no different than it would be normally. Only, instead of storing a timeoutID
value as a means to cancel any previously-pending timer, we're using an abortController
value. The only meaningful difference is how the setAbortableTimeout()
is implemented.
Once we get into the lower-level implementation details, you can see that we do still use the timeoutID
. Because, after all, it's still a timer and we still need to cancel it. Only, in this case, we're using the AbortSignal
as a Publish and Subscribe (Pub/Sub) mechanism that emits a single event, abort
. When this event is triggered, that's when we turn around and clear the underlying timer.
Now, if we run this in the browser and click on the button a few times, we get the following console output:
As you can see, when we click the button at a slow pace, each timer executes without interruption. However, if we click the button in rapid succession, each previous timer is canceled before the new timer is initialized. The AbortController
and AbortSignal
are working together to debounce the timer's callback.
If all you want to do is debounce a timer, using the AbortController
is likely too much complexity. But, I'm now thinking about how I could use an AbortController
to coordinate timers in a larger context; such as in an Angular controller or in a retryable fetch()
call.
Want to use code from this post? Check out the license.
Reader Comments
Looks like
true
or{once: true}
as the 3rd arg to signal.addEventListener removes the listener after it's called 😉@Nick,
Oh, very interesting call! I think I learned about the
once
option a while back, but it's not supported in IE11 - which I have to support at work still 😨 - so I never picked it up as part of my repertoire. That said, If I'm usingAbortController
, I've already dropped supported for IE11, so I can start usingonce
. Awesome catch!!Wow, so I was just looking at the MDN docs for
addEventListener()
because of Nick's comment; and, on a related note, it looks like very modern browsers also support anoptions.signal
in theaddEventListener()
configuration. That said, support is relatively recent:https://caniuse.com/?search=options.signal
... so, it may not work on all devices. But, definitely something to keep my eye on.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →