Skip to main content
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Doug Neiner
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Doug Neiner@dougneiner )

Using AbortController To Debounce setTimeout() Calls In JavaScript

By on

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:

Logging that shows the timer being cancelled via the passed-in AbortSignal instance.

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

1 Comments

Looks like true or {once: true} as the 3rd arg to signal.addEventListener removes the listener after it's called ๐Ÿ˜‰

function setAbortableTimeout(fn, ms, signal) {
  const timeout = setTimeout(fn, ms);
  signal.addEventListener("abort", () => clearTimeout(timeout), {});
};
15,260 Comments

@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 using AbortController, I've already dropped supported for IE11, so I can start using once. Awesome catch!!

15,260 Comments

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 an options.signal in the addEventListener() 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!

Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.