Debounce With Leading + Trailing Edges and a cancel() Method
The trailing-only debounce in every tutorial works for search inputs and breaks on click handlers. Here is the lodash-style version with leading edge, cancel(), and flush(), in 30 lines.
By @hiroshiward
December 18, 2025
·
Updated May 18, 2026
915 views
13
4.4 (8)
// The production debounce I keep in every project. Defaults match lodash:
// leading=false, trailing=true. The two extra methods (cancel, flush) are
// what makes it survive contact with React unmount and form-submit handlers.
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null;
let lastArgs = null;
let lastThis = null;
let result;
function invoke() {
const args = lastArgs; const ctx = lastThis;
lastArgs = null; lastThis = null;
result = fn.apply(ctx, args);
}
function debounced(...args) {
const callNow = leading && timer === null;
lastArgs = args; lastThis = this;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (trailing && lastArgs) invoke();
}, wait);
if (callNow) invoke();
return result;
}
debounced.cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null; lastArgs = null; lastThis = null;
};
debounced.flush = () => {
if (timer !== null) {
clearTimeout(timer); timer = null;
if (lastArgs) invoke();
}
return result;
};
return debounced;
}
let n = 0;
const log = debounce((label) => { n++; console.log(`fired: ${label} #${n}`); }, 50);
log('a'); log('b'); log('c');
setTimeout(() => log('d'), 100);
setTimeout(() => { log('e'); log('f'); log.flush(); }, 200);
setTimeout(() => { log('g'); log.cancel(); }, 300);
setTimeout(() => console.log('total fires:', n), 400);The four behaviors compose naturally on top of one timer reference. Leading is if (leading && timer === null), trailing is the contents of the timeout callback, cancel clears the timer and forgets the queued args, and flush clears the timer but invokes anyway with the queued args. The most common bug I have shipped in this code is forgetting to capture this and args together: a debounced method on a class instance must keep both for the fn.apply(ctx, args) call inside invoke, or you lose the this binding silently. The result-caching is a small lodash-compat detail that lets you read the previous return value off a debounced getter; few callers use it but its absence is surprising.
// The textbook trailing-only debounce makes search feel laggy: nothing fires
// until you stop typing. leading=true gives an instant first keystroke (so the
// spinner appears immediately), trailing=true keeps the final value.
// Tight inline version of debounce focused on the leading+trailing angle
// (cancel/flush from accordion 1 omitted; this demo does not unmount).
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null, lastArgs = null;
return function (...args) {
const callNow = leading && timer === null;
lastArgs = args;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => { timer = null; if (trailing && lastArgs) fn(...lastArgs); }, wait);
if (callNow) fn(...lastArgs);
};
}
function search(q) { console.log(`API search: "${q}"`); }
const onChange = debounce(search, 80, { leading: true, trailing: true });
const keystrokes = ['c', 'cl', 'cla', 'clau', 'claud', 'claude'];
let i = 0;
(function tick() {
if (i >= keystrokes.length) { setTimeout(() => console.log('done'), 200); return; }
onChange(keystrokes[i++]);
setTimeout(tick, 20);
})();Leading-edge behavior is what makes a search input feel responsive: the spinner appears the moment the user types their first character, then suppresses every keystroke for the next 80ms, then fires again with the final value 80ms after the last keystroke. Trailing-only debounce makes the same input feel broken to a typing-speed user; the screen sits empty for almost a second between input and feedback. The cancel hook on this version is what I wire into a React unmount: useEffect(() => () => onChange.cancel(), []) so a leftover timer does not fire after the component is gone, which would normally produce a 'setState on unmounted component' warning.
// Throttle is the sibling primitive: 'fire at most once per N ms, but DO fire
// on the leading edge'. Standalone is cleaner than building it from debounce
// because throttle tracks the last-call timestamp and schedules the trailing
// call to fit the cadence exactly.
function throttle(fn, wait) {
let lastCall = 0, timer = null, trailingArgs = null;
function fire(args) { lastCall = Date.now(); trailingArgs = null; fn(...args); }
return function (...args) {
const elapsed = Date.now() - lastCall;
if (elapsed >= wait) {
if (timer !== null) { clearTimeout(timer); timer = null; }
fire(args);
} else {
trailingArgs = args;
if (timer === null) {
timer = setTimeout(() => { timer = null; if (trailingArgs) fire(trailingArgs); }, wait - elapsed);
}
}
};
}
let calls = 0;
const onScroll = throttle((y) => { calls++; console.log(`scroll y=${y} calls=${calls}`); }, 50);
for (let y = 0; y < 200; y += 20) setTimeout(() => onScroll(y), y);
setTimeout(() => console.log('final calls:', calls), 400);Throttle and debounce sound similar but have opposite goals: debounce delays until quiet, throttle paces a stream. The cleanest version uses the timestamp of the last call rather than just a timer flag, because it tells you exactly how long until the next slot opens; that lets the trailing-call timer be set to wait - elapsed instead of wait, which preserves the regular cadence. I have used this exact throttle on a scroll handler that updates a sticky-header position; without it the handler fires 100 times per second and pegs the main thread. Without the trailing-arg capture, the very last scroll position before the user stops moving gets lost, which is a far more annoying bug than it sounds.
