Debouncing and Throttling in JavaScript: Performance Optimization Techniques
2025-12-03
When building interactive web applications, you’ll encounter events that fire frequently—scroll events, resize events, input changes, button clicks, and more. Without optimization, these events can trigger expensive operations hundreds of times per second, causing performance issues and poor user experience.
Debouncing and throttling are two essential techniques to control how often functions execute, improving performance and reducing unnecessary computations.
The Problem: Too Many Function Calls
Consider a search input that makes API calls as the user types:
// ❌ BAD: Makes API call on every keystroke
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', async (e) => {
const query = e.target.value;
const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
displayResults(results);
});
Problem: If a user types “javascript”, this makes 10 API calls (one per character). This is:
- Wasteful (unnecessary network requests)
- Slow (multiple requests compete)
- Expensive (server load)
- Poor UX (results flicker as they update)
Similarly, scroll and resize events can fire dozens of times per second:
// ❌ BAD: Expensive operation on every scroll
window.addEventListener('scroll', () => {
updateStickyHeader(); // Expensive DOM manipulation
trackScrollPosition(); // Analytics call
lazyLoadImages(); // Image loading logic
});
Debouncing: Wait for a Pause
Debouncing delays function execution until after a specified time has passed since the last event. It’s like saying: “Wait until the user stops doing this action, then execute.”
Use Cases for Debouncing
- Search inputs: Wait until user stops typing
- Window resize: Wait until user finishes resizing
- Form validation: Wait until user stops typing before validating
- Save operations: Auto-save after user stops editing
How Debouncing Works
User types: "j" → "ja" → "jav" → "java" → [pauses 300ms] → API call
The function only executes once after the user stops typing.
Basic Debounce Implementation
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
Real-World Example: Search Input
// ✅ GOOD: Debounced search
const searchInput = document.getElementById('search');
async function performSearch(query) {
const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
displayResults(results);
}
// Debounce the search function (wait 300ms after user stops typing)
const debouncedSearch = debounce(performSearch, 300);
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
if (query.length > 2) {
debouncedSearch(query);
}
});
Result: API call only happens 300ms after the user stops typing.
Advanced Debounce: Immediate Execution Option
Sometimes you want to execute immediately on the first call, then debounce subsequent calls:
function debounce(func, wait, immediate = false) {
let timeout;
return function executedFunction(...args) {
const later = () => {
timeout = null;
if (!immediate) func(...args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func(...args);
};
}
// Usage: Execute immediately, then debounce
const debouncedSave = debounce(saveForm, 1000, true);
Throttling: Limit Execution Rate
Throttling limits how often a function can execute. It ensures the function runs at most once per specified time period, regardless of how many times the event fires.
Use Cases for Throttling
- Scroll events: Update UI at most once per 100ms
- Resize events: Recalculate layout at most once per 250ms
- Mouse move: Track cursor position, but not every pixel
- API polling: Check for updates, but not continuously
How Throttling Works
Scroll events: [fire] → [fire] → [fire] → [fire] → [fire] → ...
Throttled (100ms): [execute] → [skip] → [skip] → [execute] → [skip] → ...
The function executes at regular intervals, ignoring calls in between.
Basic Throttle Implementation
function throttle(func, limit) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
Real-World Example: Scroll Handler
// ✅ GOOD: Throttled scroll handler
function updateScrollIndicator() {
const scrollY = window.scrollY;
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const percentage = (scrollY / maxScroll) * 100;
document.getElementById('scroll-progress').style.width = `${percentage}%`;
}
// Throttle to run at most once per 100ms
const throttledUpdate = throttle(updateScrollIndicator, 100);
window.addEventListener('scroll', throttledUpdate);
Result: Scroll indicator updates smoothly without lag, even during fast scrolling.
Advanced Throttle: Leading and Trailing Options
Sometimes you want to execute on the leading edge (first call) or trailing edge (last call within the period):
function throttle(func, limit, options = {}) {
let timeout;
let previous = 0;
const { leading = true, trailing = true } = options;
return function executedFunction(...args) {
const now = Date.now();
if (!previous && !leading) previous = now;
const remaining = limit - (now - previous);
if (remaining <= 0 || remaining > limit) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(this, args);
} else if (!timeout && trailing) {
timeout = setTimeout(() => {
previous = !leading ? 0 : Date.now();
timeout = null;
func.apply(this, args);
}, remaining);
}
};
}
// Usage examples:
const leadingThrottle = throttle(updateUI, 100, { leading: true, trailing: false });
const trailingThrottle = throttle(updateUI, 100, { leading: false, trailing: true });
Debounce vs Throttle: When to Use Which?
Use Debounce When:
- You want to wait until the user stops performing an action
- The function should execute once after a series of events
- Examples: Search input, form validation, auto-save
// Debounce: Wait until user stops typing
const debouncedSearch = debounce(searchFunction, 300);
input.addEventListener('input', debouncedSearch);
Use Throttle When:
- You want to limit execution rate but still execute regularly
- The function should execute at intervals during continuous events
- Examples: Scroll handlers, resize handlers, mouse move tracking
// Throttle: Execute at most once per 100ms during scroll
const throttledScroll = throttle(scrollHandler, 100);
window.addEventListener('scroll', throttledScroll);
Visual Comparison
Events: |--|--|--|--|--|--|--|--|--|--|
Debounce: |--------------------------X| (executes after pause)
Throttle: |X--|--|--X--|--|--X--|--|--| (executes at intervals)
Real-World Examples
Example 1: Search with Debounce
class SearchComponent {
constructor(inputId, resultsId) {
this.input = document.getElementById(inputId);
this.results = document.getElementById(resultsId);
this.debouncedSearch = debounce(this.search.bind(this), 300);
this.input.addEventListener('input', (e) => this.handleInput(e));
}
handleInput(e) {
const query = e.target.value.trim();
if (query.length < 2) {
this.results.innerHTML = '';
return;
}
this.debouncedSearch(query);
}
async search(query) {
this.results.innerHTML = '<div>Searching...</div>';
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
this.displayResults(data);
} catch (error) {
this.results.innerHTML = '<div>Error searching</div>';
}
}
displayResults(data) {
// Render results...
}
}
Example 2: Infinite Scroll with Throttle
class InfiniteScroll {
constructor(container, loadMoreCallback) {
this.container = container;
this.loadMore = loadMoreCallback;
this.loading = false;
this.throttledCheck = throttle(this.checkScroll.bind(this), 200);
window.addEventListener('scroll', this.throttledCheck);
}
checkScroll() {
if (this.loading) return;
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Load more when 200px from bottom
if (scrollY + windowHeight >= documentHeight - 200) {
this.loading = true;
this.loadMore().finally(() => {
this.loading = false;
});
}
}
}
Example 3: Resize Handler with Debounce
// ✅ GOOD: Debounced resize (wait until user finishes resizing)
function handleResize() {
// Expensive layout recalculation
recalculateLayout();
updateBreakpoints();
repositionElements();
}
const debouncedResize = debounce(handleResize, 250);
window.addEventListener('resize', debouncedResize);
Example 4: Button Click with Throttle (Prevent Double-Clicks)
// ✅ GOOD: Throttle button clicks to prevent rapid double-clicks
function handleSubmit() {
// Submit form
submitForm();
}
const throttledSubmit = throttle(handleSubmit, 1000);
submitButton.addEventListener('click', throttledSubmit);
Modern Alternatives: requestAnimationFrame
For animations and visual updates, requestAnimationFrame can be more appropriate than throttling:
// ✅ GOOD: Using requestAnimationFrame for smooth animations
let ticking = false;
function updateOnScroll() {
// Update scroll-based animations
updateParallax();
updateStickyHeader();
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(updateOnScroll);
ticking = true;
}
});
requestAnimationFrame automatically syncs with the browser’s repaint cycle (typically 60fps), providing smoother animations than manual throttling.
Using Lodash (If Available)
If you’re using Lodash, it provides battle-tested implementations:
import { debounce, throttle } from 'lodash';
// Debounce
const debouncedSearch = debounce(searchFunction, 300);
// Throttle
const throttledScroll = throttle(scrollHandler, 100);
// With options
const debouncedSave = debounce(saveFunction, 1000, {
leading: false,
trailing: true,
maxWait: 5000
});
Best Practices
- Choose the right technique: Debounce for “wait until done”, throttle for “limit rate”
- Set appropriate delays:
- Search inputs: 300-500ms
- Scroll/resize: 100-250ms
- Button clicks: 1000ms (prevent double-clicks)
- Consider user experience: Too long delays feel unresponsive, too short defeats the purpose
- Clean up event listeners: Remove listeners when components unmount
- Test on slow devices: Ensure performance improvements are noticeable
- Use requestAnimationFrame for animations: Better than manual throttling for visual updates
Summary
Debouncing and throttling are essential tools for optimizing JavaScript performance:
- Debounce: Wait for pause, execute once (search, validation, auto-save)
- Throttle: Limit execution rate, execute at intervals (scroll, resize, mouse move)
- requestAnimationFrame: For smooth animations synced with browser repaint
By applying these techniques to event handlers, you’ll create more responsive, performant applications that provide a better user experience while reducing unnecessary computations and API calls.