Skip to the code
cf.

Rate limiter

MediumTiming

Write rateLimit(fn, limit, interval). It returns a function that calls fn, but never starts more than limit calls in any interval ms: a call may start only if fewer than limit calls started in the last interval ms. A call that started exactly interval ms ago no longer counts.

When there's room, fn runs right away, during the call. Otherwise the call waits in a queue and runs as soon as there's room, in the order the calls were made, with its own this and arguments. A call counts from the moment it starts, whether fn succeeds, fails, or is still running.

Every call returns a promise of what fn returns. If fn throws, that promise rejects with the error, and the queue carries on.

const send = rateLimit((message) => api.post(message), 2, 1000);
send("a"); // starts now
send("b"); // starts now
send("c"); // starts at 1000 ms, when "a" is 1000 ms old
send("d"); // starts at 1000 ms, when "b" is 1000 ms old
send("e"); // starts at 2000 ms

What the tests check

  1. runs the first limit calls right away
    useFakeTimers();
    const fn = spy();
    const limited = rateLimit(fn, 2, 1000);
    limited(1);
    limited(2);
    limited(3);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2]);
    
  2. runs a queued call when the oldest run is interval ms old
    const clock = useFakeTimers();
    const fn = spy();
    const limited = rateLimit(fn, 2, 1000);
    limited(1);
    limited(2);
    limited(3);
    await clock.tickAsync(999);
    expect(fn).toHaveBeenCalledTimes(2);
    await clock.tickAsync(1);
    expect(fn).toHaveBeenCalledTimes(3);
    expect(fn).toHaveBeenLastCalledWith(3);
    
  3. runs queued calls in order, limit at a time
    const clock = useFakeTimers();
    const fn = spy();
    const limited = rateLimit(fn, 2, 1000);
    const started = () => fn.calls.map((call) => call.args[0]);
    for (const value of [1, 2, 3, 4, 5]) limited(value);
    expect(started()).toEqual([1, 2]);
    await clock.tickAsync(1000);
    expect(started()).toEqual([1, 2, 3, 4]);
    await clock.tickAsync(1000);
    expect(started()).toEqual([1, 2, 3, 4, 5]);
    
  4. counts the last interval ms, not fixed blocks of time
    const clock = useFakeTimers();
    const fn = spy();
    const limited = rateLimit(fn, 2, 1000);
    const started = () => fn.calls.map((call) => call.args[0]);
    limited(1);
    await clock.tickAsync(500);
    limited(2);
    limited(3);
    limited(4);
    expect(started()).toEqual([1, 2]);
    await clock.tickAsync(500); // 1 is now 1000 ms old
    expect(started()).toEqual([1, 2, 3]);
    await clock.tickAsync(499);
    expect(started()).toEqual([1, 2, 3]);
    await clock.tickAsync(1); // 2 is now 1000 ms old
    expect(started()).toEqual([1, 2, 3, 4]);
    
  5. resolves each call with fn's result
    const clock = useFakeTimers();
    const limited = rateLimit((value) => value * 10, 1, 1000);
    let first;
    let second;
    limited(1).then((value) => {
      first = value;
    });
    limited(2).then((value) => {
      second = value;
    });
    await clock.tickAsync(0);
    expect(first).toBe(10);
    expect(second).toBeUndefined();
    await clock.tickAsync(1000);
    expect(second).toBe(20);
    
  6. rejects a call whose fn throws, and carries on
    const clock = useFakeTimers();
    const limited = rateLimit((value) => {
      if (value === "bad") throw new TypeError("bad input");
      return value;
    }, 1, 1000);
    let error;
    let result;
    limited("first");
    limited("bad").catch((reason) => {
      error = reason;
    });
    limited("ok").then((value) => {
      result = value;
    }, () => {});
    await clock.tickAsync(1000);
    expect(error).toBeInstanceOf(TypeError);
    await clock.tickAsync(1000);
    expect(result).toBe("ok");
    
  7. calls fn with each call's own this
    const clock = useFakeTimers();
    const fn = spy();
    const limited = rateLimit(fn, 1, 1000);
    const a = { limited };
    const b = { limited };
    a.limited();
    b.limited();
    await clock.tickAsync(1000);
    expect(fn.calls[0].this).toBe(a);
    expect(fn.calls[1].this).toBe(b);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).