Throttle & Debounce
Throttle & Debounce
Two of the most essential performance patterns in frontend development. Both control how often a function executes, but they serve different purposes and behave differently.
The Problem They Solve
Consider these common scenarios:
// Without rate limiting - disaster waiting to happen
window.addEventListener("scroll", () => {
// This fires 100+ times per second during scroll!
calculatePosition();
updateUI();
fetchData(); // API hammering
});
window.addEventListener("resize", () => {
// Fires continuously during resize
recalculateLayout();
rerender();
});
searchInput.addEventListener("input", (e) => {
// Every keystroke = API call
fetch(`/api/search?q=${e.target.value}`);
});The consequences:
- Janky, unresponsive UI
- Wasted CPU cycles
- Excessive API calls (rate limiting, costs)
- Battery drain on mobile devices
- Memory pressure from queued operations
Debounce: Wait Until Activity Stops
Debounce delays execution until after a period of inactivity. The function only runs once the user stops triggering events.
Mental Model
Think of an elevator door:
- Someone approaches → door waits
- Another person approaches → door waits again (timer resets)
- No one else → door finally closes
Implementation from Scratch
function debounce(func, delay) {
let timeoutId;
return function debounced(...args) {
// Remember the context
const context = this;
// Clear any existing timer
clearTimeout(timeoutId);
// Set a new timer
timeoutId = setTimeout(() => {
func.apply(context, args);
}, delay);
};
}Enhanced Implementation with Options
function debounce(func, delay, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
let result;
let lastCallTime;
const { leading = false, trailing = true, maxWait } = options;
function invokeFunc(time) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = undefined;
result = func.apply(thisArg, args);
return result;
}
function startTimer(pendingFunc, wait) {
return setTimeout(pendingFunc, wait);
}
function cancelTimer() {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
function trailingEdge(time) {
timeoutId = undefined;
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = lastThis = undefined;
return result;
}
function debounced(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timeoutId === undefined && leading) {
return invokeFunc(time);
}
}
cancelTimer();
timeoutId = startTimer(() => trailingEdge(Date.now()), delay);
return result;
}
function shouldInvoke(time) {
return timeoutId === undefined;
}
// Cancel pending invocation
debounced.cancel = function () {
cancelTimer();
lastArgs = lastThis = timeoutId = undefined;
};
// Immediately invoke if pending
debounced.flush = function () {
if (timeoutId !== undefined) {
return trailingEdge(Date.now());
}
return result;
};
// Check if there's a pending invocation
debounced.pending = function () {
return timeoutId !== undefined;
};
return debounced;
}Real-World Scenarios
1. Search Autocomplete
const searchInput = document.getElementById("search");
const resultsContainer = document.getElementById("results");
async function performSearch(query) {
if (!query.trim()) {
resultsContainer.innerHTML = "";
return;
}
try {
resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const results = await response.json();
renderResults(results);
} catch (error) {
resultsContainer.innerHTML = '<div class="error">Search failed</div>';
}
}
// Only search 300ms after user stops typing
const debouncedSearch = debounce(performSearch, 300);
searchInput.addEventListener("input", (e) => {
debouncedSearch(e.target.value);
});
// Clean up on page unload
window.addEventListener("beforeunload", () => {
debouncedSearch.cancel();
});2. Form Auto-Save
class AutoSaveForm {
constructor(formElement, saveEndpoint) {
this.form = formElement;
this.endpoint = saveEndpoint;
this.saveIndicator = document.getElementById("save-indicator");
// Debounce save to avoid excessive API calls
this.debouncedSave = debounce(this.save.bind(this), 1000, {
maxWait: 5000, // Force save at least every 5 seconds during continuous editing
});
this.setupListeners();
}
setupListeners() {
// Save on any input change
this.form.addEventListener("input", () => {
this.showSaving();
this.debouncedSave();
});
// Immediate save before leaving
window.addEventListener("beforeunload", (e) => {
if (this.debouncedSave.pending()) {
this.debouncedSave.flush();
e.preventDefault();
e.returnValue = "";
}
});
}
showSaving() {
this.saveIndicator.textContent = "Saving...";
this.saveIndicator.className = "saving";
}
async save() {
const formData = new FormData(this.form);
try {
await fetch(this.endpoint, {
method: "POST",
body: formData,
});
this.saveIndicator.textContent = "Saved";
this.saveIndicator.className = "saved";
} catch (error) {
this.saveIndicator.textContent = "Save failed";
this.saveIndicator.className = "error";
}
}
}3. Window Resize Handler
function handleResize() {
const width = window.innerWidth;
const height = window.innerHeight;
// Expensive layout calculations
recalculateGrid(width, height);
repositionElements();
updateCharts();
}
// Only recalculate after resize ends
const debouncedResize = debounce(handleResize, 250);
window.addEventListener("resize", debouncedResize);Throttle: Rate Limit Execution
Throttle ensures a function executes at most once per specified time period. Unlike debounce, it guarantees regular execution during continuous activity.
Mental Model
Think of a rate limiter:
- You can only post one message per second
- Additional attempts within that second are ignored
- After the second passes, you can post again
Implementation from Scratch
function throttle(func, limit) {
let inThrottle;
let lastResult;
return function throttled(...args) {
const context = this;
if (!inThrottle) {
lastResult = func.apply(context, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
return lastResult;
};
}Enhanced Implementation with Leading/Trailing
function throttle(func, limit, options = {}) {
let timeoutId;
let lastArgs;
let lastThis;
let result;
let lastCallTime = 0;
const { leading = true, trailing = true } = options;
function invokeFunc() {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = undefined;
lastCallTime = Date.now();
result = func.apply(thisArg, args);
return result;
}
function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime;
return limit - timeSinceLastCall;
}
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime;
return lastCallTime === 0 || timeSinceLastCall >= limit;
}
function trailingEdge() {
timeoutId = undefined;
if (trailing && lastArgs) {
return invokeFunc();
}
lastArgs = lastThis = undefined;
return result;
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge();
}
timeoutId = setTimeout(timerExpired, remainingWait(time));
}
function throttled(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
lastThis = this;
if (isInvoking) {
if (timeoutId === undefined) {
if (leading) {
return invokeFunc();
}
lastCallTime = time;
}
}
if (timeoutId === undefined && trailing) {
timeoutId = setTimeout(timerExpired, remainingWait(time));
}
return result;
}
throttled.cancel = function () {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
lastCallTime = 0;
lastArgs = lastThis = timeoutId = undefined;
};
throttled.flush = function () {
if (timeoutId !== undefined) {
return trailingEdge();
}
return result;
};
return throttled;
}Real-World Scenarios
1. Scroll Position Tracking
class ScrollTracker {
constructor(options = {}) {
this.sections = document.querySelectorAll("[data-section]");
this.navItems = document.querySelectorAll("[data-nav-item]");
this.onSectionChange = options.onSectionChange || (() => {});
// Throttle to 100ms - smooth but not excessive
this.throttledUpdate = throttle(this.updateActiveSection.bind(this), 100);
window.addEventListener("scroll", this.throttledUpdate, { passive: true });
}
updateActiveSection() {
const scrollPosition = window.scrollY + window.innerHeight / 3;
let currentSection = null;
this.sections.forEach((section) => {
const top = section.offsetTop;
const bottom = top + section.offsetHeight;
if (scrollPosition >= top && scrollPosition < bottom) {
currentSection = section.dataset.section;
}
});
if (currentSection) {
this.highlightNavItem(currentSection);
this.onSectionChange(currentSection);
}
}
highlightNavItem(sectionId) {
this.navItems.forEach((item) => {
item.classList.toggle("active", item.dataset.navItem === sectionId);
});
}
destroy() {
window.removeEventListener("scroll", this.throttledUpdate);
this.throttledUpdate.cancel();
}
}2. Mouse Move for Interactive Elements
class InteractiveBackground {
constructor(container) {
this.container = container;
this.particles = this.createParticles();
// Throttle mouse tracking to 16ms (60fps)
this.throttledMouseMove = throttle(this.handleMouseMove.bind(this), 16);
container.addEventListener("mousemove", this.throttledMouseMove);
}
handleMouseMove(event) {
const rect = this.container.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
this.updateParticles(x, y);
}
updateParticles(mouseX, mouseY) {
this.particles.forEach((particle) => {
const dx = mouseX - particle.x;
const dy = mouseY - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Repel particles from mouse
if (distance < 0.2) {
particle.vx -= dx * 0.05;
particle.vy -= dy * 0.05;
}
});
}
createParticles() {
// Create particle array
return Array.from({ length: 100 }, () => ({
x: Math.random(),
y: Math.random(),
vx: 0,
vy: 0,
}));
}
}3. API Polling with Rate Limit
class RateLimitedPoller {
constructor(endpoint, interval = 5000) {
this.endpoint = endpoint;
this.interval = interval;
this.subscribers = new Set();
this.isPolling = false;
// Throttle fetch to prevent accidental rapid calls
this.throttledFetch = throttle(
this.fetchData.bind(this),
1000, // Minimum 1 second between fetches
);
}
async fetchData() {
try {
const response = await fetch(this.endpoint);
const data = await response.json();
this.notifySubscribers(data);
return data;
} catch (error) {
this.notifySubscribers(null, error);
throw error;
}
}
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
notifySubscribers(data, error = null) {
this.subscribers.forEach((callback) => callback(data, error));
}
start() {
if (this.isPolling) return;
this.isPolling = true;
this.throttledFetch(); // Immediate first fetch
this.pollInterval = setInterval(() => {
this.throttledFetch();
}, this.interval);
}
stop() {
this.isPolling = false;
clearInterval(this.pollInterval);
}
// Manual refresh (throttled)
refresh() {
return this.throttledFetch();
}
}Debounce vs Throttle: Decision Guide
When to Use Debounce
| Scenario | Why Debounce |
|---|---|
| Search input autocomplete | Only search after user stops typing |
| Form validation | Validate after user finishes input |
| Window resize | Recalculate only after resize ends |
| Auto-save drafts | Save after editing pauses |
| Preventing double-clicks | Execute only once after clicking stops |
When to Use Throttle
| Scenario | Why Throttle |
|---|---|
| Scroll position tracking | Need regular updates during scroll |
| Mouse movement effects | Smooth animation at controlled rate |
| Game loop inputs | Consistent input processing |
| Analytics events | Regular but controlled event firing |
| Infinite scroll loading | Trigger at intervals while scrolling |
Visual Comparison
User Events: ●-●●--●-●●●●--●-●--●●●●●--●
|__|__|__|__|__|__|__|__|__|
Debounce: ----------------------●-----●
(300ms delay) Waits for silence, then fires
Throttle: ●-----●-----●-----●-----●---
(300ms limit) Fires at regular intervalsAdvanced Pattern: Debounced Throttle
Sometimes you need both: regular updates during activity, but also a final update after activity stops.
function throttleDebounce(func, throttleLimit, debounceDelay) {
const throttled = throttle(func, throttleLimit);
const debounced = debounce(func, debounceDelay);
return function (...args) {
throttled.apply(this, args); // Regular updates
debounced.apply(this, args); // Final update after stop
};
}
// Usage: Update every 100ms during scroll, plus final update
const handleScroll = throttleDebounce(
updateScrollPosition,
100, // Throttle: every 100ms
150, // Debounce: 150ms after stop
);React Hooks
useDebounce Hook
import { useState, useEffect, useRef, 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;
}
function useDebouncedCallback(callback, delay, deps = []) {
const callbackRef = useRef(callback);
const timeoutRef = useRef();
// Update callback ref when callback changes
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback(
(...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay, ...deps],
);
}
// Usage
function SearchComponent() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
// Debounce the search query
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery).then(setResults);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}useThrottle Hook
import { useState, useEffect, useRef, useCallback } from "react";
function useThrottle(value, limit) {
const [throttledValue, setThrottledValue] = useState(value);
const lastRan = useRef(Date.now());
useEffect(() => {
const handler = setTimeout(
() => {
if (Date.now() - lastRan.current >= limit) {
setThrottledValue(value);
lastRan.current = Date.now();
}
},
limit - (Date.now() - lastRan.current),
);
return () => clearTimeout(handler);
}, [value, limit]);
return throttledValue;
}
function useThrottledCallback(callback, limit, deps = []) {
const lastRan = useRef(0);
const timeoutRef = useRef();
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback(
(...args) => {
const now = Date.now();
const remaining = limit - (now - lastRan.current);
if (remaining <= 0) {
lastRan.current = now;
callbackRef.current(...args);
} else if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
lastRan.current = Date.now();
timeoutRef.current = null;
callbackRef.current(...args);
}, remaining);
}
},
[limit, ...deps],
);
}
// Usage
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const handleScroll = useThrottledCallback(() => {
setScrollY(window.scrollY);
}, 100);
useEffect(() => {
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
return <div>Scroll position: {scrollY}</div>;
}Performance Considerations
Memory and Cleanup
class Component {
constructor() {
// Store references for cleanup
this.debouncedHandler = debounce(this.handleEvent.bind(this), 300);
this.throttledHandler = throttle(this.handleOther.bind(this), 100);
window.addEventListener("resize", this.debouncedHandler);
window.addEventListener("scroll", this.throttledHandler);
}
destroy() {
// IMPORTANT: Cancel pending executions
this.debouncedHandler.cancel();
this.throttledHandler.cancel();
// Remove listeners
window.removeEventListener("resize", this.debouncedHandler);
window.removeEventListener("scroll", this.throttledHandler);
}
}requestAnimationFrame Alternative
For visual updates, consider requestAnimationFrame instead of throttle:
function rafThrottle(callback) {
let requestId = null;
let lastArgs = null;
const later = () => {
requestId = null;
callback(...lastArgs);
};
const throttled = (...args) => {
lastArgs = args;
if (requestId === null) {
requestId = requestAnimationFrame(later);
}
};
throttled.cancel = () => {
if (requestId !== null) {
cancelAnimationFrame(requestId);
requestId = null;
}
};
return throttled;
}
// Perfectly synced with browser refresh rate
const updateAnimation = rafThrottle((x, y) => {
element.style.transform = `translate(${x}px, ${y}px)`;
});Common Mistakes to Avoid
1. Creating New Functions in Render
// ❌ BAD: Creates new debounced function every render
function SearchBad() {
const [query, setQuery] = useState("");
return <input onChange={debounce((e) => setQuery(e.target.value), 300)} />;
}
// ✅ GOOD: Stable reference
function SearchGood() {
const [query, setQuery] = useState("");
const debouncedSetQuery = useMemo(
() => debounce((value) => setQuery(value), 300),
[],
);
useEffect(() => {
return () => debouncedSetQuery.cancel();
}, [debouncedSetQuery]);
return <input onChange={(e) => debouncedSetQuery(e.target.value)} />;
}2. Not Cleaning Up
// ❌ BAD: Memory leak
useEffect(() => {
const handler = debounce(handleResize, 300);
window.addEventListener("resize", handler);
}, []);
// ✅ GOOD: Proper cleanup
useEffect(() => {
const handler = debounce(handleResize, 300);
window.addEventListener("resize", handler);
return () => {
handler.cancel(); // Cancel pending
window.removeEventListener("resize", handler);
};
}, []);Summary
| Aspect | Debounce | Throttle |
|---|---|---|
| Execution | After activity stops | At regular intervals |
| Use Case | Final state matters | Progress matters |
| Examples | Search, resize, save | Scroll, mouse, games |
| Delay | From last event | From last execution |
Both patterns are essential tools for building performant frontends. Choose based on whether you need to respond during continuous activity (throttle) or only after it stops (debounce).