Some events fire continuously while something is going on: scroll while the page moves, pointermove while the pointer moves, resize while a window is resized. Doing slow work on every one of them can make the page stutter. Throttling a function limits how often it can run: at most once per interval, say once every 100 ms, however often it's called. While the calls keep coming, it runs at a steady pace, so the page keeps updating, only less often.
Picture a turnstile that lets one person through, then stays locked for a few seconds. However hard the crowd pushes, people get through at a steady rate.
Here's a scroll handler throttled to once every 100 ms. Scrolling calls it five times, with a to e standing for the scroll position each time, then stops. Throttles come in two flavours, and both run the first call right away. The first then drops every call made less than 100 ms after it last ran. The second opens a 100 ms window, keeps the latest call made during it, and runs that call, the trailing call, when the window ends:
| Time | Call | Throttle that drops calls | Throttle with a trailing call |
|---|---|---|---|
| 0 ms | a | Runs with a. | Runs with a, and a window opens. |
| 40 ms | b | Dropped: it ran 40 ms ago. | Doesn't run. b is kept as the latest call. |
| 80 ms | c | Dropped: it ran 80 ms ago. | Doesn't run. c replaces b. |
| 100 ms | None | Nothing happens. | The window ends: runs with c, and a new window opens. |
| 130 ms | d | Runs with d: it last ran 130 ms ago. | Doesn't run. d is kept. |
| 170 ms | e | Dropped: it ran 40 ms ago. | Doesn't run. e replaces d. |
| 200 ms | None | Nothing happens. | The window ends: runs with e, and a new window opens. |
| 300 ms | None | Nothing happens. | The window ends with no call in it, so the throttle goes idle. The next call runs right away. |
Both run at most once every 100 ms. The first ran with a and d, and dropped e, the last call, so if e was where the scrolling stopped, the page never shows it. The second ran with a, c, and e: a little later, but the final state always gets through.
Throttling is often confused with debouncing, which waits for the calls to stop and then runs once. Given the same five calls, a debounce with a 100 ms wait would run only once, at 270 ms, with e: during a long stream of calls, it doesn't run at all. Throttle when you want updates while something is happening: a reading-progress bar that fills as the page scrolls, or loading the next page of a list as the user nears the bottom.
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
useFakeTimers();
const fn = spy();
const throttled = throttle(fn, 100);
throttled("a");
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenLastCalledWith("a");
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]);
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);
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]);
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]);
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);
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]);
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).
I'm here to help with Throttle with a trailing call. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.