debounce and throttle are two ways of limiting how often a function runs while a user is still doing something. Debounce waits for the activity to stop; throttle lets the function run at a fixed maximum rate while the activity continues. Both are a few lines of code, and both exist because an input or a scroll event fires far more often than the work behind it can keep up with.
Why does typing in a search box fire one request per keystroke?
Type shoe into a search field and the browser fires four input events: one for each letter. Attach a network call to that event and the server receives four requests for s, sh, sho and shoe. The first three answers are worthless to the user, and on a slow connection the last answer can arrive before the earlier ones and overwrite it.
// the version that hurts: one request per keystroke
const input = document.querySelector('#search');
input.addEventListener('input', (event) => {
fetch(`/api/search?q=${event.target.value}`);
});
The same problem appears with a window resize handler that recalculates a layout, a scroll handler that fetches more rows, and a button that saves on every keystroke in a form. In each case the work is far more expensive than the event that triggers it, so the fix is to rate-limit the work rather than to optimise the event listener.
What does debouncing do?
A debounced function waits until the activity has been quiet for a given number of milliseconds, then runs once. Every new call restarts the timer, so a burst of events collapses into a single call with the last set of arguments.
// debounce.js
export function debounce(fn, waitMs) {
let timer = null;
return function debounced(...args) {
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, waitMs);
};
}
Three details make the implementation correct. The timer is cleared on every call, otherwise several timers would be pending. The arguments are captured with a rest parameter so the wrapper passes through whatever the caller supplied. And the timer is set to null when it fires, which stops the wrapper from cancelling a timer that has already run.
// debounce demo
import { debounce } from './debounce.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const calls = [];
const search = debounce((text) => calls.push(text), 200);
search('s');
search('sh');
search('sho');
search('shoe');
console.log('so far:', calls.length); // 0
await sleep(400);
console.log('calls:', calls); // [ 'shoe' ]
Four keystrokes produced one call, carrying the final text. The cost is a delay: the search only starts once the user pauses. That is why a debounced search box feels responsive for words and sluggish for the very first character of a long query.
What does throttling do?
A throttled function runs at most once per interval, and it runs again at the end of the interval if it was called during it. The work starts immediately on the first call, so there is no dead time at the beginning.
// throttle.js
export function throttle(fn, intervalMs) {
let lastCall = 0;
let timer = null;
let pendingArgs = null;
return function throttled(...args) {
const now = Date.now();
const remaining = intervalMs - (now - lastCall);
if (remaining <= 0) {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
lastCall = now;
pendingArgs = null;
fn(...args);
return;
}
pendingArgs = args;
if (timer === null) {
timer = setTimeout(() => {
timer = null;
lastCall = Date.now();
const queued = pendingArgs;
pendingArgs = null;
fn(...queued);
}, remaining);
}
};
}
The pendingArgs variable is what separates a good throttle from a broken one. Without it, the trailing call would fire with the arguments of the first suppressed call rather than the most recent one, which is exactly the value you want. This version runs on the leading edge and once more on the trailing edge.
// throttle demo
import { throttle } from './throttle.js';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const calls = [];
const search = throttle((text) => calls.push(text), 100);
search('s');
search('sh');
search('sho');
search('shoe');
await sleep(300);
console.log('calls:', calls); // [ 's', 'shoe' ]
The same four keystrokes produced two calls: one immediately, one at the end of the interval carrying the newest text. If you need the opposite behaviour — a call on every interval but never at the start — drop the leading-edge branch and keep only the timer.
Which one should you use?
The choice comes down to whether the user is still typing or still moving. If you want the final state, wait for the user to finish. If you want continuous feedback while the user moves, run at a fixed rate.
| debounce | throttle | |
|---|---|---|
| Runs | Once, after the activity stops | At most once per interval, during the activity |
| First run | After the wait | Immediately |
| Typical use | Search suggestions, autosave, form validation | Scroll handlers, resize handlers, progress reporting |
| Trade-off | Feels delayed at the start | May run with slightly stale values |
Rate limiting is not the only defence. A search box that fires one debounced request still leaves the problem of an old response arriving after a new one, and cancelling the previous request with an AbortController is the fix for that.
How do you cancel work that is no longer needed?
Debouncing stops you sending extra requests; it does not stop a request that is already in flight from finishing later and overwriting newer results. Keep a reference to the controller for the current request and abort it before starting the next one.
// search.js
import { debounce } from './debounce.js';
const input = document.querySelector('#search');
const results = document.querySelector('#results');
let current = null;
const runSearch = debounce(async (text) => {
current?.abort();
current = new AbortController();
const response = await fetch(`/api/search?q=${encodeURIComponent(text)}`, {
signal: current.signal,
});
const data = await response.json();
results.replaceChildren(
...data.items.map((item) => {
const li = document.createElement('li');
li.textContent = item.name;
return li;
}),
);
}, 250);
input.addEventListener('input', (event) => {
runSearch(event.target.value);
});
The optional chaining on current handles the first call, when no request has started yet. Aborting the previous request means the browser discards its response, so a slow answer for sho can never replace the answer for shoe.
What to remember about debounce and throttle
- Debounce runs once after the activity stops, with the most recent arguments.
- Throttle runs at most once per interval, starting immediately and again at the end.
- Keep the latest arguments in a variable, or the trailing throttled call uses stale values.
- Use debounce for text input and autosave; use throttle for scroll and resize handlers.
- Rate limiting does not cancel work in flight. Abort the previous request with an
AbortController.
Try it yourself: Take the debounce demo and change the wait to 1000 milliseconds. Type four characters and watch the call arrive a full second later. Then wrap the same handler in the throttle implementation instead and count the calls again — two instead of one. Finally, point the search demo at an endpoint that takes three seconds to answer and confirm that the abort in the last snippet stops an earlier response from overwriting a newer one.