Skip to main content

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

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 frequently
window.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 keystroke

Key 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 implementation
function 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 input
const 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 intervals

Key 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 implementation
function throttle(func, limit) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
// Usage: Scroll handler
const 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:

AspectDebounceThrottle
Execution TimingAfter activity stopsAt regular intervals
Timer BehaviorResets on each eventFixed interval
First ExecutionWaits for delayExecutes immediately (leading) or after delay (trailing)
Use CaseSearch, form validationScroll, resize, mouse move
FrequencyOnce per burst of eventsMultiple 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 intervals

When 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 clicks
const 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 options
const 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 component
function 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;
}
// Usage
const 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;
}
// Usage
function 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 handling
const 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 calls
function 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 updates
function 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 needs
function 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 debounce
function 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 cursors
function 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 buttons
function 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 action
function 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 results
const 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;
}
// Usage
const 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 scrolling
window.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 unmount
useEffect(() => {
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 impact
function 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 needed
if (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 render
function 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 function
function 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 unmount
function Component() {
useEffect(() => {
const debounced = debounce(() => {
setState(value); // May try to update unmounted component!
}, 300);
// Missing cleanup
}, []);
}
// ✅ Good: Proper cleanup
function 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 search
const 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 function
const debouncedSearch = useMemo(
() =>
debounce((query: string) => {
performSearch(query);
}, 300),
[], // Dependencies for performSearch if needed
);

Practice 2: Use passive event listeners for scroll/resize

// ✅ Better performance
window.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() and throttleTime() operators
  • Underscore: Similar to Lodash
import { debounce, throttle } from "lodash";
// Production-ready implementations
const 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 behavior
describe("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 interactions
describe('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!