Debounce & Throttle
Status: Complete
Category: Performance
Default enforcement: Advisory
Author: PushBackLog team
Tags
- Topic: performance, user-interface
- Skillset: frontend, fullstack
- Technology: JavaScript, TypeScript
- Stage: execution, review
Summary
Debouncing and throttling are techniques for controlling how often a function is called in response to high-frequency events such as keystrokes, scroll events, mouse movements, and resize events. Without them, fast-typing users trigger dozens of redundant API calls per second, scroll handlers block the render thread, and resize listeners hammer layout recalculations. The two techniques differ in intent: debounce collapses bursts into a single call after a quiet period; throttle guarantees a call runs at most once per interval.
Rationale
High-frequency events cause performance problems
Browser events like input, scroll, mousemove, and resize can fire hundreds of times per second. A search input that fires an API request on every keystroke will send dozens of requests for a five-character search term. Most of those requests are wasted: the intermediate states are never needed, and the out-of-order responses create race conditions. The same principle applies on the server side: a rate-limited API, a webhook handler, or a timer task that runs too frequently wastes compute and can cause cascading failures.
Debounce vs. throttle trade-offs
Neither technique is universally better. The right choice depends on whether you care more about the latest event (debounce) or about regular intermediate updates (throttle). Applying the wrong pattern produces UIs that feel broken: debouncing a scroll progress bar means it only updates when the user stops scrolling; throttling a form submit button may allow multiple submissions.
Guidance
Debounce
Debouncing delays execution until a specified time has elapsed without the event firing again. The timer resets on each event.
// Custom implementation
function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delayMs: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>): void => {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), delayMs);
};
}
// Search input — fire after user stops typing for 300ms
const handleSearch = debounce(async (query: string) => {
const results = await searchApi(query);
setResults(results);
}, 300);
inputElement.addEventListener('input', (e) => {
handleSearch((e.target as HTMLInputElement).value);
});
When to debounce:
- Search / autocomplete inputs
- Form auto-save (“saving…” after user stops typing)
- Window resize recalculations
- Validation that triggers API calls
Throttle
Throttling ensures the function fires at most once per interval, dropping intermediate events.
// Custom implementation
function throttle<T extends (...args: unknown[]) => void>(
fn: T,
intervalMs: number
): (...args: Parameters<T>) => void {
let lastRun = 0;
return (...args: Parameters<T>): void => {
const now = Date.now();
if (now - lastRun >= intervalMs) {
lastRun = now;
fn(...args);
}
};
}
// Scroll progress bar — update at most 10 times per second
const updateScrollProgress = throttle(() => {
const scrolled = window.scrollY;
const total = document.body.scrollHeight - window.innerHeight;
progressBar.style.width = `${(scrolled / total) * 100}%`;
}, 100);
window.addEventListener('scroll', updateScrollProgress);
When to throttle:
- Scroll-based animations or progress indicators
- Mouse move tracking
- Button click protection (prevent double-submit)
- Rate-limited API polling
- Server-side event processing with a busy external trigger
Leading vs. trailing edge
Both debounce and throttle can fire on the leading edge (first call), trailing edge (last call), or both:
| Edge | Behaviour | Best for |
|---|---|---|
| Trailing (default) | Fires after quiet/interval expires | Search, auto-save |
| Leading | Fires immediately, then suppresses until interval passes | Button protection, first-click feedback |
| Both | Fires immediately and at end of burst | Infrequent, important events |
// Using Lodash for built-in leading/trailing options
import { debounce, throttle } from 'lodash';
// Leading edge: fire immediately, then suppress for 500ms
const handleSubmit = debounce(submitForm, 500, { leading: true, trailing: false });
// Both edges: fire immediately and again after burst settles
const handleResize = debounce(recalculate, 200, { leading: true, trailing: true });
Using lodash vs. custom
- Custom implementation: appropriate when you have a simple use case and want no dependency
- Lodash
_.debounce/_.throttle: battle-tested, supports leading/trailing/cancel/flush — prefer in most production code
import debounce from 'lodash/debounce';
// Always cancel on component unmount to prevent memory leaks
const debouncedSearch = debounce(fetchSuggestions, 300);
useEffect(() => {
return () => debouncedSearch.cancel(); // Clean up on unmount
}, []);
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Creating debounce/throttle inside render | New function created every render, breaking the debounce state | Create once with useRef or useMemo |
| Not cancelling on unmount | Memory leaks, state updates on unmounted components | Always call .cancel() in cleanup |
| Debouncing button click for UX feedback | User sees no immediate response | Use leading edge or show loading state on first click |
| Throttle interval too long | UI feels laggy | Start at 16ms (one animation frame) for visual updates |
React hook pattern
function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}
// Usage
const debouncedQuery = useDebounce(searchInput, 300);
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);