Debounce Function in JavaScript
Debouncing collapses a burst of calls into a single trailing invocation, which is the standard fix for noisy events like keystrokes, resize, and scroll. This snippet covers the canonical trailing-edge debounce, a variant with a manual `cancel` for component unmount, and a Promise-returning version that resolves only with the last call's result. Drop it into any UI handler that fires faster than the work it triggers.
615 views
17
function debounce(fn, wait) {
let timer = null;
return function debounced(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
};
}
const log = debounce((msg) => console.log('search:', msg), 30);
log('a');
log('ap');
log('apple');
// After ~30ms idle: search: appleThe classic implementation keeps one timer ID per debounced function and clears it on every call. As long as new calls keep arriving inside wait milliseconds, the timer keeps getting reset, so fn only fires after the burst goes quiet. Using fn.apply(this, args) preserves both the original this (handy when the debounced function is a class method) and the most recent argument set, which is what users expect for search-as-you-type. This is the version to default to for input handlers and resize listeners.
function debounceWithControls(fn, wait) {
let timer = null;
let pendingArgs = null;
let pendingThis = null;
function debounced(...args) {
pendingArgs = args;
pendingThis = this;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const a = pendingArgs;
const t = pendingThis;
pendingArgs = null;
pendingThis = null;
fn.apply(t, a);
}, wait);
}
debounced.cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
pendingArgs = null;
};
debounced.flush = () => {
if (timer) {
clearTimeout(timer);
timer = null;
const a = pendingArgs;
pendingArgs = null;
fn.apply(pendingThis, a);
}
};
return debounced;
}
const save = debounceWithControls((draft) => console.log('saved', draft), 50);
save({ id: 1 });
save({ id: 2 });
save.flush(); // saved { id: 2 }
save({ id: 3 });
save.cancel(); // pending call droppedComponent unmount and route changes need to drop pending work to avoid setState after unmount or stale network writes. Exposing cancel clears the timer and forgets the queued args; flush runs the pending call immediately, which is what a Save button wants when the user navigates away. Keeping pendingArgs and pendingThis separate from the timer closure is what makes both controls clean to implement. Reach for this version inside React effects, Vue setup, or any place where teardown matters.
function debouncePromise(fn, wait) {
let timer = null;
let pendingResolves = [];
let pendingArgs = null;
return function debounced(...args) {
pendingArgs = args;
if (timer) clearTimeout(timer);
return new Promise((resolve, reject) => {
pendingResolves.push({ resolve, reject });
timer = setTimeout(async () => {
const args2 = pendingArgs;
const subscribers = pendingResolves;
pendingArgs = null;
pendingResolves = [];
timer = null;
try {
const result = await fn(...args2);
subscribers.forEach(({ resolve: r }) => r(result));
} catch (err) {
subscribers.forEach(({ reject: rj }) => rj(err));
}
}, wait);
});
};
}
const search = debouncePromise(async (q) => `results for ${q}`, 20);
search('a').then(console.log);
search('ap').then(console.log);
search('apple').then(console.log);
// After ~20ms: each Promise resolves with 'results for apple'Async work like fetch plays badly with the classic debounce because callers expect a Promise back so they can await the result. This variant returns a Promise per call and resolves every queued caller with the SAME final result of the last invocation, which is exactly what type-ahead consumers want (intermediate calls don't fire their own request). Holding the resolver list separately from the timer closure means cancellation can later reject all pending Promises with AbortError if you extend the API. Use this whenever you debounce an async function and the result feeds back into UI state.
