On this page
Debouncehigh-yield
Last reviewed 22 Sept 2026
Problem
Implement debounce(fn, wait, options?). The returned function delays calling fn until wait ms have passed since the last call. Typical use: run a search only after the user stops typing.
const save = debounce((text) => console.log('saving', text), 300);save('h'); save('he'); save('hel'); // only "saving hel", 300 ms after the last callsave.cancel(); // drop a pending callsave.flush(); // run a pending call nowClarifying questions
- Trailing only, or also a
leadingoption (fire on the first call, then wait)? Start trailing; addleadingas an extension. - Must
thisand the latest arguments be passed tofn? Yes. - Do we need
cancel()andflush()? Often asked as a follow-up — plan for them. - Should the debounced function return
fn’s result? Return the last result (it cannot return the future one).
Approach
Keep one timer id in a closure. Every call clears the previous timer and starts a new one, so fn only runs once the calls stop for wait ms. Store the latest this and arguments so the delayed call uses them.
Step-by-step build
Step 1 — basic trailing debounce
function debounce(fn, wait) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), wait); };}Step 2 — keep this
An arrow function captures the wrong this. Use a regular function and remember the context.
function debounce(fn, wait) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); };}Step 3 — cancel and flush
function debounce(fn, wait) { let timer = null, lastArgs, lastThis; const invoke = () => { timer = null; const args = lastArgs, ctx = lastThis; lastArgs = lastThis = undefined; return fn.apply(ctx, args); }; function debounced(...args) { lastArgs = args; lastThis = this; clearTimeout(timer); timer = setTimeout(invoke, wait); } debounced.cancel = () => { clearTimeout(timer); timer = null; lastArgs = lastThis = undefined; }; debounced.flush = () => { if (timer !== null) { clearTimeout(timer); return invoke(); } }; return debounced;}Step 4 — the leading option
With leading: true, the first call of a burst runs immediately. With trailing: false too, no call runs at the end of the burst.
function debounced(...args) { lastArgs = args; lastThis = this; const isFirst = timer === null; // no burst in progress clearTimeout(timer); timer = setTimeout(() => { timer = null; if (trailing && lastArgs) invoke(); // only if calls arrived after the leading one }, wait); if (leading && isFirst) invoke(); // invoke clears lastArgs}Final code
function debounce(fn, wait = 0, { leading = false, trailing = true } = {}) { let timer = null; let lastArgs; let lastThis; let result;
function invoke() { const args = lastArgs, ctx = lastThis; lastArgs = lastThis = undefined; result = fn.apply(ctx, args); return result; }
function debounced(...args) { lastArgs = args; lastThis = this; const isFirst = timer === null; clearTimeout(timer); timer = setTimeout(() => { timer = null; if (trailing && lastArgs) invoke(); }, wait); if (leading && isFirst) invoke(); return result; }
debounced.cancel = () => { clearTimeout(timer); timer = null; lastArgs = lastThis = undefined; };
debounced.flush = () => { if (timer === null) return result; clearTimeout(timer); timer = null; return lastArgs ? invoke() : result; };
return debounced;}Edge cases
wait = 0still defers to the next macrotask.leading: true, trailing: truewith a single call runsfnonce, not twice —invokeclearslastArgs.leading: false, trailing: falsenever callsfn; point it out as a meaningless configuration.cancel()after the timer fired is a no-op.- In React, create the debounced function once (
useMemo/useRef) and cancel it on unmount — otherwise every render makes a new timer.
Follow-ups
- Debounce vs throttle: debounce waits for silence; throttle runs at most once per interval during continuous calls — see throttle.
maxWait: guarantee a call at least everymaxWaitms even while calls keep coming (lodash has it) — track the burst start time.- Return a promise that resolves with the eventual result, rejecting superseded calls.
- Build
useDebouncedValue(value, delay)as a React hook.
Common mistakes
- Arrow function as the returned function, losing
this. - Using the first call’s arguments instead of the latest.
- Creating the timer variable inside the returned function, so every call has its own timer and nothing is debounced.
- Forgetting cleanup in components, so a delayed call runs after unmount.
Related
- Concepts: debouncing and throttling and closures in the JavaScript track.
- Next: throttle.