
Debounce vs Throttle: When and How to Actually Use Them
Stop firing API calls on every keystroke and scroll event. A practical guide to debounce and throttle with real-world examples.

Stop firing API calls on every keystroke and scroll event. A practical guide to debounce and throttle with real-world examples.
Clicked a button, but the parent DIV triggered too? Events bubble up like water. Understand Propagation and Delegation.

Optimizing by gut feeling made my app slower. Learn to use Performance profiler to find real bottlenecks and fix what matters.

Text to Binary (HTTP/2), TCP to UDP (HTTP/3). From single-file queueing to parallel processing. Google's QUIC protocol story.

From HTML parsing to DOM, CSSOM, Render Tree, Layout, Paint, and Composite. Mastering the Critical Rendering Path (CRP), Preload Scanner, Reflow vs Repaint, and requestAnimationFrame.

When I first built a search autocomplete feature, I wanted to show real-time results as users typed. Simple enough, right? Just hook up an API call to the onChange event.
Terrible idea.
Typing "react" triggered API calls for "r", "re", "rea", "reac", "react"... That's not even counting the intermediate composition characters in Korean input. Before I knew it, 15+ requests fired off for a 5-character search term. Opening DevTools, my Network tab was a sea of red.
"If this goes to production, I'm going to get a bill that makes me cry."
A senior dev saw my panic and casually asked, "Have you tried debounce?" I'd never heard the term. After some Googling, I found there was also something called "throttle." Both seemed to be about "controlling events," but at first glance, they looked similar. When do you use which?
After hours of confusion, an analogy finally made everything clear.
Debounce is an elevator. When someone presses the button, the elevator waits a few seconds. If another person arrives during that wait? Reset the timer and wait again. Only when things are completely quiet does the door close and the elevator depart.
Throttle is a subway train. No matter how many people are rushing to board, no matter how urgent, the train leaves at fixed intervals (say, every 5 minutes). The first person boards, and everyone else waits until the next scheduled departure.
Once I had this mental model, the code patterns became crystal clear.
// Debounce: wait n seconds after the LAST event
// "Wait until the user stops typing, then execute once"
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId); // cancel previous timer
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// Throttle: execute at MOST once every n seconds
// "No matter how many events fire, only run at fixed intervals"
function throttle(func, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func.apply(this, args);
}
};
}
For the search bar problem, debounce was the answer. Wait until the user completely stops typing, then fire the API call once. With a 300ms delay, I went from 15 requests to 1.
I started with lodash's _.debounce, but quickly learned that React needs a different approach. If you create a new debounced function on every render, the whole thing falls apart.
import { useState, useCallback, useRef, useEffect } from 'react';
function useDebounce(callback, delay) {
const timeoutRef = useRef(null);
const callbackRef = useRef(callback);
// Keep the latest callback reference
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedCallback = useCallback((...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedCallback;
}
// Usage example
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const searchAPI = async (searchTerm) => {
if (!searchTerm.trim()) return;
try {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
}
};
const debouncedSearch = useDebounce(searchAPI, 300);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value); // executes after 300ms of silence
};
return (
<div>
<input
type="text"
value={query}
onChange={handleChange}
placeholder="Search..."
/>
<ul>
{results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
The key insights: manage timer IDs with useRef, memoize the function with useCallback, and always cleanup with clearTimeout on unmount to prevent memory leaks.
My next project was an infinite scroll feed. I hooked up a scroll event listener, but this time debounce was the wrong tool.
With debounce, you wait until the user "stops scrolling." But if someone scrolls fast to the bottom? They're stuck waiting for the debounce delay before new content loads. That's broken UX.
Here, throttle was the answer: "check periodically even while scrolling."
import { useEffect, useRef, useCallback } from 'react';
function useThrottle(callback, delay) {
const lastRan = useRef(Date.now());
const timeoutRef = useRef(null);
const throttledCallback = useCallback((...args) => {
const now = Date.now();
const timeSinceLastRan = now - lastRan.current;
if (timeSinceLastRan >= delay) {
callback(...args);
lastRan.current = now;
} else {
// Optional: ensure trailing edge execution
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
callback(...args);
lastRan.current = Date.now();
}, delay - timeSinceLastRan);
}
}, [callback, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return throttledCallback;
}
// Infinite scroll example
function InfiniteScrollFeed() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const loadMore = async () => {
if (loading) return;
setLoading(true);
const response = await fetch(`/api/posts?page=${page}`);
const newItems = await response.json();
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
setLoading(false);
};
const handleScroll = () => {
const scrollTop = window.scrollY;
const windowHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
if (scrollTop + windowHeight >= docHeight - 200) {
loadMore();
}
};
const throttledScroll = useThrottle(handleScroll, 200);
useEffect(() => {
window.addEventListener('scroll', throttledScroll);
return () => window.removeEventListener('scroll', throttledScroll);
}, [throttledScroll]);
return (
<div>
{items.map(item => (
<div key={item.id}>{item.content}</div>
))}
{loading && <div>Loading...</div>}
</div>
);
}
Check scroll position every 200ms. Even if scroll events fire hundreds of times per second, the actual check only runs 5 times per second.
My first useDebounce hook behaved weirdly. The callback kept referencing old state values. Classic closure problem - it captured values from when the closure was created.
Solution: store the latest callback in a useRef. By always calling callbackRef.current, you get the freshest version every time.
Component unmounts but setTimeout is still running? Memory leak. Even worse, you might try to update state on an unmounted component and get React warnings.
Always cleanup in useEffect's return function - clearTimeout or remove event listeners.
Lodash's debounce/throttle have leading and trailing options:
For preventing button double-clicks, use leading. Process the first click immediately, then ignore subsequent clicks for a time period.
Now it's clear.
Use debounce when:Both are performance optimization techniques, but the timing strategy is completely different. Debounce says "only the last one matters." Throttle says "one per time period." Once I understood that distinction, choosing the right tool became intuitive.
More importantly, building these from scratch taught me why closures, useRef, useCallback, and cleanup matter in React. Using libraries is fine, but implementing it yourself is the real shortcut to understanding.