๐Ÿณ IntuitiveFE
Login
โ† All concepts

Debounce & Throttle

โฑ๏ธ ~4-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

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

user event executed cancelled / skipped timer reset
0
100
200
300
400
500
600
700
800
900
1000
t=0msโ€”click โ€” timer set (fires at 200)
Gotcha: Creating a new debounced function on every React render is a common bug โ€” the timer never accumulates because each render discards the previous closure.
Key insight: Debounce collapses a burst of events into one call at the end of the burst. Perfect for search inputs, resize handlers, and form validation.
Explored:๐Ÿ›—๐Ÿ’“โšก๐Ÿ๐Ÿ”ง
๐ŸŽฏ

Challenge

Explore all 5 patterns โ€” debounce, throttle, leading edge, trailing edge, and implementation. Pay attention to which events fire and which get dropped.

Try it
๐ŸŽฏ

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?"

js
1// Search input: you want the FINAL value after the user stops typing
2// Intermediate keystrokes โ†’ wasted API calls, racing responses
3const 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.
9const 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?"

js
1// TRAILING (default debounce) โ€” search, resize, validation
2// "Wait for silence, then act on the final value"
3const search = debounce(query => fetchResults(query), 300);
4ย 
5// LEADING โ€” submit buttons, game inputs, tooltips
6// "Act immediately, ignore rapid repeats"
7const 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
12const 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?"

js
1function 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
13function 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

js
1// โœ— BUG: fresh closure every render โ†’ timer never accumulates
2function 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)
8function 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)
14function 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.

js
1// Manual throttle at 16ms โ€” works but slightly off-beat with paint
2const handleScroll = throttle(() => updateLayout(), 16);
3ย 
4// rAF throttle โ€” syncs to actual paint cycle
5function 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}
16const 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

js
1import { 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
6const autosave = debounce(save, 300, { maxWait: 1000 });
7ย 
8// cancel: discard the pending call
9autosave.cancel(); // e.g. component unmount, navigation away
10ย 
11// flush: fire immediately (like calling the original fn)
12autosave.flush(); // e.g. form submit โ€” don't wait for debounce
13ย 
14// pending: check if a call is queued
15if (autosave.pending()) console.log("save queued...");

React: stable debounce with useRef + useCallback

js
1// Pattern 1: useRef for a truly stable debounced fn
2// Pro: never recreated. Con: onSearch ref may go stale.
3function 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)
17function 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.

js
1// โœ— RACE: slow response from q="re" arrives after q="react"
2const 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
8let controller;
9const 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
19let seq = 0;
20const 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.
1/4

What is the ideal wait time for debouncing a form validation field?