Throttle vs Debounce: Complete Guide to When to Use Each
Master throttle and debounce patterns in JavaScript. Learn the differences, implementation strategies, and when to use each technique for optimal performance.
Table of Contents
- Introduction
- Understanding the Problem
- What is Debounce?
- What is Throttle?
- Key Differences: Throttle vs Debounce
- Implementing Debounce
- Implementing Throttle
- Real-World Use Cases
- Advanced Patterns and Variations
- Performance Considerations
- Common Pitfalls and Best Practices
- Testing Throttle and Debounce
- Conclusion
Introduction
Have you ever noticed how search bars wait until you stop typing before making API calls? Or how scroll events don’t fire on every single pixel movement? These performance optimizations are powered by two fundamental JavaScript patterns: throttle and debounce.
While both techniques limit how often a function executes, they serve different purposes and behave differently. Understanding when to use throttle vs debounce is crucial for building performant web applications that provide smooth user experiences without overwhelming browsers or servers with excessive function calls.
This comprehensive guide will teach you everything you need to know about throttle and debounce patterns. You’ll learn how they work, when to use each one, how to implement them from scratch, and discover advanced patterns used in production applications. By the end, you’ll be able to confidently choose the right technique for any scenario and optimize your application’s performance.
Understanding the Problem
Before diving into solutions, let’s understand the problem these patterns solve. Modern web applications handle numerous events that can fire extremely frequently:
- Scroll events: Fire hundreds of times per second as users scroll
- Resize events: Trigger continuously during window resizing
- Mouse move events: Fire dozens of times per second
- Input events: Trigger on every keystroke
- API calls: Can be expensive and rate-limited
Without optimization, these events can cause:
// ❌ Problem: Function fires too frequentlywindow.addEventListener("scroll", () => { // This runs hundreds of times per second! updateScrollPosition(); checkIfElementIsVisible(); updateProgressBar(); // Performance nightmare!});Performance issues:
- Browser becomes unresponsive
- Excessive memory usage
- Unnecessary API calls (rate limiting, costs)
- Poor user experience (lag, janky animations)
- Battery drain on mobile devices
Both throttle and debounce solve this by controlling function execution frequency, but they do it differently.
What is Debounce?
Debounce delays function execution until after a specified time period has passed since the last event. Think of it as “wait until the user stops doing something, then execute.”
How Debounce Works
Debounce creates a timer that resets every time the event fires. The function only executes after the timer completes without interruption.
// Visual timeline of debounce (delay: 300ms)// User types: "H" -> "e" -> "l" -> "l" -> "o"//// H---e---l---l---o---[300ms wait]---EXECUTE// ↑ ↑ ↑ ↑ ↑// Timer resets on each keystrokeKey characteristics:
- ✅ Executes only after activity stops
- ✅ Resets timer on each new event
- ✅ Best for: search inputs, form validation, API calls
- ✅ Prevents unnecessary executions
Simple Debounce Example
// Basic debounce implementationfunction debounce(func, delay) { let timeoutId;
return function (...args) { // Clear the previous timer clearTimeout(timeoutId);
// Set a new timer timeoutId = setTimeout(() => { func.apply(this, args); }, delay); };}
// Usage: Search inputconst searchInput = document.getElementById("search");const debouncedSearch = debounce((query) => { console.log("Searching for:", query); // Make API call here fetch(`/api/search?q=${query}`) .then((res) => res.json()) .then((data) => updateResults(data));}, 300);
searchInput.addEventListener("input", (e) => { debouncedSearch(e.target.value);});In this example, the search function only executes 300ms after the user stops typing, preventing excessive API calls.
What is Throttle?
Throttle limits function execution to at most once per specified time period. Think of it as “execute at most once every X milliseconds, regardless of how many times the event fires.”
How Throttle Works
Throttle ensures a function executes at regular intervals, ignoring all events that occur between executions.
// Visual timeline of throttle (limit: 300ms)// User scrolls continuously//// Scroll---[300ms]---EXECUTE---Scroll---Scroll---[300ms]---EXECUTE// ↑ ↑// Regular intervals Regular intervalsKey characteristics:
- ✅ Executes at regular intervals
- ✅ Ignores events between executions
- ✅ Best for: scroll handlers, resize handlers, mouse move
- ✅ Ensures consistent execution rate
Simple Throttle Example
// Basic throttle implementationfunction throttle(func, limit) { let inThrottle;
return function (...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } };}
// Usage: Scroll handlerconst throttledScroll = throttle(() => { const scrollTop = window.pageYOffset; updateScrollIndicator(scrollTop); checkIfStickyHeader(scrollTop);}, 100);
window.addEventListener("scroll", throttledScroll);In this example, the scroll handler executes at most once every 100ms, ensuring smooth performance even during rapid scrolling.
Key Differences: Throttle vs Debounce
Understanding the differences is crucial for choosing the right pattern. Here’s a side-by-side comparison:
| Aspect | Debounce | Throttle |
|---|---|---|
| Execution Timing | After activity stops | At regular intervals |
| Timer Behavior | Resets on each event | Fixed interval |
| First Execution | Waits for delay | Executes immediately (leading) or after delay (trailing) |
| Use Case | Search, form validation | Scroll, resize, mouse move |
| Frequency | Once per burst of events | Multiple times, but limited |
Visual Comparison
// Event timeline: User scrolls rapidly for 2 seconds// Events fire: [0ms, 50ms, 100ms, 150ms, 200ms, ..., 2000ms]
// Debounce (300ms delay):// Events: [0, 50, 100, 150, 200, ..., 2000]// └─────────────────────────────────┘// Wait 300ms after last event// Execute: [2300ms] ← Only once!
// Throttle (300ms limit):// Events: [0, 50, 100, 150, 200, ..., 2000]// ↑ ↑ ↑ ↑ ↑// Execute at: 0ms, 300ms, 600ms, 900ms, 1200ms, 1500ms, 1800ms// ← Multiple executions at regular intervalsWhen to Use Each
Use Debounce when:
- ✅ User input (search, form fields)
- ✅ API calls that should wait for user to finish
- ✅ Window resize (if you only care about final size)
- ✅ Button clicks (prevent double-clicks)
Use Throttle when:
- ✅ Scroll events (need continuous updates)
- ✅ Mouse move events (tracking, tooltips)
- ✅ Window resize (if you need updates during resize)
- ✅ Game loops or animations
- ✅ Any event that needs regular, limited updates
Implementing Debounce
Let’s explore different debounce implementations, from basic to advanced.
Basic Debounce Implementation
function debounce(func, delay) { let timeoutId;
return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { func.apply(this, args); }, delay); };}Leading Edge Debounce
Sometimes you want to execute immediately on the first call, then ignore subsequent calls:
function debounceLeading(func, delay) { let timeoutId; let hasExecuted = false;
return function (...args) { if (!hasExecuted) { func.apply(this, args); hasExecuted = true; }
clearTimeout(timeoutId); timeoutId = setTimeout(() => { hasExecuted = false; }, delay); };}
// Use case: Submit button - execute immediately, ignore rapid clicksconst debouncedSubmit = debounceLeading(() => { submitForm();}, 1000);Advanced Debounce with Options
function debounceAdvanced(func, delay, options = {}) { const { leading = false, // Execute on leading edge trailing = true, // Execute on trailing edge maxWait = null, // Maximum wait time } = options;
let timeoutId; let maxTimeoutId; let lastCallTime; let lastInvokeTime = 0; let leadingExecuted = false;
function invokeFunc(time) { const args = lastArgs; lastInvokeTime = time; result = func.apply(thisArg, args); return result; }
function leadingEdge(time) { lastInvokeTime = time; timeoutId = setTimeout(timerExpired, delay); return leading ? invokeFunc(time) : result; }
function remainingWait(time) { const timeSinceLastCall = time - lastCallTime; const timeSinceLastInvoke = time - lastInvokeTime; const timeWaiting = delay - timeSinceLastCall;
return maxWait !== null ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting; }
function shouldInvoke(time) { const timeSinceLastCall = time - lastCallTime; const timeSinceLastInvoke = time - lastInvokeTime;
return ( lastCallTime === undefined || timeSinceLastCall >= delay || timeSinceLastCall < 0 || (maxWait !== null && timeSinceLastInvoke >= maxWait) ); }
function timerExpired() { const time = Date.now(); if (shouldInvoke(time)) { return trailingEdge(time); } timeoutId = setTimeout(timerExpired, remainingWait(time)); }
function trailingEdge(time) { timeoutId = undefined;
if (trailing && lastArgs) { return invokeFunc(time); } lastArgs = lastThis = undefined; return result; }
function cancel() { if (timeoutId !== undefined) { clearTimeout(timeoutId); } if (maxTimeoutId !== undefined) { clearTimeout(maxTimeoutId); } lastInvokeTime = 0; lastArgs = lastCallTime = lastThis = timeoutId = undefined; }
function flush() { return timeoutId === undefined ? result : trailingEdge(Date.now()); }
function pending() { return timeoutId !== undefined; }
let lastArgs; let lastThis; let result; let thisArg;
function debounced(...args) { const time = Date.now(); const isInvoking = shouldInvoke(time);
lastArgs = args; lastThis = this; lastCallTime = time; thisArg = this;
if (isInvoking) { if (timeoutId === undefined) { return leadingEdge(lastCallTime); } if (maxWait !== null) { timeoutId = setTimeout(timerExpired, delay); return invokeFunc(lastCallTime); } } if (timeoutId === undefined) { timeoutId = setTimeout(timerExpired, delay); } return result; }
debounced.cancel = cancel; debounced.flush = flush; debounced.pending = pending;
return debounced;}
// Usage with optionsconst searchDebounced = debounceAdvanced((query) => searchAPI(query), 300, { leading: false, trailing: true, maxWait: 1000, // Execute after 1s even if still typing});React Hook: useDebounce
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => { // Set up timer const handler = setTimeout(() => { setDebouncedValue(value); }, delay);
// Cleanup: cancel timer if value changes before delay return () => { clearTimeout(handler); }; }, [value, delay]);
return debouncedValue;}
// Usage in componentfunction SearchComponent() { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 300);
useEffect(() => { if (debouncedSearchTerm) { // Make API call fetchSearchResults(debouncedSearchTerm); } }, [debouncedSearchTerm]);
return ( <input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} placeholder="Search..." /> );}Implementing Throttle
Now let’s explore throttle implementations, from simple to advanced.
Basic Throttle Implementation
function throttle(func, limit) { let inThrottle;
return function (...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } };}Leading and Trailing Throttle
function throttle(func, limit, options = {}) { const { leading = true, trailing = true } = options;
let timeoutId; let lastExecTime = 0; let lastArgs; let lastThis;
function invokeFunc(time) { const args = lastArgs; const thisArg = lastThis; lastArgs = lastThis = undefined; lastExecTime = time; return func.apply(thisArg, args); }
function leadingEdge(time) { lastExecTime = time; timeoutId = setTimeout(timerExpired, limit); return leading ? invokeFunc(time) : result; }
function remainingWait(time) { const timeSinceLastExec = time - lastExecTime; const timeWaiting = limit - timeSinceLastExec; return timeWaiting; }
function shouldInvoke(time) { const timeSinceLastExec = time - lastExecTime; return ( lastExecTime === 0 || timeSinceLastExec >= limit || timeSinceLastExec < 0 ); }
function timerExpired() { const time = Date.now(); if (shouldInvoke(time)) { return trailingEdge(time); } timeoutId = setTimeout(timerExpired, remainingWait(time)); }
function trailingEdge(time) { timeoutId = undefined; if (trailing && lastArgs) { return invokeFunc(time); } lastArgs = lastThis = undefined; return result; }
let result;
function throttled(...args) { const time = Date.now(); lastArgs = args; lastThis = this;
if (shouldInvoke(time)) { if (timeoutId === undefined) { return leadingEdge(time); } } if (timeoutId === undefined) { timeoutId = setTimeout(timerExpired, limit); } return result; }
throttled.cancel = function () { if (timeoutId !== undefined) { clearTimeout(timeoutId); } lastExecTime = 0; lastArgs = lastThis = timeoutId = undefined; };
throttled.flush = function () { return timeoutId === undefined ? result : trailingEdge(Date.now()); };
return throttled;}
// Usageconst throttledScroll = throttle(() => updateScrollPosition(), 100, { leading: true, trailing: true,});React Hook: useThrottle
import { useState, useEffect, useRef } from 'react';
function useThrottle<T>(value: T, limit: number): T { const [throttledValue, setThrottledValue] = useState<T>(value); const lastRan = useRef<number>(Date.now());
useEffect(() => { const handler = setTimeout(() => { if (Date.now() - lastRan.current >= limit) { setThrottledValue(value); lastRan.current = Date.now(); } }, limit - (Date.now() - lastRan.current));
return () => { clearTimeout(handler); }; }, [value, limit]);
return throttledValue;}
// Usagefunction ScrollComponent() { const [scrollY, setScrollY] = useState(0); const throttledScrollY = useThrottle(scrollY, 100);
useEffect(() => { const handleScroll = () => { setScrollY(window.scrollY); };
window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []);
useEffect(() => { // This effect runs at most once every 100ms updateHeader(throttledScrollY); }, [throttledScrollY]);
return <div>Scroll position: {throttledScrollY}</div>;}Request Animation Frame Throttle
For animations and visual updates, requestAnimationFrame provides optimal throttling:
function throttleRAF(func) { let rafId = null; let lastArgs;
return function (...args) { lastArgs = args;
if (rafId === null) { rafId = requestAnimationFrame(() => { func.apply(this, lastArgs); rafId = null; }); } };}
// Usage: Smooth scroll handlingconst smoothScrollHandler = throttleRAF(() => { updateParallax(); updateScrollProgress(); checkIntersections();});
window.addEventListener("scroll", smoothScrollHandler);💡 Pro Tip: requestAnimationFrame throttles to ~60fps (16.67ms intervals), perfect for visual updates and animations.
Real-World Use Cases
Let’s explore practical applications of throttle and debounce in real applications.
Search Input with Debounce
// Search component with debounced API callsfunction SearchInput() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false);
// Debounce the search function const debouncedSearch = useMemo( () => debounce(async (searchQuery: string) => { if (!searchQuery.trim()) { setResults([]); return; }
setLoading(true); try { const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`); const data = await response.json(); setResults(data.results); } catch (error) { console.error('Search failed:', error); } finally { setLoading(false); } }, 300), [] );
useEffect(() => { debouncedSearch(query);
// Cleanup on unmount return () => { debouncedSearch.cancel?.(); }; }, [query, debouncedSearch]);
return ( <div> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> {loading && <div>Searching...</div>} <ResultsList results={results} /> </div> );}Scroll-Based Animations with Throttle
// Scroll handler with throttled updatesfunction ScrollAnimations() { useEffect(() => { const handleScroll = throttle(() => { const scrollY = window.scrollY; const windowHeight = window.innerHeight;
// Update progress bar const progress = (scrollY / (document.body.scrollHeight - windowHeight)) * 100; updateProgressBar(progress);
// Check if elements are visible checkElementVisibility();
// Update parallax effects updateParallax(scrollY);
// Show/hide sticky header toggleStickyHeader(scrollY > 100); }, 16); // ~60fps for smooth animations
window.addEventListener('scroll', handleScroll, { passive: true });
return () => { window.removeEventListener('scroll', handleScroll); handleScroll.cancel?.(); }; }, []);
return <div>Content with scroll animations</div>;}Window Resize Handler
// Resize handler - choose based on needsfunction ResponsiveComponent() { // Option 1: Debounce (only care about final size) const debouncedResize = useMemo( () => debounce(() => { // Recalculate layout only after resize completes recalculateLayout(); updateBreakpoints(); }, 250), [] );
// Option 2: Throttle (need updates during resize) const throttledResize = useMemo( () => throttle(() => { // Update continuously during resize updateResponsiveElements(); adjustGridLayout(); }, 100), [] );
useEffect(() => { // Use debounce for layout recalculation window.addEventListener('resize', debouncedResize);
return () => { window.removeEventListener('resize', debouncedResize); debouncedResize.cancel?.(); }; }, [debouncedResize]);
return <div>Responsive content</div>;}Form Validation with Debounce
// Form field validation with debouncefunction EmailInput() { const [email, setEmail] = useState(''); const [isValid, setIsValid] = useState(true); const [isChecking, setIsChecking] = useState(false);
const validateEmail = useMemo( () => debounce(async (emailValue: string) => { if (!emailValue) { setIsValid(true); return; }
setIsChecking(true);
// Check format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(emailValue)) { setIsValid(false); setIsChecking(false); return; }
// Check availability (API call) try { const response = await fetch(`/api/check-email?email=${emailValue}`); const { available } = await response.json(); setIsValid(available); } catch (error) { setIsValid(false); } finally { setIsChecking(false); } }, 500), // Wait 500ms after user stops typing [] );
useEffect(() => { validateEmail(email);
return () => { validateEmail.cancel?.(); }; }, [email, validateEmail]);
return ( <div> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className={isValid ? '' : 'error'} /> {isChecking && <span>Checking...</span>} {!isValid && <span>Email is invalid or already taken</span>} </div> );}Mouse Move Tracking with Throttle
// Mouse position tracking for tooltips or cursorsfunction MouseTracker() { const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
useEffect(() => { const handleMouseMove = throttle((e: MouseEvent) => { setMousePos({ x: e.clientX, y: e.clientY }); updateCustomCursor(e.clientX, e.clientY); checkTooltipHover(e.clientX, e.clientY); }, 16); // Smooth 60fps updates
window.addEventListener('mousemove', handleMouseMove);
return () => { window.removeEventListener('mousemove', handleMouseMove); handleMouseMove.cancel?.(); }; }, []);
return <div>Mouse position: {mousePos.x}, {mousePos.y}</div>;}Button Click Debounce (Prevent Double-Clicks)
// Prevent accidental double-clicks on submit buttonsfunction SubmitButton() { const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = useMemo( () => debounceLeading(async () => { if (isSubmitting) return;
setIsSubmitting(true); try { await submitForm(); showSuccessMessage(); } catch (error) { showErrorMessage(error); } finally { setIsSubmitting(false); } }, 1000), // Ignore clicks for 1 second after first click [isSubmitting] );
return ( <button onClick={handleSubmit} disabled={isSubmitting}> {isSubmitting ? 'Submitting...' : 'Submit'} </button> );}Advanced Patterns and Variations
Combining Throttle and Debounce
Sometimes you need both behaviors:
// Throttle for immediate feedback, debounce for final actionfunction hybridThrottleDebounce( func: Function, throttleLimit: number, debounceDelay: number,) { let throttleTimer: NodeJS.Timeout | null = null; let debounceTimer: NodeJS.Timeout | null = null; let lastExecTime = 0;
return function (...args: any[]) { const now = Date.now();
// Throttle: Execute immediately if enough time has passed if (now - lastExecTime >= throttleLimit) { func.apply(this, args); lastExecTime = now; }
// Debounce: Always schedule a final execution if (debounceTimer) { clearTimeout(debounceTimer); }
debounceTimer = setTimeout(() => { func.apply(this, args); lastExecTime = Date.now(); }, debounceDelay); };}
// Use case: Search with immediate suggestions + final resultsconst searchHybrid = hybridThrottleDebounce( (query: string) => { // Show suggestions immediately (throttled) showSuggestions(query); // Then show final results (debounced) showFinalResults(query); }, 100, // Throttle: Update every 100ms 500, // Debounce: Final update after 500ms of inactivity);Adaptive Throttle/Debounce
Adjust delay based on system performance:
function adaptiveThrottle(func: Function, baseLimit: number) { let lastExecTime = 0; let currentLimit = baseLimit; let frameCount = 0; let lastFpsCheck = Date.now();
return function (...args: any[]) { const now = Date.now();
// Check FPS every second if (now - lastFpsCheck >= 1000) { const fps = frameCount; frameCount = 0; lastFpsCheck = now;
// Adjust limit based on performance if (fps < 30) { currentLimit = baseLimit * 2; // Slow down if FPS is low } else if (fps > 55) { currentLimit = Math.max(baseLimit / 2, 16); // Speed up if FPS is high } }
if (now - lastExecTime >= currentLimit) { func.apply(this, args); lastExecTime = now; frameCount++; } };}Priority-Based Throttle
Execute high-priority functions more frequently:
type Priority = "high" | "medium" | "low";
function priorityThrottle(func: Function, limits: Record<Priority, number>) { let lastExecTime = 0; let currentPriority: Priority = "medium";
const setPriority = (priority: Priority) => { currentPriority = priority; };
const throttled = function (...args: any[]) { const now = Date.now(); const limit = limits[currentPriority];
if (now - lastExecTime >= limit) { func.apply(this, args); lastExecTime = now; } };
throttled.setPriority = setPriority; return throttled;}
// Usageconst scrollHandler = priorityThrottle(() => updateScroll(), { high: 16, // 60fps for important updates medium: 100, // 10fps for normal updates low: 500, // 2fps for background updates});
// Increase priority when user is actively scrollingwindow.addEventListener("scroll", () => { scrollHandler.setPriority("high"); scrollHandler();
// Reduce priority after scrolling stops setTimeout(() => { scrollHandler.setPriority("low"); }, 1000);});Performance Considerations
Understanding performance implications helps you optimize effectively.
Memory Management
⚠️ Important: Always clean up timers to prevent memory leaks:
// ✅ Good: Cleanup on unmountuseEffect(() => { const debouncedFn = debounce(() => { // Do something }, 300);
window.addEventListener("scroll", debouncedFn);
return () => { window.removeEventListener("scroll", debouncedFn); debouncedFn.cancel?.(); // Cancel pending timers };}, []);
// ❌ Bad: No cleanup - memory leak!useEffect(() => { const debouncedFn = debounce(() => { // Do something }, 300);
window.addEventListener("scroll", debouncedFn); // Missing cleanup!}, []);Choosing the Right Delay
Debounce delays:
- Search inputs: 300-500ms (balance between responsiveness and API calls)
- Form validation: 500-1000ms (less frequent, more thorough)
- Window resize: 250-500ms (wait for final size)
Throttle limits:
- Scroll handlers: 16ms (60fps) for smooth animations, 100ms for general updates
- Mouse move: 16ms for cursor effects, 100ms for tooltips
- Resize handlers: 100-250ms (balance between updates and performance)
- API polling: 1000-5000ms (depends on data freshness needs)
Performance Benchmarks
// Measure performance impactfunction benchmarkThrottleDebounce() { const iterations = 10000;
// Without optimization console.time("No optimization"); for (let i = 0; i < iterations; i++) { expensiveFunction(); } console.timeEnd("No optimization");
// With throttle const throttled = throttle(expensiveFunction, 100); console.time("With throttle"); for (let i = 0; i < iterations; i++) { throttled(); } console.timeEnd("With throttle");
// With debounce const debounced = debounce(expensiveFunction, 100); console.time("With debounce"); for (let i = 0; i < iterations; i++) { debounced(); } console.timeEnd("With debounce");
// Execute debounced function to complete setTimeout(() => {}, 200);}Browser Compatibility
Modern implementations work in all browsers, but consider:
// Polyfill for older browsers if neededif (typeof requestAnimationFrame === "undefined") { window.requestAnimationFrame = function (callback: FrameRequestCallback) { return setTimeout(callback, 16); };
window.cancelAnimationFrame = function (id: number) { clearTimeout(id); };}Common Pitfalls and Best Practices
Common Mistakes
❌ Mistake 1: Creating new debounced/throttled functions on every render
// ❌ Bad: New function on every renderfunction Component() { const handleScroll = debounce(() => { // Handler }, 100);
useEffect(() => { window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, [handleScroll]); // handleScroll changes every render!}
// ✅ Good: Memoize the debounced functionfunction Component() { const handleScroll = useMemo( () => debounce(() => { // Handler }, 100), [], // Empty deps - function created once );
useEffect(() => { window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, [handleScroll]);}❌ Mistake 2: Not cleaning up timers
// ❌ Bad: Timers continue after unmountfunction Component() { useEffect(() => { const debounced = debounce(() => { setState(value); // May try to update unmounted component! }, 300);
// Missing cleanup }, []);}
// ✅ Good: Proper cleanupfunction Component() { useEffect(() => { const debounced = debounce(() => { setState(value); }, 300);
return () => { debounced.cancel?.(); }; }, []);}❌ Mistake 3: Using wrong pattern for the use case
// ❌ Bad: Throttle for search (wastes API calls)const searchThrottled = throttle((query) => { searchAPI(query); // Called every 100ms even if user stopped typing!}, 100);
// ✅ Good: Debounce for searchconst searchDebounced = debounce((query) => { searchAPI(query); // Only called after user stops typing}, 300);Best Practices
✅ Practice 1: Use useMemo or useCallback for stable references
// ✅ Stable debounced functionconst debouncedSearch = useMemo( () => debounce((query: string) => { performSearch(query); }, 300), [], // Dependencies for performSearch if needed);✅ Practice 2: Use passive event listeners for scroll/resize
// ✅ Better performancewindow.addEventListener("scroll", handleScroll, { passive: true });✅ Practice 3: Consider using libraries for production
Popular libraries provide battle-tested implementations:
- Lodash:
_.debounce()and_.throttle()- Lodash Documentation - RxJS:
debounceTime()andthrottleTime()operators - Underscore: Similar to Lodash
import { debounce, throttle } from "lodash";
// Production-ready implementationsconst debouncedSearch = debounce(searchFunction, 300, { leading: false, trailing: true, maxWait: 1000,});✅ Practice 4: Test your implementations
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Test debounce behaviordescribe("debounce", () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("should execute after delay", () => { const func = vi.fn(); const debounced = debounce(func, 300);
debounced(); expect(func).not.toHaveBeenCalled();
vi.advanceTimersByTime(300); expect(func).toHaveBeenCalledTimes(1); });
it("should reset timer on new calls", () => { const func = vi.fn(); const debounced = debounce(func, 300);
debounced(); vi.advanceTimersByTime(200); debounced(); // Reset timer vi.advanceTimersByTime(200); expect(func).not.toHaveBeenCalled(); vi.advanceTimersByTime(100); expect(func).toHaveBeenCalledTimes(1); });});Testing Throttle and Debounce
Testing time-based functions requires special techniques.
Using Vitest Fake Timers
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("throttle", () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("should execute immediately on first call", () => { const func = vi.fn(); const throttled = throttle(func, 100);
throttled(); expect(func).toHaveBeenCalledTimes(1); });
it("should limit execution frequency", () => { const func = vi.fn(); const throttled = throttle(func, 100);
throttled(); throttled(); throttled();
expect(func).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100); throttled(); expect(func).toHaveBeenCalledTimes(2); });});Integration Testing
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';import { render, screen, fireEvent, act } from '@testing-library/react';
// Test with real user interactionsdescribe('SearchInput integration', () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('should debounce search API calls', async () => { const mockFetch = vi.fn().mockResolvedValue({ json: async () => ({ results: [] }) }); global.fetch = mockFetch;
render(<SearchInput />); const input = screen.getByPlaceholderText('Search...');
// Type rapidly fireEvent.change(input, { target: { value: 't' } }); fireEvent.change(input, { target: { value: 'te' } }); fireEvent.change(input, { target: { value: 'tes' } }); fireEvent.change(input, { target: { value: 'test' } });
// Should not have called API yet expect(mockFetch).not.toHaveBeenCalled();
// Advance timers for debounce delay await act(async () => { vi.advanceTimersByTime(350); });
// Should have called API once expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('test')); });});Performance Testing
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("Performance", () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("should reduce function calls significantly", () => { const func = vi.fn(); const throttled = throttle(func, 100);
// Simulate 1000 rapid calls for (let i = 0; i < 1000; i++) { throttled(); }
// Should only execute ~10 times (1000ms / 100ms) expect(func).toHaveBeenCalledTimes(1); // First call
vi.advanceTimersByTime(1000); expect(func).toHaveBeenCalledTimes(10); });});Conclusion
Throttle and debounce are essential patterns for optimizing JavaScript applications. Understanding their differences and when to use each is crucial for building performant web applications.
Key Takeaways
- Debounce: Wait until activity stops, then execute. Perfect for search inputs, form validation, and API calls that should wait for user completion.
- Throttle: Execute at regular intervals. Ideal for scroll handlers, resize events, and any scenario requiring consistent, limited updates.
- Choose wisely: Consider your use case carefully—wrong choice leads to poor UX or wasted resources.
- Implement properly: Always clean up timers, use stable function references, and test your implementations.
- Consider libraries: For production, consider battle-tested libraries like Lodash or RxJS.
Next Steps
- Practice implementing both patterns from scratch to deepen understanding
- Experiment with different delay values to find optimal performance
- Explore advanced patterns like adaptive throttling and priority-based execution
- Read about related performance optimization techniques in our guide on web performance optimization
- Learn about React performance optimization for component-level optimizations
- Understand the JavaScript event loop to see how these patterns fit into the bigger picture
Mastering throttle and debounce will significantly improve your application’s performance and user experience. Start applying these patterns to your projects today!