17

How can a function rate-limit its calls? The calls should not be discarded if too frequent, but rather be queued up and spaced out in time, X milliseconds apart. I've looked at throttle and debounce, but they discard calls instead of queuing them up to be run in the future.

Any better solution than a queue with a process() method set on an X millisecond interval? Are there such standard implementations in JS frameworks? I've looked at underscore.js so far - nothing.

Dan Dascalescu
  • 143,271
  • 52
  • 317
  • 404
  • Whats wrong with the interval timer method? – Petah Apr 15 '14 at 01:05
  • @Petah: nothing in principle, but I don't want to reinvent the wheel. – Dan Dascalescu Apr 15 '14 at 01:06
  • Its hardly reinventing the wheel, should be < 20 LOC. – Petah Apr 15 '14 at 01:08
  • I don't like the word _"interval"_ here, especially if you're doing things that have arbitrary processing requirements. Use the word _timeout_ and your phraseology will match how you should be writing the code (with _setTimeout_) – Paul S. Apr 15 '14 at 01:09
  • @PaulS.: the use I had in mind for `setInterval` was to process the queue, like in [this example](http://stackoverflow.com/a/17543630/1269037). – Dan Dascalescu Apr 15 '14 at 05:50
  • @DanDascalescu With `setInterval`, when time to process exceeds the delay, you end up in an environment where the second tries to happen before the first finishes, which makes both slower, so the third tries to happen before the second finishes, and maybe the first. Ultimately, the `n+1`th starts to happen before the `n`th and `n-1`th have finished, and then all hell breaks loose – Paul S. Apr 15 '14 at 12:02

5 Answers5

7

Should be rather simple without a library:

var stack = [], 
    timer = null;

function process() {
    var item = stack.shift();
    // process
    if (stack.length === 0) {
        clearInterval(timer);
        timer = null;
    }
}

function queue(item) {
    stack.push(item);
    if (timer === null) {
        timer = setInterval(process, 500);
    }
}

http://jsfiddle.net/6TPed/4/

Petah
  • 45,477
  • 28
  • 157
  • 213
  • 2
    Thanks for this clear answer. I've combined it with @PaulS.'s answer to return a limited function, and I've made a change to start the queue right away (because `setInterval` delays first). [Here's the fiddle.](http://jsfiddle.net/dandv/47cbj/) What do you think? – Dan Dascalescu Apr 17 '14 at 12:47
6

Here is an example which carries forward this (or lets you set a custom one)

function RateLimit(fn, delay, context) {
    var canInvoke = true,
        queue = [],
        timeout,
        limited = function () {
            queue.push({
                context: context || this,
                arguments: Array.prototype.slice.call(arguments)
            });
            if (canInvoke) {
                canInvoke = false;
                timeEnd();
            }
        };
    function run(context, args) {
        fn.apply(context, args);
    }
    function timeEnd() {
        var e;
        if (queue.length) {
            e = queue.splice(0, 1)[0];
            run(e.context, e.arguments);
            timeout = window.setTimeout(timeEnd, delay);
        } else
            canInvoke = true;
    }
    limited.reset = function () {
        window.clearTimeout(timeout);
        queue = [];
        canInvoke = true;
    };
    return limited;
}

Now

function foo(x) {
    console.log('hello world', x);
}
var bar = RateLimit(foo, 1e3);
bar(1); // logs: hello world 1
bar(2);
bar(3);
// undefined, bar is void
// ..
// logged: hello world 2
// ..
// logged: hello world 3
Paul S.
  • 64,864
  • 9
  • 122
  • 138
  • 2
    I've combined this answer with @Petah's solution of using `setInterval`. [Here's the fiddle.](http://jsfiddle.net/dandv/47cbj/) What do you think? – Dan Dascalescu Apr 17 '14 at 12:32
2

While the snippets offered by others do work (I've built a library based on them), for those who want to use well-supported modules, here are the top choices:

  • the most popular rate limiter is limiter
  • function-rate-limit has a simple API that works, and good usage stats on npmjs
  • valvelet, a newer module, claims to do an even better job by supporting promises, but hasn't gained popularity yet
Dan Dascalescu
  • 143,271
  • 52
  • 317
  • 404
  • 1
    Existing answers and libraries mentioned didn't satisfy my need so I wrote yet another one: [debounce-queue](https://github.com/laggingreflex/debounce-queue). Its unique feature being that it gives an array of all previous items instead of just the last/first one. – laggingreflex Nov 02 '16 at 19:30
1

I needed a TypeScript version of this, so I took @Dan Dascelescu's fiddle and added types to it.

Please leave a comment if you can improve the typings

function rateLimit<T>(
  fn: (...args: Array<any>) => void,
  delay: number,
  context?: T
) {
  const queue: Array<{ context: T; arguments: Array<any> }> = []
  let timer: NodeJS.Timeout | null = null

  function processQueue() {
    const item = queue.shift()

    if (item) {
      fn.apply<T, Array<any>, void>(item.context, item.arguments)
    }

    if (queue.length === 0 && timer) {
      clearInterval(timer)
      timer = null
    }
  }

  return function limited(this: T, ...args: Array<any>) {
    queue.push({
      context: context || this,
      arguments: [...args],
    })

    if (!timer) {
      processQueue() // start immediately on the first invocation
      timer = setInterval(processQueue, delay)
    }
  }
}
Tobbe
  • 3,282
  • 6
  • 41
  • 53
0

I think this could get simpler and more reusable with decorators:

/**
 * Sleeps async for a given amount of time.
 * @param milisec
 * @returns
 */
function asyncDelay(milisec: number): Promise<void> {
  return new Promise<void>((resolve) => {
    setTimeout(() => { resolve(); }, milisec);
  });
}

/**
 * Generates a random int within the max and min range.
 * Maximum is exclusive and minimum is inclusive.
 * @param min
 * @param max
 */
export const getRandomInt = (
  min: number,
  max: number,
): number => (
  Math.floor(
    Math.random() * (
      Math.floor(max) - Math.ceil(min)
    ) + Math.ceil(min),
  )
);

/**
 * Will throttle a method by a fixed ms time, if number is passed.
 * if tuple, will throttle by a random time (ms) between each.
 * @param milliseconds
 * @returns
 */
function throttle(milliseconds: number | [number, number]): any {
  let lastCall = 0;
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      const now = Date.now();
      if (!lastCall) {
        lastCall = now;
        return originalMethod.apply(this, args);
      }
      const ms = Array.isArray(milliseconds)
        ? getRandomInt(milliseconds[0], milliseconds[1])
        : Math.round(milliseconds);

      const diff = now - lastCall;
      if (diff < ms) {
        await asyncDelay(ms - diff);
      }
      lastCall = Date.now();
      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

The you can just do:

class MyClass {
  @throttle(1000)
  async fixedRateLimitedMethod() {
    await doSomething()
  }

  @throttle([1000, 5000])
  async randomRateLimitedMethod() {
    await doSomething()
  }
}

TSPlayground

Omar Omeiri
  • 1,506
  • 1
  • 17
  • 33