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). It returns a function that runs fn at most once every wait ms. The first call runs fn right away, during the call, with that call's this and arguments.
Calls made less than wait ms after fn last ran are dropped: fn never runs for them, not even later. The first call made wait ms or more after fn last ran runs it again, right away.
const onScroll = throttle(() => console.log(window.scrollY), 100);
window.addEventListener("scroll", onScroll);
// logs right away, then at most once every 100 ms while you scroll
const log = throttle((n) => console.log(n), 100);
log(1); // at 0 ms: logs 1 right away
log(2); // at 50 ms: dropped, since fn ran 50 ms ago
log(3); // at 99 ms: dropped
log(4); // at 100 ms: logs 4
log(5); // at 150 ms: dropped
// 2, 3, and 5 are never logged, not even later
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);
clock.tick(50);
throttled(2);
clock.tick(49);
throttled(3);
clock.tick(1000);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenLastCalledWith(1);
const clock = useFakeTimers();
const fn = spy();
const throttled = throttle(fn, 100);
throttled(1);
clock.tick(100);
throttled(2);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenLastCalledWith(2);
const clock = useFakeTimers();
const fn = spy();
const throttled = throttle(fn, 100);
throttled(1);
clock.tick(60);
throttled(2);
clock.tick(40);
throttled(3);
expect(fn.calls.map((call) => call.args[0])).toEqual([1, 3]);
const clock = useFakeTimers();
const start = clock.now();
const runs = [];
const throttled = throttle(() => runs.push(clock.now() - start), 100);
for (let t = 0; t <= 300; t += 30) {
throttled();
clock.tick(30);
}
expect(runs).toEqual([0, 120, 240]);
useFakeTimers();
const fn = spy();
const box = { onScroll: throttle(fn, 100) };
box.onScroll(1, 2);
expect(fn.calls[0].this).toBe(box);
expect(fn).toHaveBeenLastCalledWith(1, 2);
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. 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.