JavaScript Event Throttling & Debouncing

A number of built-in JavaScript events fire frequently. This behavior provides benefits to general applications, but can hinder the performance or aesthetic of a page. The default sampling behavior can be easily modified by event throttling or event debouncing.

Throttling

To illustrate throttling let's start with a sample mousemove handler:

window.addEventListener('mousemove', function(e) {
   document.body.innerHTML = '(' + e.pageX + ', ' + e.pageY + ')';
});

This code binds an anonymous function to the mousemove event, which fires every time the mouse moves within the monitored element. The anonymous function itself updates the page content with the mouse's position.

But suppose we'd like the effective sample rate to be half a second. We can modify the event handler as follows:

var allowSample = true;
window.addEventListener('mousemove', function(e) {
	if (allowSample) {
		allowSample = false;
		document.body.innerHTML = '(' + e.pageX + ', ' + e.pageY + ')';
		setTimeout(function () { allowSample = true; }, 500)
	}
});

Use of a boolean sampling flag and a reset timeout function creates a 0.5-second buffer on the front of the event handler, during which additional event firings are effectively ignored.

Debouncing

To illustrate debouncing, take a look at a sample resize handler:

window.addEventListener('resize', function(e) {
   document.body.innerHTML = window.innerWidth + 'x' + window.innerHeight;
});

This behaves similarly to the basic mousemove example, except that the anonymous function is bound to the resize event, which fires every time the monitored element is resized. The anonymous function itself updates the page content with the current window dimensions.

If we'd like to handle the event only when it has been inactive for an arbitrary amount of time we can modify the event handler as follows:

var delayed;

window.addEventListener('resize', function(e) {
	clearTimeout(delayed);
	delayed = setTimeout(function () {
		document.body.innerHTML = window.innerWidth + 'x' + window.innerHeight;
	}, 500);
});

Use of a timeout variable creates a 0.5-second buffer on the back end of the event handler. Any time the event is fired, the event handler buffer is cleared (which cancels the execution) and the timer is reinitialized. So the code within the timeout variable will only run after 0.5 seconds of event inactivity.

More Advanced Improvements

The previous examples get the point across, but are sloppy implementations. The particular issue is the unnecessary scope pollution with the temporally relevant variables allowSample and delayed. More importantly, you'd be limited to one throttled event and one debounced event.

To address this we make use of the fact that:

  1. Functions are first-class objects and can be returned just like strings, numbers, etc.
  2. Functions automatically create unique execution contexts when they are executed

To immediately illustrate these two points take a look at the revised throttle implementation.

function throttle(fn, delay) {
	var allowSample = true;

	return function(e) {
		if (allowSample) {
			allowSample = false;
			setTimeout(function() { allowSample = true; }, delay);
			fn(e);
		}
	};
}

window.addEventListener('mousemove', throttle(function(e) {
	document.body.innerHTML = '(' + e.pageX + ', ' + e.pageY + ')';
}, 500));

So now instead of binding the function directly, we've bound the anonymous function returned by the call to throttle with a delay set to 500ms. The returned function will have a scoped reference to the variable allowSample. Subsequent calls to throttle will all uniquely reference they're OWN allowSample. The anonymous function itself will handle the "throttling" action and, if permitted, will execute the originally passed function fn.

The revised debounce implementation takes advantage of this same technique.

function debounce(fn, delay) {
	var delayed;

	return function(e) {
		clearTimeout(delayed);
		delayed = setTimeout(function() {
			fn(e);
		}, delay);
	};
}

window.addEventListener('resize', debounce(function(e) {
	document.body.innerHTML = window.innerWidth + 'x' + window.innerHeight;
}, 500));