Debouncing vs Throttling in JavaScript — When to Use Each
Photo by Ferenc Almasi on Unsplash
Table of Contents
The Search Box That Called the API 300 Times
For a word like "python", that's six API calls, five of which return results nobody will ever see. On a fast connection, it just looked a little jittery. On a slow connection or with a rate-limited API, it caused actual errors. My backend team was less than thrilled.
The solution was debouncing — and understanding the difference between debouncing and throttling has saved me from more than a few production bugs since then. They look similar on the surface (both limit how often a function runs) but they work in completely different ways and solve completely different problems.
I see developers mix them up constantly, use the wrong one, then wonder why their scroll handler is still freezing or their search is still spamming the API. So let's clear it up once and for all.
Debouncing — Wait Until Things Settle Down
Every time the event fires, you reset the timer. The function only actually executes when the timer finally completes without being reset.
Here's a minimal implementation:
```javascript function debounce(fn, delay) { let timeoutId; return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => { fn.apply(this, args); }, delay); }; }
// Usage const handleSearch = debounce((query) => { fetchSearchResults(query); }, 300);
input.addEventListener('input', (e) => handleSearch(e.target.value)); ```
With a 300ms delay, the `fetchSearchResults` call only fires when the user pauses typing for at least 300 milliseconds. If they type "python" quickly (under 300ms between each key), the API gets called exactly once — with the complete word.
The mental model I use: imagine a motion-sensor light. It only turns off after the room has been empty for 30 seconds. Every time someone moves, the timer resets. Debounce works the same way — the timer resets on every event, and the action only happens after the events stop.
When to use debounce: - Search inputs (only fetch after user pauses) - Form validation (only validate after user stops typing) - Auto-save (save draft only after user stops editing) - Window resize handlers (only recalculate layout after resize is done) - API calls triggered by input events
Throttling — Allow a Maximum Rate of Execution
Photo by Alexandre Debiève on Unsplash
Unlike debouncing, throttling guarantees the function gets called at regular intervals while events are firing. You don't wait for them to stop — you cap the frequency.
Here's a minimal throttle implementation:
```javascript function throttle(fn, limit) { let lastCall = 0; return function (...args) { const now = Date.now(); if (now - lastCall >= limit) { lastCall = now; return fn.apply(this, args); } }; }
// Usage const handleScroll = throttle(() => { updateProgressBar(); }, 100);
window.addEventListener('scroll', handleScroll); ```
With a 100ms limit, `updateProgressBar` runs at most 10 times per second — even if the scroll event fires 60+ times per second (which it does on modern browsers). The UI stays smooth because the heavy work is capped, but unlike debounce, it actually runs *during* the scroll — not just when it stops.
The mental model: imagine a tap that drips at a fixed rate no matter how much pressure you put on it. The pressure (events) can vary wildly, but the output (function calls) stays metered.
When to use throttle: - Scroll event handlers (update a sticky nav or progress indicator) - Mouse move tracking (tooltip positioning, canvas drawing) - Game input handling (read keys at a fixed frame rate) - Resize handlers where you need continuous updates (not just the final state) - Real-time analytics events (track user engagement without flooding your server)
The Key Difference — Leading vs Trailing Execution
Trailing execution (the default): the function runs *after* the delay/period completes. In debounce, this means it runs when typing stops. In throttle, it means it runs at the *end* of each time window.
Leading execution: the function runs *immediately* on the first call, then is blocked for the delay/period. Useful when you want instant feedback on the first trigger but not on rapid repetitions.
Lodash's `_.debounce` gives you both options:
```javascript // Leading: fires immediately on first call, then waits 300ms before allowing another const debouncedFn = _.debounce(myFn, 300, { leading: true, trailing: false });
// Trailing (default): fires 300ms after the last call const debouncedFn = _.debounce(myFn, 300);
// Both leading and trailing: fires immediately AND at the end const debouncedFn = _.debounce(myFn, 300, { leading: true, trailing: true }); ```
For a button that submits a form, leading debounce is often the right call — you want the form to submit on the first click (instant feedback) but ignore rapid double-clicks. For search-as-you-type, trailing is correct — you want to wait until the user pauses.
I'd recommend reaching for Lodash's `debounce` and `throttle` over rolling your own unless you have a reason not to. The lodash.debounce source on npm is available as a standalone package (about 1.5kb minified) if you don't want the full library. It handles edge cases — like clearing debounced functions when components unmount — that the naive implementation I showed above doesn't.
React Hooks — Debounce and Throttle in Components
The naive approach breaks:
```javascript // DON'T do this — creates a new debounced function on every render function SearchInput() { const handleInput = debounce((val) => fetchResults(val), 300); // Bug! return <input onInput={(e) => handleInput(e.target.value)} />; } ```
Every render creates a fresh `debounced` function with a fresh internal timer. So each keystroke both resets the debounce AND creates a new one. The debounce never fires.
Fix it with `useCallback` and `useRef`:
```javascript function SearchInput() { const fetchRef = useRef(null);
useEffect(() => { fetchRef.current = debounce((val) => fetchResults(val), 300); return () => fetchRef.current?.cancel?.(); // cleanup on unmount }, []);
const handleInput = useCallback((e) => { fetchRef.current?.(e.target.value); }, []);
return <input onInput={handleInput} />; } ```
Or use a library like `use-debounce` which handles this correctly:
```javascript import { useDebounce } from 'use-debounce';
function SearchInput() { const [value, setValue] = useState(''); const [debouncedValue] = useDebounce(value, 300);
useEffect(() => { if (debouncedValue) fetchResults(debouncedValue); }, [debouncedValue]);
return <input value={value} onChange={(e) => setValue(e.target.value)} />; } ```
Clean, readable, and doesn't leak timers on unmount. This is the approach I use in every React project now.
If you're debugging the API requests that result from search input — checking payloads, inspecting query parameters — the JSON formatter makes quick work of those response blobs. And for encoding query strings that contain special characters, the URL encoder is handy when building search URLs manually. I wrote about URL encoding edge cases earlier if you want the full picture.
Performance — When This Stuff Actually Matters
Scroll events can fire 60-100 times per second on modern browsers with smooth scrolling enabled. A `mousemove` event on an active canvas can fire at 200+ Hz. An `input` event fires on every single character change.
If your scroll handler reads from the DOM (like checking `element.getBoundingClientRect()`), that triggers a layout reflow — a computationally expensive operation that can block the main thread. Do that 60 times per second and you'll see frame drops, especially on lower-end devices.
Throttling a scroll handler to 10 calls per second (every 100ms) means you do the DOM read 10 times instead of 60 — a 6x reduction with virtually no noticeable difference in UX, since humans can't perceive updates faster than about 10-15 times per second for most scroll-linked effects.
Here's the real-world impact I've measured. On a project with an animated sticky header that repositioned elements on scroll, the throttled version at 100ms ran at a consistent 60fps. The unthrottled version dropped to 30fps on a mid-tier Android device, causing the scroll itself to feel sluggish.
The rule I follow: any event that fires more than a few times per second (scroll, resize, input, mousemove) should have some rate limiting. The choice between debounce and throttle comes down to one question: **do I need the function to fire *during* the events, or just *after* they stop?**
- During → throttle - After → debounce
Get that distinction right and you won't mix them up again.
One more thing: if you're writing utility functions for debounce/throttle in TypeScript, the type signatures are a bit fiddly. The post on TypeScript vs JavaScript covers some of the gotchas around typing higher-order functions that wrap arbitrary callbacks — relevant if you're building your own utility library.
Here's the rule of thumb I follow now: if a handler runs more than once per second on a real device, wrap it. If it touches the DOM or fires a network call, definitely wrap it. The cost of adding `debounce` or `throttle` is roughly zero. The cost of forgetting is a janky scroll, a 500-call API bill, or a mysterious memory leak that takes you three hours to track down on a Sunday night.
Frequently Asked Questions
What's the difference between debounce and throttle in simple terms?
Debounce waits until events stop firing before running the function — like a search bar that only fetches results when you pause typing. Throttle runs the function at a fixed maximum rate while events are firing — like a scroll handler that updates a progress bar at most 10 times per second. Debounce is for 'wait until it's done,' throttle is for 'limit how often this runs.'
Should I write my own debounce or use a library?
For simple cases, a minimal implementation works fine. But library versions (Lodash's debounce/throttle, or the use-debounce React hook) handle edge cases you might not think of: cancellation, leading vs trailing execution, flushing pending calls, and cleanup on component unmount. Unless you have a strong reason to avoid the dependency, I'd use the library version in production code.
How do I choose the right delay value for debounce?
For search inputs, 300-400ms is the standard — enough time to feel instant while preventing rapid API calls. For form validation, 500-700ms feels natural (validate after the user pauses, not on every keystroke). For auto-save, 1000-2000ms is typical. For scroll and resize handlers with throttle, 100-150ms (roughly 7-10 calls per second) is a good starting point. Adjust based on feel — if users notice the lag, lower the delay; if your API is getting hammered, raise it.
Can I use debounce with async functions?
Yes, but you need to handle the return value carefully. A debounced async function doesn't return the promise from the inner async call — it returns undefined from the debounce wrapper. The actual API call happens later when the debounce fires. The cleanest pattern is to update state inside the debounced function itself (rather than awaiting the debounced call) and let React or your framework re-render from the state update.
What happens if a component unmounts before a debounced function fires?
If the debounce timer is still pending when the component unmounts, the inner function will try to run anyway — which can cause 'cannot update state on an unmounted component' warnings or errors. Fix this by canceling the debounce timer in a useEffect cleanup function. Lodash's debounce returns an object with a .cancel() method for exactly this purpose. The use-debounce hook handles cleanup automatically.
Is throttle better than requestAnimationFrame for scroll animations?
For scroll-linked visual animations, requestAnimationFrame (rAF) is actually better than throttle in most cases. rAF syncs your updates to the browser's paint cycle (typically 60fps), which means smoother animations with less jank than a 16ms throttle. Use rAF when you're doing visual work tied to scroll position. Use throttle for non-visual scroll work like firing analytics events or making API calls, where exact frame timing doesn't matter.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools