If you are a front end dev you NEED to know about that (Throttle vs. Debounce)

Performance and UX live and die on the client. Two small utilities—throttle and debounce—solve a surprising number of real-world problems: from taming noisy scroll/resize events to making type-ahead search feel instantaneous without DDoS-ing your backend. This post goes beyond one-liners and covers when to use each, solid implementations, React patterns, and common pitfalls.
Mental Models
- Debounce collects rapid-fire calls and emits the last one after a quiet period.
- Throttle guarantees a maximum execution rate (e.g., at most once every 200ms), optionally firing leading and/or trailing calls.
Plain JavaScript Implementations
Debounce (simple + cancel/flush)
function debounce(fn, wait, immediate = false) {
let timerId = null;
let lastArgs;
let lastThis;
function later() {
const context = lastThis;
const args = lastArgs;
timerId = null;
if (!immediate) fn.apply(context, args);
}
const debounced = function (...args) {
lastArgs = args;
lastThis = this;
const callNow = immediate && !timerId;
clearTimeout(timerId);
timerId = setTimeout(later, wait);
if (callNow) fn.apply(lastThis, lastArgs);
};
debounced.cancel = () => {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
debounced.flush = () => {
if (timerId) {
clearTimeout(timerId);
const context = lastThis;
const args = lastArgs;
timerId = null;
fn.apply(context, args);
}
};
return debounced;
}
Throttle (leading/trailing + cancel)
function throttle(fn, wait, { leading = true, trailing = true } = {}) {
let timerId = null;
let lastCallTime = 0;
let lastArgs;
let lastThis;
function invoke() {
lastCallTime = Date.now();
fn.apply(lastThis, lastArgs);
lastArgs = lastThis = null;
}
const throttled = function (...args) {
const now = Date.now();
if (!lastCallTime && leading === false) {
lastCallTime = now;
}
const remaining = wait - (now - lastCallTime);
lastArgs = args;
lastThis = this;
if (remaining <= 0 || remaining > wait) {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
invoke();
} else if (!timerId && trailing !== false) {
timerId = setTimeout(() => {
timerId = null;
if (trailing !== false) invoke();
}, remaining);
}
};
throttled.cancel = () => {
if (timerId) clearTimeout(timerId);
timerId = null;
lastCallTime = 0;
lastArgs = lastThis = null;
};
return throttled;
}
Practical Uses
Debounce Search (prevent request storms)
const searchInput = document.querySelector('#search');
async function fetchResults(query) {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return res.json();
}
const onInput = debounce(async (e) => {
const q = e.target.value.trim();
if (!q) return;
const data = await fetchResults(q);
console.log('Results:', data);
}, 300);
searchInput.addEventListener('input', onInput);
Throttle Scroll (smooth performance)
const onScroll = throttle(() => {
// e.g., update scroll-based UI, analytics, lazy-load check
console.log('Scroll position:', window.scrollY);
}, 200, { leading: true, trailing: true });
window.addEventListener('scroll', onScroll, { passive: true });
requestAnimationFrame Throttle (buttery animations)
function rafThrottle(fn) {
let running = false;
return (...args) => {
if (running) return;
running = true;
requestAnimationFrame(() => {
fn(...args);
running = false;
});
};
}
const onMouseMove = rafThrottle((e) => {
// UI paint work here
});
window.addEventListener('mousemove', onMouseMove);
React Patterns
useDebouncedValue (derive debounced state)
import { useEffect, useState } from 'react';
export function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// Usage: const debouncedQuery = useDebouncedValue(query, 300);
useThrottleCallback (stable throttled handler)
import { useMemo, useRef, useEffect } from 'react';
function throttle(fn, wait, options) {
// ...same throttle as above...
}
export function useThrottleCallback(fn, wait, options) {
const fnRef = useRef(fn);
useEffect(() => { fnRef.current = fn; }, [fn]);
const throttled = useMemo(() =>
throttle((...args) => fnRef.current(...args), wait, options),
[wait, options]
);
useEffect(() => throttled.cancel, [throttled]);
return throttled;
}
// Usage: const onScroll = useThrottleCallback(handleScroll, 200, { trailing: true });
Debounced fetch with AbortController (cancel stale requests)
const search = (() => {
let controller;
return async (q) => {
if (controller) controller.abort();
controller = new AbortController();
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
return res.json();
};
})();
const debouncedSearch = debounce(search, 300);
Lodash Equivalents
_.debounce(fn, wait, { leading, trailing, maxWait })
_.throttle(fn, wait, { leading, trailing })
Both return functions with .cancel()
and .flush()
methods. If you can depend on Lodash, use it—it’s well-tested and handles edge cases.
Common Pitfalls & Edge Cases
- Losing
this
/arguments: make sure your wrappers usefn.apply(this, args)
. - Double-fires: combining
{ leading: true, trailing: true }
can fire both at start and end. Be intentional. - Async functions: throttling/debouncing doesn’t queue Promises. Handle concurrency (e.g., ignore stale responses, use
AbortController
). - Cleanup in React: always cancel timers on unmount to avoid state updates on unmounted components.
- Passive listeners: use
{ passive: true }
for scroll/touch for better performance. - SSR: debounce/throttle are no-ops server-side; guard browser-only APIs.
- Time drift: for visual work, prefer
requestAnimationFrame
over time-based throttling.
When to Use Which
- Typing, resize end, auto-save after pause → Debounce
- Continuous input (scroll, drag, mousemove), analytics beacons → Throttle
- Animations/paints →
requestAnimationFrame
throttle - Network: prefer canceling stale requests vs. sending all and reconciling results
Bonus: Testing Tips
- Use fake timers (e.g., Jest) to step time deterministically.
- Assert both leading and trailing behaviors.
- Verify
.cancel()
prevents calls and.flush()
forces immediate execution.
Backend Note: Throttling vs. Rate Limiting
Frontend throttling is a client-side pacing strategy. Server-side rate limiting enforces quotas per client or token. They complement each other: throttle on the client for UX, rate-limit on the server for protection.
Key Takeaways
- Debounce reduces noise; throttle caps frequency.
- Preserve context/args, offer cancel/flush, and be explicit about leading/trailing.
- Pair with
AbortController
, passive listeners, and rAF for best UX. - Prefer a well-tested util (e.g., Lodash) unless you have strong reasons to roll your own.