Rate Limiting: Throttling Consecutive Function Calls with Queues

March 12, 2017

An implementation for limiting the amount of time between function calls in JavaScript may be necessary when any sort of process requires regulation like generating network requests. Whether it’s an API call, data store, or even a DOM event, multiple consecutive firings of a function may not give enough time for the intended result before the next invocation occurs.

Rate Limiters

A common solution for controlling consecutive function calls would be to create a rate limiter or throttled wrapper function. A method that, when given a function and a time delay, will return a new function that when executed consecutively, will invoke the given function parameter with a wait time before it can be executed again.

Below is a working example of an initial design.

function limiter(fn, wait){
    let isCalled = false;

    return function(){
        if (!isCalled){
            fn();
            isCalled = true;
            setTimeout(function(){
                isCalled = false;
            }, wait)
        }
    };
}

As you can see, the boolean value of isCalled determines whether or not the function has already been called. Now after the given wait in milliseconds, the setTimeout callback reverts the limiter back to a “non-called” state, making the method available for continued execution.

Dropped Calls

A rather significant limitation to this rate limiter is the other function calls are dropped. For example, if one were to loop over this method, they would lose certain iterations of the call if they occurred within the specified wait period. If the desired intention is for every call to be actualized, this method will not suffice.

A way to improve this would be to queue up each of the calls, so that every iteration will still occur but with the rate limiting timeout between each invocation.

// Broken Code
function limiter(fn, wait){
    let isCalled = false,
        calls = [];

	return function(){
		calls.push(fn);

        // Infinite Loop
		while (calls.length){
			if (!isCalled){
				calls.shift().call();
				isCalled = true;
				setTimeout(function(){
					isCalled = false;
				}, wait)
			}
		}
	};
}

Each execution of the returned function, will push the call to the array variable calls, which will act as the queue. While the queue contains values, a check will see if the function is already in a called state. If not, it will shift off the top or first method in the queue and call it. Then as with the earlier implementation, the isCalled boolean value will revert after the allotted timeout.

This does not work.

Why? Because the while loop blocks the event-driven nature of JavaScript and its event loop. Our callback in the setTimeout will never execute until after the while loop finishes its iterating, which won’t occur until the callback happens, causing an infinite loop.

Recursive Design

With this realization or understanding in mind, our real solution lies with good old recursion. By abstracting a caller method containing the fn parameter invocation which could be called recursively from within the setTimeout, it would allow the function execution to occur after the callback and would prevent the unintended continuous iteration.

function limiter(fn, wait){
    let isCalled = false,
        calls = [];

    let caller = function(){
        if (calls.length && !isCalled){
            isCalled = true;
            calls.shift().call();
            setTimeout(function(){
                isCalled = false;
                caller();
            }, wait);
        }
    };

    return function(){
        calls.push(fn);
        caller();
    };
}

Passing Arguments

Now how could the fn function be supplied with arguments of its own?

Looking into examples of other solutions, one implementation I have found, created by Matthew O’Riordan, contains some insight into ways in which this method could be optimized. His _.rateLimit() method, written as an extension of Underscore.js, adds additional functionality for parameters to be provided at the time of execution.

Continuing with the usage of idiomatic JavaScript and not requiring any dependencies or additional libraries is the most pragmatic approach for this implementation. Now making sure that arguments may be passed to the rate limited function requires their inclusion within the limiter method’s return function.

function limiter(fn, wait){
    let isCalled = false,
        calls = [];

    let caller = function(){
        if (calls.length && !isCalled){
            isCalled = true;
            calls.shift().call();
            setTimeout(function(){
                isCalled = false;
                caller();
            }, wait);
        }
    };

    return function(){
        calls.push(fn.bind(this, ...arguments));
        // let args = Array.prototype.slice.call(arguments);
        // calls.push(fn.bind.apply(fn, [this].concat(args)));

        caller();
    };
}

For simplicity the Spread operator is used on the Arguments object to bind the arguments to our fn parameter.

Alternatively, if ES6 compatibility is a concern, among the other changes necessary (i.e. ‘let’), an alteration must be made to provide the arguments to the rate limited function. As shown in the comments, the arguments object must first be converted into a true array which will enable it to be properly passed into the apply() method. This method is necessary to allow for the passing of an array of parameters to the bind() method as opposed to its de facto spread sequence.

Brief Implementation

Implementing the limiter function is now a rather straightforward task.

const logMessageLimited = limiter(msg => { console.log(msg); }, 500);

for (let i = 0; i < 3; i++){
    logMessageLimited(`[Message Log] Action (${i}) rate limited.`);
}

Each message will log in the console at an interval of 500ms. This may easily be replicated with setInterval(), but its usage gives a rather clear picture of a rate limited function declaration.

Rate limiters and throttling functions exist through a plethora of JavaScript libraries; I’m now looking forward to comparing their methodologies with the one above.