Interview Importance: 🔴 Critical — One of the most commonly asked JavaScript interview questions. Expected at every level from junior to senior.
Debounce is a technique that delays the execution of a function until a certain amount of time has passed since the last time it was invoked. If the function is called again before the delay completes, the timer resets.
Think of an elevator door:
Without Debounce: With Debounce:
----------------- -----------------
User types: H User types: H
-> API call User types: e
User types: e User types: l
-> API call User types: l
User types: l User types: o
-> API call ... waits 300ms ...
User types: l -> ONE API call for "Hello"
-> API call
User types: o
-> API call
5 API calls! 1 API call!
| Use Case | Problem Without Debounce | Solution With Debounce |
|---|---|---|
| Search Input | API call on every keystroke | API call after user stops typing |
| Window Resize | Layout calculation on every pixel | Calculate after resize ends |
| Button Click | Multiple submissions on rapid clicks | Single submission after clicks stop |
| Auto-save | Save on every character | Save after user pauses typing |
| Scroll Events | Hundreds of events per scroll | Process after scroll stops |
// Typing "hello world" triggers: // Without debounce: 11 function calls // With 300ms debounce: 1-2 function calls (after pauses)
function debounce(fn, delay) { let timerId; return function(...args) { // Clear any existing timer clearTimeout(timerId); // Set a new timer timerId = setTimeout(() => { fn.apply(this, args); }, delay); }; }
const log = (msg) => console.log(msg); const debouncedLog = debounce(log, 1000); // Timeline of calls: debouncedLog("A"); // t = 0ms debouncedLog("B"); // t = 300ms debouncedLog("C"); // t = 600ms // ... user stops typing ... // Output at t = 1600ms: "C"
Time | Action | timerId State
---------|----------------------------------|------------------
0ms | debouncedLog("A") called | timerId = undefined
| clearTimeout(undefined) -> no-op |
| setTimeout -> timer1 for t=1000ms | timerId = timer1
| |
300ms | debouncedLog("B") called |
| clearTimeout(timer1) -> CANCELLED |
| setTimeout -> timer2 for t=1300ms | timerId = timer2
| |
600ms | debouncedLog("C") called |
| clearTimeout(timer2) -> CANCELLED |
| setTimeout -> timer3 for t=1600ms | timerId = timer3
| |
1000ms | timer1 would fire (CANCELLED) | (already cancelled)
1300ms | timer2 would fire (CANCELLED) | (already cancelled)
1600ms | timer3 FIRES! log("C") executes | Output: "C"
Result: Only "C" is logged, 1000ms after the last call.
clearTimeout(timerId)?// Without clearTimeout: debouncedLog("A"); // Creates timer for A debouncedLog("B"); // Creates timer for B debouncedLog("C"); // Creates timer for C // Result: A, B, C all logged (defeats purpose!) // With clearTimeout: debouncedLog("A"); // Creates timer for A debouncedLog("B"); // Cancels A, creates timer for B debouncedLog("C"); // Cancels B, creates timer for C // Result: Only C logged after delay
fn.apply(this, args)?Preserves the correct this context and passes arguments:
const obj = { name: "Button", handleClick: debounce(function(event) { console.log(this.name); // "Button" - correct context console.log(event.type); // "click" - arguments passed }, 300) }; button.addEventListener("click", obj.handleClick.bind(obj));
The returned function "remembers" timerId between calls:
function debounce(fn, delay) { let timerId; // This variable persists across calls return function(...args) { // Each call accesses the SAME timerId clearTimeout(timerId); timerId = setTimeout(() => fn.apply(this, args), delay); }; }
Sometimes you want to execute immediately on first call, then debounce subsequent calls:
function debounce(fn, delay, { leading = false, trailing = true } = {}) { let timerId; let lastArgs; return function(...args) { const shouldCallImmediately = leading && !timerId; lastArgs = args; clearTimeout(timerId); if (shouldCallImmediately) { fn.apply(this, args); } timerId = setTimeout(() => { if (trailing && lastArgs) { fn.apply(this, lastArgs); } timerId = null; lastArgs = null; }, delay); }; } // Usage: const logLeading = debounce(console.log, 1000, { leading: true, trailing: false }); logLeading("A"); // Logs immediately logLeading("B"); // Ignored logLeading("C"); // Ignored // After 1000ms of inactivity, can trigger again
function debounce(fn, delay) { let timerId; let lastArgs; let lastThis; function debounced(...args) { lastArgs = args; lastThis = this; clearTimeout(timerId); timerId = setTimeout(() => { fn.apply(lastThis, lastArgs); timerId = null; }, delay); } // Cancel pending execution debounced.cancel = function() { clearTimeout(timerId); timerId = null; lastArgs = null; lastThis = null; }; // Execute immediately debounced.flush = function() { if (timerId) { clearTimeout(timerId); fn.apply(lastThis, lastArgs); timerId = null; } }; return debounced; } // Usage: const debouncedSave = debounce(save, 1000); debouncedSave(data); // User navigates away - save immediately debouncedSave.flush(); // Or cancel if not needed debouncedSave.cancel();
Ensures function is called at least once every maxWait ms:
function debounce(fn, delay, maxWait = Infinity) { let timerId; let lastCallTime = 0; return function(...args) { const now = Date.now(); const timeSinceLastCall = now - lastCallTime; clearTimeout(timerId); // If maxWait exceeded, execute immediately if (timeSinceLastCall >= maxWait) { lastCallTime = now; fn.apply(this, args); return; } timerId = setTimeout(() => { lastCallTime = Date.now(); fn.apply(this, args); }, delay); }; } // Usage: Debounce with max 2 second wait const debouncedScroll = debounce(handleScroll, 300, 2000);
import { useCallback, useRef, useEffect } from 'react'; function useDebounce(callback, delay) { const timeoutRef = useRef(null); const callbackRef = useRef(callback); // Update callback ref when callback changes useEffect(() => { callbackRef.current = callback; }, [callback]); // Cleanup on unmount useEffect(() => { return () => clearTimeout(timeoutRef.current); }, []); return useCallback((...args) => { clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { callbackRef.current(...args); }, delay); }, [delay]); } // Usage: function SearchComponent() { const [query, setQuery] = useState(''); const debouncedSearch = useDebounce((searchTerm) => { console.log('Searching for:', searchTerm); // API call here }, 300); const handleChange = (e) => { setQuery(e.target.value); debouncedSearch(e.target.value); }; return <input value={query} onChange={handleChange} />; }
import { useState, useEffect } from 'react'; function useDebouncedValue(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timerId = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timerId); }, [value, delay]); return debouncedValue; } // Usage: function SearchComponent() { const [query, setQuery] = useState(''); const debouncedQuery = useDebouncedValue(query, 300); useEffect(() => { if (debouncedQuery) { // API call with debouncedQuery console.log('Searching for:', debouncedQuery); } }, [debouncedQuery]); return <input value={query} onChange={(e) => setQuery(e.target.value)} />; }
| Aspect | Debounce | Throttle |
|---|---|---|
| When it fires | After a pause in calls | At regular intervals |
| Frequency | Once after delay | At most once per delay |
| Use case | Search input, auto-save | Scroll, resize, rate limiting |
| Behavior | Waits for silence | Limits frequency |
Events: --●--●●●●●------●●●------●----------●--------
0 50 500 700 900ms
Debounce (200ms):
-----------------●-------------●------------●
500ms 900ms 1100ms
(fires 200ms after last event in each burst)
Throttle (200ms):
--●----●----●----●----●----●----●----●------
0 200 400 500 700 800 900 1000ms
(fires at most every 200ms)
Answer: See Section 3 for basic implementation.
Answer: Every call would schedule a new execution, defeating the purpose. All calls would eventually fire:
// Bad: No clearTimeout function badDebounce(fn, delay) { return function(...args) { setTimeout(() => fn.apply(this, args), delay); // No clearing! }; } // Calling badDebounce("A"), ("B"), ("C") rapidly // Results in: "A", "B", "C" all firing after delay
Answer: Use fake timers (Jest):
jest.useFakeTimers(); test('debounce delays execution', () => { const fn = jest.fn(); const debounced = debounce(fn, 1000); debounced('a'); debounced('b'); debounced('c'); expect(fn).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledWith('c'); });
function debounce(fn, delay) { let timerId; return (...args) => { clearTimeout(timerId); timerId = setTimeout(() => fn(...args), delay); }; }
Answer: Arrow function doesn't preserve this context from caller. Should use function keyword and fn.apply(this, args):
// Fix: function debounce(fn, delay) { let timerId; return function(...args) { // Regular function clearTimeout(timerId); timerId = setTimeout(() => fn.apply(this, args), delay); }; }
Answer: See Section 5 "With Immediate/Leading Edge Option".
// ❌ BAD: New debounced function each render function SearchComponent() { const debouncedSearch = debounce(search, 300); // Recreated every render! return <input onChange={(e) => debouncedSearch(e.target.value)} />; } // ✅ GOOD: Stable reference function SearchComponent() { const debouncedSearch = useMemo( () => debounce(search, 300), [] ); return <input onChange={(e) => debouncedSearch(e.target.value)} />; }
// ❌ BAD: Timer might fire after unmount useEffect(() => { const debouncedFn = debounce(updateState, 300); window.addEventListener('resize', debouncedFn); return () => window.removeEventListener('resize', debouncedFn); // Timer might still fire! }, []); // ✅ GOOD: Cancel on cleanup useEffect(() => { const debouncedFn = debounce(updateState, 300); window.addEventListener('resize', debouncedFn); return () => { window.removeEventListener('resize', debouncedFn); debouncedFn.cancel?.(); // Cancel pending timer }; }, []);
this Context// ❌ BAD: Arrow function loses `this` const obj = { name: 'Test', log: debounce(() => { console.log(this.name); // undefined - wrong `this` }, 300) }; // ✅ GOOD: Regular function preserves `this` const obj = { name: 'Test', log: debounce(function() { console.log(this.name); // "Test" - correct }, 300) };
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| Call debounced function | O(1) | O(1) |
| clearTimeout | O(1) | - |
| setTimeout | O(1) | - |
| Overall | O(1) | O(1) |
The debounce function itself is O(1) — the complexity of fn is separate.
| Concept | Description |
|---|---|
| Debounce | Delays execution until calls stop for specified duration |
| Timer Reset | Each call cancels previous timer and starts new one |
| Leading Edge | Execute immediately on first call |
| Trailing Edge | Execute after delay (default behavior) |
| Use Cases | Search, auto-save, resize handlers |
this context — use regular functions, not arrowsTest your understanding with 3 quick questions