
How to Implement Debouncing and Throttling in Vanilla JavaScript and React
Web applications respond to user actions via event listeners. However, certain events (such as resizing window ports, scrolling pages, dragging elements, or typing into search bars) can fire dozens of times per second.
If these events trigger heavy DOM updates, layout calculations, or API network requests, they can block the main browser thread, resulting in laggy scrolling and high server loads.
To solve this, developers use two performance patterns: Debouncing and Throttling.
In this guide, we will analyze the differences between these two patterns, write custom implementations in vanilla JavaScript, and explore how to use them safely inside React hooks.
Debounce vs. Throttle: The Core Difference
Both techniques control how often a function executes over time, but they use different throttling strategies:
- Debounce delays the execution of a function until a specified idle time has passed since the last event trigger. If another event occurs before the idle window closes, the timer resets. It groups multiple rapid events into a single execution.
- Throttle guarantees that a function runs at most once in a specified time window. It limits the execution speed of a continuous action stream.
Implementing Debounce in Vanilla JavaScript
Debouncing is ideal for search boxes. You want to wait until the user stops typing before making an API call, avoiding redundant network queries.
Here is the vanilla JavaScript implementation using closures and timers:
function debounce<T extends (...args: any[]) => void>(func: T, delay: number): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (...args: Parameters<T>) {
// Clear the active timer
if (timeoutId) {
clearTimeout(timeoutId);
}
// Set a new timer
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}Implementing Throttle in Vanilla JavaScript
Throttling is ideal for scroll listeners. If you want to check if a user has scrolled near the bottom of a page to trigger infinite loading, you do not need to check on every pixel. Checking once every 200ms is sufficient.
Here is a timestamp-based implementation of throttling:
function throttle<T extends (...args: any[]) => void>(func: T, limit: number): (...args: Parameters<T>) => void {
let lastFunc: ReturnType<typeof setTimeout> | null = null;
let lastRan: number = 0;
return function (...args: Parameters<T>) {
const now = Date.now();
if (!lastRan) {
func(...args);
lastRan = now;
} else {
if (lastFunc) {
clearTimeout(lastFunc);
}
lastFunc = setTimeout(() => {
if (Date.now() - lastRan >= limit) {
func(...args);
lastRan = Date.now();
}
}, limit - (now - lastRan));
}
};
}Implementing Debounce inside React
Using debounce inside React is tricky. If you wrap a function in debounce inside a standard React component, the debounced function is recreated on every render pass, invalidating the internal timer closures.
To fix this, you must store the debounced function inside a useRef reference, ensuring it persists across renders.
Here is a custom useDebounce hook in React:
import { useEffect, useRef } from 'react';
export function useDebounceCallback<T extends (...args: any[]) => void>(callback: T, delay: number) {
const callbackRef = useRef(callback);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep callback reference updated
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Clean up timer on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return function (...args: Parameters<T>) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
};
}Usage Example: Search Component
export function SearchInput() {
const handleSearch = useDebounceCallback((query: string) => {
console.log('API Request sent for query:', query);
// Execute fetch here...
}, 500);
return (
<input
type="text"
placeholder="Type to search..."
onChange={(e) => handleSearch(e.target.value)}
class="border p-2 rounded"
/>
);
}Conclusion
Both debouncing and throttling are essential performance utilities. Use debouncing when you want to group multiple triggers into a single final execution (like autocomplete search inputs). Use throttling when you want to regulate the execution rate of continuous streams of events (like scrolling, resizing, or click actions).