Interview Importance: 🔴 Critical — One of the most commonly asked JavaScript interview questions. Expected at every level from junior to senior.
1️⃣ What is Debounce?
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.
Real-World Analogy
Think of an elevator door:
- When someone enters, the door waits a few seconds before closing
- If another person enters during that wait, the timer resets
- The door only closes after no one has entered for the full delay period
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!
2️⃣ Why Use Debounce?
Common Use Cases
| 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 |
Performance Impact
// Typing "hello world" triggers: // Without debounce: 11 function calls // With 300ms debounce: 1-2 function calls (after pauses)
3️⃣ How Debounce Works — Implementation
Basic Implementation
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); }; }
🔍 Dry Run: Step-by-Step Execution
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.
4️⃣ Understanding Key Concepts
Why 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
Why 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));
Why Return a Function (Closure)?
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); }; }
5️⃣ Advanced Implementations
With Immediate/Leading Edge Option
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
With Cancel and Flush Methods
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();
With Max Wait (Throttle Fallback)
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);
6️⃣ React Implementations
Using useCallback + useRef
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} />; }
Debounced Value Hook
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)} />; }
7️⃣ Debounce vs Throttle
| 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 |
Visual Comparison
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)
8️⃣ Common Interview Questions
Q1: Implement a debounce function
Answer: See Section 3 for basic implementation.
Q2: What happens if you call debounce without clearing the timeout?
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
Q3: How would you test a debounced function?
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'); });
Q4: What's wrong with this implementation?
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); }; }
Q5: Implement debounce with leading and trailing options
Answer: See Section 5 "With Immediate/Leading Edge Option".
9️⃣ Common Pitfalls
Pitfall 1: Creating New Debounced Function on Every Render
// ❌ 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)} />; }
Pitfall 2: Not Cleaning Up on Unmount
// ❌ 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 }; }, []);
Pitfall 3: Losing 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) };
🔟 Time Complexity Analysis
| 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.
Summary
| 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 |
Key Takeaways
- Debounce waits for a pause — ideal for search inputs
- Always clear previous timeout — otherwise all calls will fire
- Preserve
thiscontext — use regular functions, not arrows - Clean up in React — cancel timers on unmount
- Know the difference from throttle — debounce waits, throttle limits
📚 Further Reading
Test your understanding with 3 quick questions