Rate Limiting with JavaScript

Sometimes you need to be alerted when some event or action happens, but the event or action could happen multiple times in quick succession. A perfect example of this is window.onscroll. The window.onscroll event fires entirely too much. Not only that, it fires inconsistently across browsers. This has been talked about before. Here, I offer a solution:

We can make perfect use of some JavaScript built-ins: setTimeout and clearTimeout, and some uber-cool Dojo magic: dojo.publish and dojo.subscribe. PubSub is a mechanism for arbitrary communication between elements. The usage pattern fits here perfectly. Because window.onscroll fires so much, having multiple connections to this event can cause serious slowdowns in your application when the user scrolls. This technique involves connecting to window.onscroll once, and rate-limiting the firing of this event to something more manageable.

Start by making the connection. We’ll wrap it in an anonymous-self-executing function to scope our variables and keep them out of the global space:

(function(d){
	var timer, // create a variable to store a timeout 
		rate = 50 //ms .. and a variable to use for the delay
	;
 
	// setup one connection
	d.connect(d.global, "onscroll", function(e){
		// if this function has been previously called and not fired,
		// clear the timeout
		timer && clearTimeout(timer); 
		timer = setTimeout(function(){
			// publish a custom topic when this timeout executes
			d.publish("/window/scrolled", [e]);
			timer = null;
		}, rate);
	});
 
})(dojo);

If the onscroll event fires 100 times in a short period, only the last occurrence will fire the publish. We can utilize this rate-limited version of onscroll simply by subscribing to the “/window/scrolled” topic.

	dojo.subscribe("/window/scrolled", function(e){
		// do some recalculation based on knowing the scrolling is "done". 
		// will fire at MOST once per 50ms, so won't be very expensive
	});

This technique can be applied to most anything. Perhaps you have a button which sends an Ajax request. Some users double and triple-click buttons out of habit, inadequate visual feedback or a number of other reasons. We can ensure that only one click is allowed within a rate limited window. Same basic setup, different event. The original connecting code might look something like this:

(function(d){
 
	// this sends our POST based on whatever form
	var someFunction = function(e){
		dojo.xhrPost({ form:"someFormId" });
	}
 
	// setup the click events. 
	d.query(".buttons").onclick(someFunction);
 
})(dojo);

If a user clicks something with class=”buttons” rapidly in succession, `someFunction` will be called that many times, causing
many Ajax requests to be sent. Converting the code to something which will only fire once per `rate` ms is

(function(d){
 
	var someFunction = function(e){
		dojo.xhrPost({ form:"someFormId" });
	}
 
	var timer, rate = 50;
	dojo.query(".buttons").onclick(function(e){
		timer && clearTimeout(timer);
		timer = setTimeout(function(){
			someFunction({ target: e.target });
			timer = null;
		}, rate);
	});
 
})(dojo);

Getting a little more advanced, we can take this concept and reduce it to a common function. We’ll invent a new API to handle all the rate-limiting locally. We’ll just make a function which accepts an extra parameter for the rate to set:

(function(d, nl){
 
	d.connectLimited = function(rate, target, event, scope, cb, fixDom){
		// summary: Just like `dojo.connect`, but takes an additional argument BEFORE
		//		standard connect() arguments: rate. This value is used to prevent 
		//		rapid successive calls to this event
 
		var timer,
			fn = scope && cb ? d.hitch(scope, cb) : scope
		;
 
		return d.connect(target, event, function(e){
			timer && clearTimeout(timer);
			var args = arguments;
			timer = setTimeout(function(){
				fn.apply(d, args);
				timer = null;
			}, rate);
		}, null, fixDom);
 
	}
 
	nl.prototype.connectLimited = function(rate, event, scope, cb, fixDom){
		// not a straight forEach, need to shift `node` into each call
		return this.forEach(function(node){
			d.connectLimited.call(d, rate, node, event, scope, cb, fixDom);
		});
	}
 
})(dojo, dojo.NodeList);

By returning the handle from the rate-limited dojo.connect call, we are able to disconnect this event with dojo.disconnect.

Now, to use it we simply call the function passing a rate in addition to whatever we normally would have called:

	dojo.query(".buttons").connectLimited(50, "onclick", someFunction);
	// or
	dojo.connectLimited(75, window, "onscroll", function(){
		dojo.publish("/window/scrolled");
	});

Hope this helps someone.

Tags: