Skip to the code
cf.

Throttle with a trailing call

MediumTiming

Write throttle(fn, wait) that runs fn at most once every wait ms without losing the last call. The first call runs fn right away and opens a wait ms window. Calls during the window don't run fn.

When the window ends, if any calls came during it, fn runs once with the this and arguments of the latest one, and that run opens a new window. If none came, the throttle goes idle, and the next call runs fn right away again.

Add a cancel() method that drops a pending call and resets the throttle, so the next call runs right away.

const onScroll = throttle(() => console.log(window.scrollY), 100);
window.addEventListener("scroll", onScroll);
// logs right away, at most every 100 ms while scrolling,
// and once more with the position where scrolling stopped
 
const log = throttle((text) => console.log(text), 100);
log("a"); // at 0 ms: logs "a" right away, and a window opens
log("b"); // at 30 ms: doesn't run, but is kept as the latest call
log("c"); // at 60 ms: replaces "b" as the latest call
// at 100 ms: the window ends, so it logs "c" and a new window opens
// at 200 ms: no calls came, so the throttle goes idle
log("d"); // at 250 ms: logs "d" right away

What the tests check

  1. runs fn right away on the first call
    useFakeTimers();
    const fn = spy();
    const throttled = throttle(fn, 100);
    throttled("a");
    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenLastCalledWith("a");
    
  2. runs once more when the window ends, with the latest arguments
    const clock = useFakeTimers();
    const fn = spy();
    const throttled = throttle(fn, 100);
    throttled(1);
    throttled(2);
    throttled(3);
    clock.tick(99);
    expect(fn).toHaveBeenCalledTimes(1);
    clock.tick(1);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 3]);
    
  3. goes idle when no calls came during the window
    const clock = useFakeTimers();
    const fn = spy();
    const throttled = throttle(fn, 100);
    throttled(1);
    clock.tick(1000);
    expect(fn).toHaveBeenCalledTimes(1);
    throttled(2);
    expect(fn).toHaveBeenCalledTimes(2);
    
  4. the trailing run opens a new window
    const clock = useFakeTimers();
    const fn = spy();
    const throttled = throttle(fn, 100);
    throttled(1);
    throttled(2);
    clock.tick(100);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2]);
    clock.tick(1);
    throttled(3);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2]);
    clock.tick(99);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
    
  5. runs every wait ms while calls keep coming
    const clock = useFakeTimers();
    const fn = spy();
    const throttled = throttle(fn, 100);
    for (let t = 0; t < 500; t += 25) {
      throttled(t);
      clock.tick(25);
    }
    expect(fn.calls.map((call) => call.args[0])).toEqual([0, 75, 175, 275, 375, 475]);
    
  6. uses the this of the latest call
    const clock = useFakeTimers();
    const fn = spy();
    const shared = throttle(fn, 100);
    const a = { shared };
    const b = { shared };
    a.shared(1);
    b.shared(2);
    clock.tick(100);
    expect(fn.calls[0].this).toBe(a);
    expect(fn.calls[1].this).toBe(b);
    
  7. cancel() drops the pending call and resets
    const clock = useFakeTimers();
    const fn = spy();
    const throttled = throttle(fn, 100);
    throttled(1);
    throttled(2);
    throttled.cancel();
    throttled(3);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 3]);
    clock.tick(1000);
    expect(fn.calls.map((call) => call.args[0])).toEqual([1, 3]);
    

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).