Debounce & Throttle
โฑ๏ธ ~4-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ Debounce is an elevator door โ it waits for everyone to step in before closing. Throttle is a heartbeat โ it fires at steady intervals no matter how busy things get.
๐ Debounce โ elevator door
The door sensor fires a "should I close?" check on every movement it detects. But the door waits 3 seconds after the last movement before closing. Someone jumps in at 2.9 seconds? Clock resets. The door only closes after a full 3-second pause in activity.
In code: clearTimeout + setTimeout. Each new event cancels the pending timer and starts a fresh one. Only the final event's timer survives to fire.
๐ Throttle โ heartbeat
Your heart doesn't beat faster just because you're excited โ it maintains a rhythm. A scroll handler firing at 60fps is the same idea: it runs on a clock, not on demand. No matter how many scroll events arrive, the handler runs at most once per 16ms.
In code: track lastRun timestamp. If now - lastRun < wait, skip. Otherwise fire and update lastRun.
The quick cheat sheet
Search input (debounce)
Wait until the user stops typing, then fire one request. Trailing edge.
Submit button (debounce)
Fire on first click, ignore for 500ms. Leading edge prevents double-submit.
Scroll handler (throttle)
Run at most once per 16ms. Keeps paint in sync with display refresh.
Mousemove / drag (throttle)
16โ50ms throttle. Smooth enough, not wasteful.
Window resize (debounce)
Wait until resize ends, then recalculate layout. Trailing edge.
API polling (throttle)
Max one call per 2s even if the user hammers refresh.
The three decisions
- Debounce or throttle? Need the final value โ debounce. Need regular updates โ throttle.
- Leading or trailing edge? Immediate feedback (clicks, keystrokes) โ leading. Calm after the storm (search, resize) โ trailing.
- What wait value? Human perception threshold is ~100ms. UI responses: 16โ50ms. Search inputs: 200โ300ms. Form validation: 300โ500ms.
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Waits for activity to stop before firing. Resets the timer on every new event.
Timeline โ wait=200ms
Challenge
Explore all 5 patterns โ debounce, throttle, leading edge, trailing edge, and implementation. Pay attention to which events fire and which get dropped.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q1: "Debounce for search input vs throttle for scroll โ why?"
| 1 | // Search input: you want the FINAL value after the user stops typing |
| 2 | // Intermediate keystrokes โ wasted API calls, racing responses |
| 3 | const handleSearch = debounce((q) => { |
| 4 | fetch(`/api/search?q=${q}`).then(setResults); |
| 5 | }, 300); // fire 300ms after last keystroke |
| 6 | ย |
| 7 | // Scroll handler: you want REGULAR updates as the user scrolls |
| 8 | // One call per 16ms โ smooth 60fps. One per render frame. |
| 9 | const handleScroll = throttle(() => { |
| 10 | setScrollY(window.scrollY); |
| 11 | updateNavOpacity(); |
| 12 | }, 16); |
Key distinction: debounce โ final value; throttle โ regular cadence. Swapping them breaks the UX: a throttled search fires on every 300ms tick even while the user is still typing; a debounced scroll handler only updates when scrolling stops โ no smooth transitions.
Q2: "Leading vs trailing edge โ when does each make sense?"
| 1 | // TRAILING (default debounce) โ search, resize, validation |
| 2 | // "Wait for silence, then act on the final value" |
| 3 | const search = debounce(query => fetchResults(query), 300); |
| 4 | ย |
| 5 | // LEADING โ submit buttons, game inputs, tooltips |
| 6 | // "Act immediately, ignore rapid repeats" |
| 7 | const submit = debounce(handleSubmit, 500, { leading: true, trailing: false }); |
| 8 | // User clicks submit โ fires immediately โ next 500ms clicks ignored |
| 9 | ย |
| 10 | // BOTH edges (lodash default with maxWait) |
| 11 | // Fire at start of burst AND at end of burst |
| 12 | const autosave = debounce(save, 1000, { leading: true, trailing: true }); |
Leading edge = "act now, cool down." Trailing edge = "wait for calm, then act." Leading for clicks that should be instant; trailing for inputs where you want the completed value.
Q3: "How do you implement cancelable debounce?"
| 1 | function debounce(fn, wait) { |
| 2 | let timer; |
| 3 | const debounced = (...args) => { |
| 4 | clearTimeout(timer); |
| 5 | timer = setTimeout(() => fn(...args), wait); |
| 6 | }; |
| 7 | debounced.cancel = () => clearTimeout(timer); |
| 8 | debounced.flush = (...args) => { clearTimeout(timer); fn(...args); }; |
| 9 | return debounced; |
| 10 | } |
| 11 | ย |
| 12 | // In React โ CRITICAL: stable reference via useRef |
| 13 | function SearchBox() { |
| 14 | const search = useRef(debounce(fetchResults, 300)); |
| 15 | ย |
| 16 | useEffect(() => { |
| 17 | return () => search.current.cancel(); // cleanup on unmount |
| 18 | }, []); |
| 19 | ย |
| 20 | return <input onChange={e => search.current(e.target.value)} />; |
| 21 | } |
๐ Bug: new debounced function on every render
| 1 | // โ BUG: fresh closure every render โ timer never accumulates |
| 2 | function SearchBox({ onSearch }) { |
| 3 | const debouncedSearch = debounce(onSearch, 300); // โ recreated! |
| 4 | return <input onChange={e => debouncedSearch(e.target.value)} />; |
| 5 | } |
| 6 | ย |
| 7 | // โ FIX 1: useRef (stable across renders) |
| 8 | function SearchBox({ onSearch }) { |
| 9 | const debouncedSearch = useRef(debounce(onSearch, 300)); |
| 10 | return <input onChange={e => debouncedSearch.current(e.target.value)} />; |
| 11 | } |
| 12 | ย |
| 13 | // โ FIX 2: useMemo (recreates only when dep changes) |
| 14 | function SearchBox({ onSearch }) { |
| 15 | const debouncedSearch = useMemo( |
| 16 | () => debounce(onSearch, 300), |
| 17 | [onSearch] // useCallback for onSearch to avoid churn |
| 18 | ); |
| 19 | return <input onChange={e => debouncedSearch(e.target.value)} />; |
| 20 | } |
This is the single most common debounce bug in React. Each render creates a new function with a fresh timer = undefined in its closure. Every keystroke starts a timer that immediately gets garbage-collected with the old render.
The Deep Dive
โ Spec refs, engine internals, the minutiae.requestAnimationFrame as a 60fps throttle
requestAnimationFrame gives you a browser-managed throttle that syncs exactly to the display refresh rate (typically 60fps = 16.67ms). Unlike a manual throttle, rAF automatically pauses when the tab is hidden โ zero battery waste.
| 1 | // Manual throttle at 16ms โ works but slightly off-beat with paint |
| 2 | const handleScroll = throttle(() => updateLayout(), 16); |
| 3 | ย |
| 4 | // rAF throttle โ syncs to actual paint cycle |
| 5 | function rafThrottle(fn) { |
| 6 | let scheduled = false; |
| 7 | return (...args) => { |
| 8 | if (scheduled) return; |
| 9 | scheduled = true; |
| 10 | requestAnimationFrame(() => { |
| 11 | fn(...args); |
| 12 | scheduled = false; |
| 13 | }); |
| 14 | }; |
| 15 | } |
| 16 | const handleScroll = rafThrottle(() => updateLayout()); |
The rAF variant coalesces all scroll events within one frame into a single call. If the user scrolls 12px between two paint frames, your handler runs once at the end with the final position โ not 12 times.
Lodash options: maxWait and flush
| 1 | import { debounce } from 'lodash'; |
| 2 | ย |
| 3 | // maxWait: guarantee execution even during extended bursts |
| 4 | // Without maxWait: user typing for 5s straight = 0 calls |
| 5 | // With maxWait: fires every 1s during typing, plus at the end |
| 6 | const autosave = debounce(save, 300, { maxWait: 1000 }); |
| 7 | ย |
| 8 | // cancel: discard the pending call |
| 9 | autosave.cancel(); // e.g. component unmount, navigation away |
| 10 | ย |
| 11 | // flush: fire immediately (like calling the original fn) |
| 12 | autosave.flush(); // e.g. form submit โ don't wait for debounce |
| 13 | ย |
| 14 | // pending: check if a call is queued |
| 15 | if (autosave.pending()) console.log("save queued..."); |
React: stable debounce with useRef + useCallback
| 1 | // Pattern 1: useRef for a truly stable debounced fn |
| 2 | // Pro: never recreated. Con: onSearch ref may go stale. |
| 3 | function useDebounced(fn, wait) { |
| 4 | const ref = useRef(fn); |
| 5 | useEffect(() => { ref.current = fn; }); // keep ref fresh |
| 6 | ย |
| 7 | const debounced = useRef( |
| 8 | debounce((...args) => ref.current(...args), wait) |
| 9 | ); |
| 10 | ย |
| 11 | useEffect(() => () => debounced.current.cancel(), []); |
| 12 | ย |
| 13 | return debounced.current; |
| 14 | } |
| 15 | ย |
| 16 | // Pattern 2: useMemo + useCallback (simpler, slightly more GC) |
| 17 | function SearchBox({ onSearch }) { |
| 18 | const stableSearch = useCallback(onSearch, []); // or your deps |
| 19 | const debouncedSearch = useMemo( |
| 20 | () => debounce(stableSearch, 300), |
| 21 | [stableSearch] |
| 22 | ); |
| 23 | useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); |
| 24 | return <input onChange={e => debouncedSearch(e.target.value)} />; |
| 25 | } |
Race conditions in async debounced functions
A debounced async function can still produce out-of-order results if the previous call resolves after the latest one. Use AbortController or a sequence counter to discard stale responses.
| 1 | // โ RACE: slow response from q="re" arrives after q="react" |
| 2 | const debouncedSearch = debounce(async (q) => { |
| 3 | const results = await fetch(`/api?q=${q}`).then(r => r.json()); |
| 4 | setResults(results); // may be stale! |
| 5 | }, 300); |
| 6 | ย |
| 7 | // โ FIX 1: AbortController โ cancel in-flight requests |
| 8 | let controller; |
| 9 | const debouncedSearch = debounce((q) => { |
| 10 | controller?.abort(); |
| 11 | controller = new AbortController(); |
| 12 | fetch(`/api?q=${q}`, { signal: controller.signal }) |
| 13 | .then(r => r.json()) |
| 14 | .then(setResults) |
| 15 | .catch(e => { if (e.name !== 'AbortError') throw e; }); |
| 16 | }, 300); |
| 17 | ย |
| 18 | // โ FIX 2: sequence counter โ ignore older responses |
| 19 | let seq = 0; |
| 20 | const debouncedSearch = debounce(async (q) => { |
| 21 | const id = ++seq; |
| 22 | const results = await fetch(`/api?q=${q}`).then(r => r.json()); |
| 23 | if (id === seq) setResults(results); // only keep latest |
| 24 | }, 300); |
Quick-reference: patterns to know cold
debounce(fn, wait)
Timer in closure. clearTimeout + setTimeout on every call. Only last timer fires.
throttle(fn, wait)
lastRun timestamp in closure. Skip if now-lastRun < wait. Update lastRun on fire.
leading debounce
Fire on first call, set flag, clear flag after wait. Ignore calls while flag set.
cancelable debounce
Expose .cancel() = clearTimeout(timer). Always call in cleanup/unmount.
rAF throttle
requestAnimationFrame with scheduled flag. Zero cost when tab hidden.
maxWait debounce
Lodash option. Guarantees at least one call per maxWait even during long bursts.
Interview Questions
โ Real questions from real interviews โ with answers.Debounce delays until inactivity; throttle guarantees at most one call per time period.
Store a timer ID in closure; clearTimeout + setTimeout on each call; only the last scheduled invocation fires.
Responses can arrive out of order โ the last response shown might be from an earlier (stale) request.
Track lastCall timestamp; fire immediately on leading edge; schedule trailing call only if invoked during cooldown.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is the ideal wait time for debouncing a form validation field?