Debouncing vs Throttling: When to Use Each
TL;DR
Debouncing waits until user stops (search input). Throttling limits rate (scroll handler). Debounce = last call wins. Throttle = regular intervals. Use debounce for user input, throttle for continuous events.
Our search autocomplete hammered the API with every keystroke. Typing "javascript" sent 10 requests in 2 seconds. Database couldn't keep up. I added debouncing - now it waits until the user stops typing. One request instead of 10.
Debouncing and throttling solve different problems. Here's when to use each, with code you can copy.
The Problem
// Search input - fires on every keystroke
searchInput.addEventListener('input', (e) => {
searchAPI(e.target.value); // Called 10 times for "javascript"
});
// Scroll handler - fires hundreds of times
window.addEventListener('scroll', () => {
updateScrollPosition(); // Called 200 times scrolling down page
});
Without rate limiting:
- Too many API calls
- Wasted bandwidth
- Slow UI
- Server overload
Debouncing: Wait Until User Stops
Debouncing delays execution until activity stops:
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// Usage
const debouncedSearch = debounce((query) => {
console.log('Searching for:', query);
searchAPI(query);
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
// User types "javascript":
// j - wait 300ms
// ja - reset timer, wait 300ms
// jav - reset timer, wait 300ms
// java - reset timer, wait 300ms
// javas - reset timer, wait 300ms
// javasc - reset timer, wait 300ms
// javascr - reset timer, wait 300ms
// javascri - reset timer, wait 300ms
// javascrip - reset timer, wait 300ms
// javascript - reset timer, wait 300ms
// (user stops typing)
// After 300ms: search API called ONCE
Visual:
User types: j-a-v-a-s-c-r-i-p-t----
Debounced: ✓
(waits 300ms after last keystroke)
Throttling: Execute at Regular Intervals
Throttling limits execution to once per time period:
function throttle(func, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func.apply(this, args);
}
};
}
// Usage
const throttledScroll = throttle(() => {
console.log('Scroll position:', window.scrollY);
updateScrollIndicator();
}, 100);
window.addEventListener('scroll', throttledScroll);
// User scrolls continuously:
// Scroll fires 200 times per second
// Throttled: fires every 100ms (10 times per second)
Visual:
Scroll events: ||||||||||||||||||||||||||||||||
Throttled: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
(every 100ms)
Side-by-Side Comparison
// DEBOUNCING - Wait until user stops
const debouncedSave = debounce(() => {
console.log('Saving...');
}, 1000);
// Type: h-e-l-l-o----
// Calls: none yet...
// After 1s of no typing: save once
// THROTTLING - Regular intervals
const throttledSave = throttle(() => {
console.log('Saving...');
}, 1000);
// Type: h-e-l-l-o continuously
// Calls: every 1 second while typing
// Multiple saves happen
Key difference:
- Debounce: Last call wins (waits for silence)
- Throttle: Regular intervals (continues during activity)
When to Use Debouncing
1. Search Autocomplete
const debouncedSearch = debounce(async (query) => {
if (query.length < 2) return;
const results = await fetch(`/api/search?q=${query}`);
displayResults(await results.json());
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
2. Form Validation
const debouncedValidate = debounce(async (email) => {
const response = await fetch(`/api/validate-email?email=${email}`);
const { valid } = await response.json();
if (!valid) {
showError('Email already registered');
}
}, 500);
emailInput.addEventListener('input', (e) => {
debouncedValidate(e.target.value);
});
3. Auto-Save
const debouncedSave = debounce(async (content) => {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ content })
});
showNotification('Draft saved');
}, 2000);
editor.addEventListener('input', (e) => {
debouncedSave(e.target.value);
});
4. Window Resize
const debouncedResize = debounce(() => {
console.log('Window resized to:', window.innerWidth);
adjustLayout();
}, 250);
window.addEventListener('resize', debouncedResize);
When to Use Throttling
1. Scroll Handler
const throttledScroll = throttle(() => {
const scrollPercent = (window.scrollY / document.body.scrollHeight) * 100;
updateProgressBar(scrollPercent);
}, 100);
window.addEventListener('scroll', throttledScroll);
2. Mouse Move Tracking
const throttledMouseMove = throttle((e) => {
console.log('Mouse at:', e.clientX, e.clientY);
updateCursorEffect(e.clientX, e.clientY);
}, 50);
document.addEventListener('mousemove', throttledMouseMove);
3. Infinite Scroll
const throttledCheckScroll = throttle(() => {
const scrollBottom = window.scrollY + window.innerHeight;
const pageHeight = document.body.scrollHeight;
if (scrollBottom >= pageHeight - 100) {
loadMoreItems();
}
}, 200);
window.addEventListener('scroll', throttledCheckScroll);
4. Game Loop / Animation
const throttledUpdate = throttle(() => {
updateGameState();
render();
}, 16); // ~60 FPS
function gameLoop() {
throttledUpdate();
requestAnimationFrame(gameLoop);
}
Advanced Debouncing (Leading + Trailing)
function debounce(func, delay, options = {}) {
let timeoutId;
let lastCallTime = 0;
const { leading = false, trailing = true } = options;
return function(...args) {
const now = Date.now();
const isFirstCall = lastCallTime === 0;
clearTimeout(timeoutId);
if (leading && isFirstCall) {
func.apply(this, args);
}
if (trailing) {
timeoutId = setTimeout(() => {
lastCallTime = 0;
func.apply(this, args);
}, delay);
}
lastCallTime = now;
};
}
// Leading edge - execute immediately, then wait
const leadingDebounce = debounce(search, 300, { leading: true, trailing: false });
// Trailing edge - wait, then execute (default)
const trailingDebounce = debounce(search, 300, { leading: false, trailing: true });
// Both - execute immediately AND after delay
const bothDebounce = debounce(search, 300, { leading: true, trailing: true });
Advanced Throttling (Leading + Trailing)
function throttle(func, delay, options = {}) {
let lastCall = 0;
let timeoutId = null;
const { leading = true, trailing = true } = options;
return function(...args) {
const now = Date.now();
const timeSinceLastCall = now - lastCall;
if (leading && timeSinceLastCall >= delay) {
lastCall = now;
func.apply(this, args);
}
if (trailing) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
lastCall = Date.now();
func.apply(this, args);
}, delay - timeSinceLastCall);
}
};
}
React Hooks
import { useState, useEffect, useCallback } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchComponent() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
function useThrottle(callback, delay) {
const lastRan = useRef(Date.now());
return useCallback((...args) => {
const now = Date.now();
if (now - lastRan.current >= delay) {
callback(...args);
lastRan.current = now;
}
}, [callback, delay]);
}
// Usage
function ScrollComponent() {
const handleScroll = useThrottle(() => {
console.log('Scrolled:', window.scrollY);
}, 100);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return <div>Scroll me</div>;
}
Lodash Implementations
import { debounce, throttle } from 'lodash';
// Debounce
const debouncedSearch = debounce((query) => {
searchAPI(query);
}, 300);
// Throttle
const throttledScroll = throttle(() => {
updatePosition();
}, 100);
// Cancel pending calls
debouncedSearch.cancel();
throttledScroll.cancel();
// Execute immediately
debouncedSearch.flush();
throttledScroll.flush();
Real-World Examples
Search with Loading State
let searchController = null;
const debouncedSearch = debounce(async (query) => {
// Cancel previous request
if (searchController) {
searchController.abort();
}
searchController = new AbortController();
try {
setLoading(true);
const response = await fetch(`/api/search?q=${query}`, {
signal: searchController.signal
});
const results = await response.json();
displayResults(results);
} catch (err) {
if (err.name !== 'AbortError') {
showError(err);
}
} finally {
setLoading(false);
}
}, 300);
Infinite Scroll with Throttle
let loading = false;
const loadMore = throttle(async () => {
if (loading) return;
const scrollBottom = window.scrollY + window.innerHeight;
const pageHeight = document.body.scrollHeight;
if (scrollBottom >= pageHeight - 200) {
loading = true;
const items = await fetchMoreItems();
appendItems(items);
loading = false;
}
}, 200);
window.addEventListener('scroll', loadMore);
Form Auto-Save with Status
let saveStatus = 'saved'; // 'saving', 'saved', 'error'
const debouncedSave = debounce(async (content) => {
try {
saveStatus = 'saving';
updateUI('Saving...');
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ content })
});
saveStatus = 'saved';
updateUI('All changes saved');
} catch (err) {
saveStatus = 'error';
updateUI('Error saving changes');
}
}, 2000);
editor.addEventListener('input', (e) => {
updateUI('Unsaved changes');
debouncedSave(e.target.value);
});
Performance Comparison
// Without debouncing - 10 API calls
function testWithoutDebounce() {
let calls = 0;
const search = () => calls++;
'javascript'.split('').forEach(() => search());
console.log('API calls:', calls); // 10
}
// With debouncing - 1 API call
function testWithDebounce() {
let calls = 0;
const search = debounce(() => calls++, 300);
'javascript'.split('').forEach(() => search());
setTimeout(() => {
console.log('API calls:', calls); // 1
}, 400);
}
Savings:
Without: 10 requests × 50ms = 500ms total
With: 1 request × 50ms = 50ms total
Result: 90% reduction in API calls
Common Mistakes
Mistake 1: Creating New Debounced Function Each Render
// BAD - Creates new function every render
function SearchComponent() {
const handleSearch = debounce((query) => {
searchAPI(query);
}, 300); // New function every time!
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
// GOOD - Memoize the function
function SearchComponent() {
const handleSearch = useCallback(
debounce((query) => {
searchAPI(query);
}, 300),
[]
);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
Mistake 2: Not Canceling on Unmount
// BAD - Continues after component unmounts
useEffect(() => {
const debouncedFn = debounce(() => {
updateState();
}, 300);
window.addEventListener('resize', debouncedFn);
return () => window.removeEventListener('resize', debouncedFn);
}, []);
// GOOD - Cancel on unmount
useEffect(() => {
const debouncedFn = debounce(() => {
updateState();
}, 300);
window.addEventListener('resize', debouncedFn);
return () => {
window.removeEventListener('resize', debouncedFn);
debouncedFn.cancel(); // Cancel pending calls
};
}, []);
Mistake 3: Wrong Delay Times
// Too short - still too many calls
debounce(search, 10); // Fires almost every keystroke
// Too long - feels laggy
debounce(search, 2000); // 2 second delay feels broken
// Good delays:
debounce(search, 300); // Search: 300ms
debounce(autoSave, 2000); // Auto-save: 2s
throttle(scroll, 100); // Scroll: 100ms
throttle(mousemove, 50); // Mouse: 50ms
Testing
describe('debounce', () => {
jest.useFakeTimers();
it('should delay function execution', () => {
const func = jest.fn();
const debounced = debounce(func, 300);
debounced();
expect(func).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(func).toHaveBeenCalledTimes(1);
});
it('should reset timer on multiple calls', () => {
const func = jest.fn();
const debounced = debounce(func, 300);
debounced();
jest.advanceTimersByTime(200);
debounced();
jest.advanceTimersByTime(200);
debounced();
expect(func).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(func).toHaveBeenCalledTimes(1);
});
});
Decision Tree
Is the event triggered repeatedly?
├─ Yes: Need rate limiting
│ ├─ Should it execute during activity?
│ │ ├─ Yes: Use THROTTLE
│ │ │ └─ Examples: scroll, mousemove, resize during drag
│ │ └─ No: Use DEBOUNCE
│ │ └─ Examples: search input, form validation, auto-save
│ └─ Should it execute immediately?
│ ├─ Yes: leading: true
│ └─ No: trailing: true (default)
└─ No: No rate limiting needed
Quick Reference
// DEBOUNCE - Wait for pause
debounce(func, delay)
✓ Search autocomplete
✓ Form validation
✓ Auto-save
✓ Window resize
✓ API calls on input
// THROTTLE - Regular intervals
throttle(func, delay)
✓ Scroll handlers
✓ Mouse tracking
✓ Infinite scroll
✓ Game loops
✓ Progress indicators
The Bottom Line
Debouncing waits for user to stop. Throttling limits execution rate.
Use debounce for user input - search, validation, auto-save. Waits until they're done.
Use throttle for continuous events - scroll, mousemove, resize. Executes during activity.
Common delays: 300ms for search, 100ms for scroll, 2s for auto-save.
Our search sent 10 API calls per query. Added debouncing, now it's 1 call. Server happy, users happy, problem solved.
Pick the right tool for your use case. Copy these implementations. Test the delays. Your app will feel more responsive.