Debounce and throttle functions in plain JavaScript
Two small dependency-free helpers that limit how often a function runs: debounce waits for a pause, throttle runs at most once per interval.
Some events fire dozens of times per second: typing, scrolling, resizing, mouse moves. Running expensive work on every event wastes CPU and network. Debounce and throttle are the two standard ways to slow it down.
Which one do I need?
- Debounce: run once, after the events stop for a given time. Use it for search-as-you-type, autosave, and validating a field after the user finishes typing.
- Throttle: run at most once per interval while events keep coming. Use it for scroll position tracking, resize handling, and progress reporting.
Imagine an event every 50 ms for half a second. A 200 ms debounce calls your function once, about 200 ms after the last event. A 200 ms throttle calls it immediately, then roughly every 200 ms, and once more at the end with the latest arguments.
The script
Save as timing.js. The functions work in browsers and Node. Running node timing.js prints a short demo that simulates rapid events.
function debounce(fn, wait = 300) {
let timer;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
}
debounced.cancel = () => clearTimeout(timer);
return debounced;
}
function throttle(fn, interval = 300) {
let last = 0;
let timer;
let pendingArgs;
return function throttled(...args) {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
clearTimeout(timer);
timer = undefined;
last = now;
fn.apply(this, args);
} else {
pendingArgs = args;
timer ??= setTimeout(() => {
last = Date.now();
timer = undefined;
fn.apply(this, pendingArgs);
}, remaining);
}
};
}
if (typeof module !== "undefined" && require.main === module) {
const start = Date.now();
const log = (label) => (value) => console.log(`${label} ${value} at ~${Math.round((Date.now() - start) / 50) * 50} ms`);
const search = debounce(log("debounced:"), 200);
const scroll = throttle(log("throttled:"), 200);
let n = 0;
const id = setInterval(() => {
n++;
search(`q${n}`);
scroll(`y${n}`);
if (n === 10) clearInterval(id);
}, 50);
}
if (typeof module !== "undefined") module.exports = { debounce, throttle };
Using it in a browser
const input = document.querySelector("#search");
const runSearch = debounce(async (query) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
renderResults(await res.json());
}, 300);
input.addEventListener("input", (e) => runSearch(e.target.value));
window.addEventListener("scroll", throttle(() => {
saveScrollPosition(window.scrollY);
}, 200), { passive: true });
How they work
debounce keeps a single timer. Every call cancels the previous timer and starts a new one, so the function only runs when the timer survives a full quiet period. cancel() lets you drop a pending call, for example when a component is removed.
throttle remembers when it last ran. If enough time has passed, it runs right away (the leading call). Otherwise it stores the newest arguments and schedules one trailing call for when the interval ends, so the final state is never lost.
Gotchas
- Create the debounced function once and reuse it. Calling
debounce(fn)inside an event handler creates a fresh timer each time and debounces nothing. In React, keep it inuseMemooruseRef. - Both helpers forward
this, so they work as object methods, but arrow functions passed in keep their ownthis. - A debounced async function can still overlap with a slow earlier request. For search boxes, also cancel the previous request with an
AbortControlleror ignore stale responses. - For animation tied to the screen refresh,
requestAnimationFrameis usually a better throttle than a fixed interval. - If you already use Lodash, its
_.debounceand_.throttleadd options likemaxWaitand leading/trailing switches.
Repos in the wild
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- commitpedia-admin +4,010 chars